chore: organize solution into module folders (Core/Server/Drivers/Client/Tooling)
Group all 69 projects into category subfolders under src/ and tests/ so the Rider Solution Explorer mirrors the module structure. Folders: Core, Server, Drivers (with a nested Driver CLIs subfolder), Client, Tooling. - Move every project folder on disk with git mv (history preserved as renames). - Recompute relative paths in 57 .csproj files: cross-category ProjectReferences, the lib/ HintPath+None refs in Driver.Historian.Wonderware, and the external mxaccessgw refs in Driver.Galaxy and its test project. - Rebuild ZB.MOM.WW.OtOpcUa.slnx with nested solution folders. - Re-prefix project paths in functional scripts (e2e, compliance, smoke SQL, integration, install). Build green (0 errors); unit tests pass. Docs left for a separate pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+99
@@ -0,0 +1,99 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Commands;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers <see cref="WriteCommand.ParseValue"/>. Every Logix atomic type has at least
|
||||
/// one happy-path case plus a failure case for unparseable input.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class WriteCommandParseValueTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("true", true)]
|
||||
[InlineData("0", false)]
|
||||
[InlineData("on", true)]
|
||||
[InlineData("NO", false)]
|
||||
public void ParseValue_Bool_accepts_common_aliases(string raw, bool expected)
|
||||
{
|
||||
WriteCommand.ParseValue(raw, AbCipDataType.Bool).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseValue_Bool_rejects_garbage()
|
||||
{
|
||||
Should.Throw<CliFx.Exceptions.CommandException>(
|
||||
() => WriteCommand.ParseValue("maybe", AbCipDataType.Bool));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseValue_SInt_widens_to_sbyte()
|
||||
{
|
||||
WriteCommand.ParseValue("-128", AbCipDataType.SInt).ShouldBe((sbyte)-128);
|
||||
WriteCommand.ParseValue("127", AbCipDataType.SInt).ShouldBe((sbyte)127);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseValue_Int_signed_16bit()
|
||||
{
|
||||
WriteCommand.ParseValue("-32768", AbCipDataType.Int).ShouldBe((short)-32768);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseValue_DInt_and_Dt_both_land_on_int()
|
||||
{
|
||||
WriteCommand.ParseValue("42", AbCipDataType.DInt).ShouldBeOfType<int>();
|
||||
WriteCommand.ParseValue("1234567", AbCipDataType.Dt).ShouldBeOfType<int>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseValue_LInt_64bit()
|
||||
{
|
||||
WriteCommand.ParseValue("9223372036854775807", AbCipDataType.LInt).ShouldBe(long.MaxValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseValue_unsigned_range_respects_bounds()
|
||||
{
|
||||
WriteCommand.ParseValue("255", AbCipDataType.USInt).ShouldBeOfType<byte>();
|
||||
WriteCommand.ParseValue("65535", AbCipDataType.UInt).ShouldBeOfType<ushort>();
|
||||
WriteCommand.ParseValue("4294967295", AbCipDataType.UDInt).ShouldBeOfType<uint>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseValue_Real_invariant_culture_decimal()
|
||||
{
|
||||
WriteCommand.ParseValue("3.14", AbCipDataType.Real).ShouldBe(3.14f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseValue_LReal_handles_double_precision()
|
||||
{
|
||||
WriteCommand.ParseValue("2.718281828", AbCipDataType.LReal).ShouldBeOfType<double>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseValue_String_passthrough()
|
||||
{
|
||||
WriteCommand.ParseValue("hello logix", AbCipDataType.String).ShouldBe("hello logix");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseValue_non_numeric_for_numeric_types_throws()
|
||||
{
|
||||
Should.Throw<FormatException>(
|
||||
() => WriteCommand.ParseValue("xyz", AbCipDataType.DInt));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Motor01_Speed", AbCipDataType.Real, "Motor01_Speed:Real")]
|
||||
[InlineData("Program:Main.Counter", AbCipDataType.DInt, "Program:Main.Counter:DInt")]
|
||||
[InlineData("Recipe[3]", AbCipDataType.Int, "Recipe[3]:Int")]
|
||||
public void SynthesiseTagName_preserves_path_verbatim(
|
||||
string path, AbCipDataType type, string expected)
|
||||
{
|
||||
ReadCommand.SynthesiseTagName(path, type).ShouldBe(expected);
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.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\Drivers\Cli\ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli\ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Commands;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers <see cref="WriteCommand.ParseValue"/>. PCCC types are narrower than AB CIP
|
||||
/// (no 64-bit, no unsigned variants, no Structure / Dt) so the matrix is smaller.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class WriteCommandParseValueTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("true", true)]
|
||||
[InlineData("0", false)]
|
||||
[InlineData("yes", true)]
|
||||
[InlineData("OFF", false)]
|
||||
public void ParseValue_Bit_accepts_common_aliases(string raw, bool expected)
|
||||
{
|
||||
WriteCommand.ParseValue(raw, AbLegacyDataType.Bit).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseValue_Int_signed_16bit()
|
||||
{
|
||||
WriteCommand.ParseValue("-32768", AbLegacyDataType.Int).ShouldBe((short)-32768);
|
||||
WriteCommand.ParseValue("32767", AbLegacyDataType.Int).ShouldBe((short)32767);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseValue_AnalogInt_parses_same_as_Int()
|
||||
{
|
||||
// A-file uses N-file semantics — 16-bit signed with the same wire format.
|
||||
WriteCommand.ParseValue("100", AbLegacyDataType.AnalogInt).ShouldBeOfType<short>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseValue_Long_32bit()
|
||||
{
|
||||
WriteCommand.ParseValue("-2147483648", AbLegacyDataType.Long).ShouldBe(int.MinValue);
|
||||
WriteCommand.ParseValue("2147483647", AbLegacyDataType.Long).ShouldBe(int.MaxValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseValue_Float_invariant_culture()
|
||||
{
|
||||
WriteCommand.ParseValue("3.14", AbLegacyDataType.Float).ShouldBe(3.14f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseValue_String_passthrough()
|
||||
{
|
||||
WriteCommand.ParseValue("hello slc", AbLegacyDataType.String).ShouldBe("hello slc");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AbLegacyDataType.TimerElement)]
|
||||
[InlineData(AbLegacyDataType.CounterElement)]
|
||||
[InlineData(AbLegacyDataType.ControlElement)]
|
||||
public void ParseValue_Element_types_land_on_int32(AbLegacyDataType type)
|
||||
{
|
||||
// T/C/R sub-elements are 32-bit at the wire level regardless of semantic meaning.
|
||||
WriteCommand.ParseValue("42", type).ShouldBeOfType<int>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseValue_Bit_rejects_unknown_strings()
|
||||
{
|
||||
Should.Throw<CliFx.Exceptions.CommandException>(
|
||||
() => WriteCommand.ParseValue("perhaps", AbLegacyDataType.Bit));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseValue_non_numeric_for_numeric_types_throws()
|
||||
{
|
||||
Should.Throw<FormatException>(
|
||||
() => WriteCommand.ParseValue("xyz", AbLegacyDataType.Int));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("N7:0", AbLegacyDataType.Int, "N7:0:Int")]
|
||||
[InlineData("B3:0/3", AbLegacyDataType.Bit, "B3:0/3:Bit")]
|
||||
[InlineData("F8:10", AbLegacyDataType.Float, "F8:10:Float")]
|
||||
[InlineData("T4:0.ACC", AbLegacyDataType.TimerElement, "T4:0.ACC:TimerElement")]
|
||||
public void SynthesiseTagName_preserves_PCCC_address_verbatim(
|
||||
string address, AbLegacyDataType type, string expected)
|
||||
{
|
||||
ReadCommand.SynthesiseTagName(address, type).ShouldBe(expected);
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
<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.Cli.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\Drivers\Cli\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class SnapshotFormatterTests
|
||||
{
|
||||
private static readonly DateTime FixedTime =
|
||||
new(2026, 4, 21, 12, 34, 56, 789, DateTimeKind.Utc);
|
||||
|
||||
[Fact]
|
||||
public void Format_includes_tag_value_status_and_both_timestamps()
|
||||
{
|
||||
var snap = new DataValueSnapshot(42, 0u, FixedTime, FixedTime);
|
||||
var output = SnapshotFormatter.Format("N7:0", snap);
|
||||
|
||||
output.ShouldContain("Tag: N7:0");
|
||||
output.ShouldContain("Value: 42");
|
||||
output.ShouldContain("Status: 0x00000000 (Good)");
|
||||
output.ShouldContain("Source Time: 2026-04-21T12:34:56.789Z");
|
||||
output.ShouldContain("Server Time: 2026-04-21T12:34:56.789Z");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0x00000000u, "Good")]
|
||||
[InlineData(0x80000000u, "Bad")]
|
||||
[InlineData(0x80050000u, "BadCommunicationError")]
|
||||
[InlineData(0x80060000u, "BadTimeout")]
|
||||
[InlineData(0x80340000u, "BadNodeIdUnknown")]
|
||||
[InlineData(0x40000000u, "Uncertain")]
|
||||
public void FormatStatus_names_well_known_status_codes(uint status, string expectedName)
|
||||
{
|
||||
SnapshotFormatter.FormatStatus(status).ShouldContain(expectedName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatStatus_unknown_codes_fall_back_to_hex_only()
|
||||
{
|
||||
// 0xDEADBEEF isn't in the shortlist — just render the hex form, no name.
|
||||
SnapshotFormatter.FormatStatus(0xDEADBEEFu).ShouldBe("0xDEADBEEF");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatValue_renders_null_as_placeholder()
|
||||
{
|
||||
var snap = new DataValueSnapshot(null, 0x80050000u, null, FixedTime);
|
||||
var output = SnapshotFormatter.Format("Orphan", snap);
|
||||
output.ShouldContain("Value: <null>");
|
||||
output.ShouldContain("Source Time: -"); // null timestamp → dash
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatValue_formats_booleans_lowercase()
|
||||
{
|
||||
var snap = new DataValueSnapshot(true, 0u, FixedTime, FixedTime);
|
||||
SnapshotFormatter.Format("Coil", snap).ShouldContain("Value: true");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatValue_formats_floats_invariant_culture()
|
||||
{
|
||||
// Guards against non-invariant decimal separators (e.g. comma on PL locales)
|
||||
// that would break cross-platform log diffs.
|
||||
var snap = new DataValueSnapshot(3.14f, 0u, FixedTime, FixedTime);
|
||||
SnapshotFormatter.Format("F8:0", snap).ShouldContain("3.14");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatValue_quotes_strings()
|
||||
{
|
||||
var snap = new DataValueSnapshot("hello", 0u, FixedTime, FixedTime);
|
||||
SnapshotFormatter.Format("Msg", snap).ShouldContain("\"hello\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatWrite_shows_status_with_tag_name()
|
||||
{
|
||||
var result = new WriteResult(0u);
|
||||
SnapshotFormatter.FormatWrite("Scratch", result)
|
||||
.ShouldBe("Write Scratch: 0x00000000 (Good)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatTable_aligns_columns_and_includes_header_separator()
|
||||
{
|
||||
var names = new[] { "A", "LongerTag" };
|
||||
var snaps = new[]
|
||||
{
|
||||
new DataValueSnapshot(1, 0u, FixedTime, FixedTime),
|
||||
new DataValueSnapshot(2, 0u, FixedTime, FixedTime),
|
||||
};
|
||||
var table = SnapshotFormatter.FormatTable(names, snaps);
|
||||
|
||||
table.ShouldContain("TAG");
|
||||
table.ShouldContain("VALUE");
|
||||
table.ShouldContain("STATUS");
|
||||
table.ShouldContain("SOURCE TIME");
|
||||
table.ShouldContain("---"); // separator row
|
||||
table.ShouldContain("LongerTag");
|
||||
table.ShouldContain("0x00000000");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatTable_rejects_mismatched_lengths()
|
||||
{
|
||||
Should.Throw<ArgumentException>(() => SnapshotFormatter.FormatTable(
|
||||
new[] { "A", "B" },
|
||||
new[] { new DataValueSnapshot(1, 0u, FixedTime, FixedTime) }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatTimestamp_normalises_local_kind_to_utc()
|
||||
{
|
||||
// Unspecified / Local times must land on UTC in the output — otherwise a CI box in
|
||||
// UTC+X would emit diffs against dev-laptop runs.
|
||||
var local = new DateTime(2026, 4, 21, 8, 0, 0, DateTimeKind.Local);
|
||||
var formatted = SnapshotFormatter.FormatTimestamp(local);
|
||||
formatted.ShouldEndWith("Z");
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
<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.Cli.Common.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\Drivers\Cli\ZB.MOM.WW.OtOpcUa.Driver.Cli.Common\ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,21 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Commands;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ReadCommandTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(ModbusRegion.HoldingRegisters, 100, ModbusDataType.UInt16, "HR[100]:UInt16")]
|
||||
[InlineData(ModbusRegion.Coils, 0, ModbusDataType.Bool, "Coil[0]:Bool")]
|
||||
[InlineData(ModbusRegion.DiscreteInputs, 42, ModbusDataType.Bool, "DI[42]:Bool")]
|
||||
[InlineData(ModbusRegion.InputRegisters, 5, ModbusDataType.Int16, "IR[5]:Int16")]
|
||||
[InlineData(ModbusRegion.HoldingRegisters, 200, ModbusDataType.Float32, "HR[200]:Float32")]
|
||||
public void SynthesiseTagName_produces_stable_region_prefix_plus_address_plus_type(
|
||||
ModbusRegion region, ushort address, ModbusDataType type, string expected)
|
||||
{
|
||||
ReadCommand.SynthesiseTagName(region, address, type).ShouldBe(expected);
|
||||
}
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Commands;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers the <c>--value</c> string → CLR type parser inside
|
||||
/// <see cref="WriteCommand.ParseValue"/>. This is the piece that guards against
|
||||
/// locale surprises (e.g. comma-as-decimal-separator on PL locales), so all numeric
|
||||
/// paths assert the invariant-culture path.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class WriteCommandParseValueTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("true", true)]
|
||||
[InlineData("false", false)]
|
||||
[InlineData("1", true)]
|
||||
[InlineData("0", false)]
|
||||
[InlineData("YES", true)]
|
||||
[InlineData("No", false)]
|
||||
[InlineData("on", true)]
|
||||
[InlineData("off", false)]
|
||||
public void ParseValue_Bool_accepts_common_aliases(string raw, bool expected)
|
||||
{
|
||||
WriteCommand.ParseValue(raw, ModbusDataType.Bool).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseValue_Bool_rejects_unknown_strings()
|
||||
{
|
||||
Should.Throw<CliFx.Exceptions.CommandException>(
|
||||
() => WriteCommand.ParseValue("maybe", ModbusDataType.Bool));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseValue_Int16_parses_positive_and_negative()
|
||||
{
|
||||
WriteCommand.ParseValue("-32768", ModbusDataType.Int16).ShouldBe((short)-32768);
|
||||
WriteCommand.ParseValue("32767", ModbusDataType.Int16).ShouldBe((short)32767);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseValue_UInt16_and_Bcd16_both_yield_ushort()
|
||||
{
|
||||
WriteCommand.ParseValue("65535", ModbusDataType.UInt16).ShouldBeOfType<ushort>();
|
||||
WriteCommand.ParseValue("65535", ModbusDataType.Bcd16).ShouldBeOfType<ushort>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseValue_Float32_uses_invariant_culture_period_as_decimal_separator()
|
||||
{
|
||||
WriteCommand.ParseValue("3.14", ModbusDataType.Float32).ShouldBe(3.14f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseValue_Float64_handles_larger_precision()
|
||||
{
|
||||
var result = WriteCommand.ParseValue("2.718281828", ModbusDataType.Float64);
|
||||
result.ShouldBeOfType<double>();
|
||||
((double)result).ShouldBe(2.718281828d, 0.0000001d);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseValue_String_returns_raw_string_unmodified()
|
||||
{
|
||||
WriteCommand.ParseValue("hello world", ModbusDataType.String).ShouldBe("hello world");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseValue_BitInRegister_accepts_bool_aliases()
|
||||
{
|
||||
WriteCommand.ParseValue("true", ModbusDataType.BitInRegister).ShouldBe(true);
|
||||
WriteCommand.ParseValue("0", ModbusDataType.BitInRegister).ShouldBe(false);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseValue_Int32_parses_negative_max()
|
||||
{
|
||||
WriteCommand.ParseValue("-2147483648", ModbusDataType.Int32).ShouldBe(int.MinValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseValue_rejects_non_numeric_for_numeric_types()
|
||||
{
|
||||
Should.Throw<FormatException>(
|
||||
() => WriteCommand.ParseValue("not-a-number", ModbusDataType.Int32));
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
<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.Modbus.Cli.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\Drivers\Cli\ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli\ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Commands;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers <see cref="WriteCommand.ParseValue"/> across every S7 atomic type.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class WriteCommandParseValueTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("true", true)]
|
||||
[InlineData("0", false)]
|
||||
[InlineData("yes", true)]
|
||||
[InlineData("OFF", false)]
|
||||
public void ParseValue_Bool_accepts_common_aliases(string raw, bool expected)
|
||||
{
|
||||
WriteCommand.ParseValue(raw, S7DataType.Bool).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseValue_Bool_rejects_garbage()
|
||||
{
|
||||
Should.Throw<CliFx.Exceptions.CommandException>(
|
||||
() => WriteCommand.ParseValue("maybe", S7DataType.Bool));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseValue_Byte_ranges()
|
||||
{
|
||||
WriteCommand.ParseValue("0", S7DataType.Byte).ShouldBe((byte)0);
|
||||
WriteCommand.ParseValue("255", S7DataType.Byte).ShouldBe((byte)255);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseValue_Int16_signed_range()
|
||||
{
|
||||
WriteCommand.ParseValue("-32768", S7DataType.Int16).ShouldBe((short)-32768);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseValue_UInt16_unsigned_max()
|
||||
{
|
||||
WriteCommand.ParseValue("65535", S7DataType.UInt16).ShouldBe((ushort)65535);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseValue_Int32_parses_negative()
|
||||
{
|
||||
WriteCommand.ParseValue("-2147483648", S7DataType.Int32).ShouldBe(int.MinValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseValue_UInt32_parses_max()
|
||||
{
|
||||
WriteCommand.ParseValue("4294967295", S7DataType.UInt32).ShouldBe(uint.MaxValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseValue_Int64_parses_min()
|
||||
{
|
||||
WriteCommand.ParseValue("-9223372036854775808", S7DataType.Int64).ShouldBe(long.MinValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseValue_UInt64_parses_max()
|
||||
{
|
||||
WriteCommand.ParseValue("18446744073709551615", S7DataType.UInt64).ShouldBe(ulong.MaxValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseValue_Float32_invariant_culture()
|
||||
{
|
||||
WriteCommand.ParseValue("3.14", S7DataType.Float32).ShouldBe(3.14f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseValue_Float64_higher_precision()
|
||||
{
|
||||
WriteCommand.ParseValue("2.718281828", S7DataType.Float64).ShouldBeOfType<double>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseValue_String_passthrough()
|
||||
{
|
||||
WriteCommand.ParseValue("hallo siemens", S7DataType.String).ShouldBe("hallo siemens");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseValue_DateTime_parses_roundtrip_form()
|
||||
{
|
||||
var result = WriteCommand.ParseValue("2026-04-21T12:34:56Z", S7DataType.DateTime);
|
||||
result.ShouldBeOfType<DateTime>();
|
||||
((DateTime)result).Year.ShouldBe(2026);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseValue_non_numeric_for_numeric_types_throws()
|
||||
{
|
||||
Should.Throw<FormatException>(
|
||||
() => WriteCommand.ParseValue("xyz", S7DataType.Int16));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("DB1.DBW0", S7DataType.Int16, "DB1.DBW0:Int16")]
|
||||
[InlineData("M0.0", S7DataType.Bool, "M0.0:Bool")]
|
||||
[InlineData("IW4", S7DataType.UInt16, "IW4:UInt16")]
|
||||
[InlineData("QD8", S7DataType.UInt32, "QD8:UInt32")]
|
||||
[InlineData("DB10.STRING[0]", S7DataType.String, "DB10.STRING[0]:String")]
|
||||
public void SynthesiseTagName_preserves_S7_address_verbatim(
|
||||
string address, S7DataType type, string expected)
|
||||
{
|
||||
ReadCommand.SynthesiseTagName(address, type).ShouldBe(expected);
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
<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.S7.Cli.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\Drivers\Cli\ZB.MOM.WW.OtOpcUa.Driver.S7.Cli\ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Commands;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers <see cref="WriteCommand.ParseValue"/> for the IEC 61131-3 atomic types
|
||||
/// TwinCAT exposes. Wider matrix than AB CIP because IEC adds WSTRING + the four
|
||||
/// TIME/DATE variants that all marshal as UDINT on the wire.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class WriteCommandParseValueTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("true", true)]
|
||||
[InlineData("0", false)]
|
||||
[InlineData("on", true)]
|
||||
[InlineData("NO", false)]
|
||||
public void ParseValue_Bool_accepts_common_aliases(string raw, bool expected)
|
||||
{
|
||||
WriteCommand.ParseValue(raw, TwinCATDataType.Bool).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseValue_Bool_rejects_garbage()
|
||||
{
|
||||
Should.Throw<CliFx.Exceptions.CommandException>(
|
||||
() => WriteCommand.ParseValue("maybe", TwinCATDataType.Bool));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseValue_SInt_signed_byte()
|
||||
{
|
||||
WriteCommand.ParseValue("-128", TwinCATDataType.SInt).ShouldBe((sbyte)-128);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseValue_USInt_unsigned_byte()
|
||||
{
|
||||
WriteCommand.ParseValue("255", TwinCATDataType.USInt).ShouldBe((byte)255);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseValue_Int_signed_16bit()
|
||||
{
|
||||
WriteCommand.ParseValue("-32768", TwinCATDataType.Int).ShouldBe((short)-32768);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseValue_UInt_unsigned_16bit()
|
||||
{
|
||||
WriteCommand.ParseValue("65535", TwinCATDataType.UInt).ShouldBe((ushort)65535);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseValue_DInt_int32_bounds()
|
||||
{
|
||||
WriteCommand.ParseValue("-2147483648", TwinCATDataType.DInt).ShouldBe(int.MinValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseValue_UDInt_uint32_max()
|
||||
{
|
||||
WriteCommand.ParseValue("4294967295", TwinCATDataType.UDInt).ShouldBe(uint.MaxValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseValue_LInt_int64_min()
|
||||
{
|
||||
WriteCommand.ParseValue("-9223372036854775808", TwinCATDataType.LInt).ShouldBe(long.MinValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseValue_ULInt_uint64_max()
|
||||
{
|
||||
WriteCommand.ParseValue("18446744073709551615", TwinCATDataType.ULInt).ShouldBe(ulong.MaxValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseValue_Real_invariant_culture()
|
||||
{
|
||||
WriteCommand.ParseValue("3.14", TwinCATDataType.Real).ShouldBe(3.14f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseValue_LReal_higher_precision()
|
||||
{
|
||||
WriteCommand.ParseValue("2.718281828", TwinCATDataType.LReal).ShouldBeOfType<double>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseValue_String_passthrough()
|
||||
{
|
||||
WriteCommand.ParseValue("hallo beckhoff", TwinCATDataType.String).ShouldBe("hallo beckhoff");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseValue_WString_passthrough()
|
||||
{
|
||||
// CLI layer doesn't distinguish UTF-8 input; the driver handles the WSTRING
|
||||
// encoding on the wire.
|
||||
WriteCommand.ParseValue("überstall", TwinCATDataType.WString).ShouldBe("überstall");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(TwinCATDataType.Time)]
|
||||
[InlineData(TwinCATDataType.Date)]
|
||||
[InlineData(TwinCATDataType.DateTime)]
|
||||
[InlineData(TwinCATDataType.TimeOfDay)]
|
||||
public void ParseValue_IEC_date_time_variants_land_on_uint32(TwinCATDataType type)
|
||||
{
|
||||
// IEC 61131-3 TIME / DATE / DT / TOD all marshal as UDINT on the wire; the CLI
|
||||
// accepts a numeric raw value and lets the caller handle the encoding.
|
||||
WriteCommand.ParseValue("1234567", type).ShouldBeOfType<uint>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseValue_Structure_refused()
|
||||
{
|
||||
Should.Throw<CliFx.Exceptions.CommandException>(
|
||||
() => WriteCommand.ParseValue("42", TwinCATDataType.Structure));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseValue_non_numeric_for_numeric_types_throws()
|
||||
{
|
||||
Should.Throw<FormatException>(
|
||||
() => WriteCommand.ParseValue("xyz", TwinCATDataType.DInt));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("MAIN.bStart", TwinCATDataType.Bool, "MAIN.bStart:Bool")]
|
||||
[InlineData("GVL.Counter", TwinCATDataType.DInt, "GVL.Counter:DInt")]
|
||||
[InlineData("Motor1.Status.Running", TwinCATDataType.Bool, "Motor1.Status.Running:Bool")]
|
||||
[InlineData("Recipe[3]", TwinCATDataType.Real, "Recipe[3]:Real")]
|
||||
public void SynthesiseTagName_preserves_symbolic_path_verbatim(
|
||||
string symbol, TwinCATDataType type, string expected)
|
||||
{
|
||||
ReadCommand.SynthesiseTagName(symbol, type).ShouldBe(expected);
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
<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.TwinCAT.Cli.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\Drivers\Cli\ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli\ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,50 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end smoke tests that exercise the real libplctag stack against a running
|
||||
/// <c>ab_server</c>. Skipped when the binary isn't on PATH (<see cref="AbServerFactAttribute"/>).
|
||||
/// Parametrized over <see cref="KnownProfiles.All"/> so one test file covers every family
|
||||
/// (ControlLogix / CompactLogix / Micro800 / GuardLogix).
|
||||
/// </summary>
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Requires", "AbServer")]
|
||||
public sealed class AbCipReadSmokeTests
|
||||
{
|
||||
public static IEnumerable<object[]> Profiles =>
|
||||
KnownProfiles.All.Select(p => new object[] { p });
|
||||
|
||||
[AbServerTheory]
|
||||
[MemberData(nameof(Profiles))]
|
||||
public async Task Driver_reads_seeded_DInt_from_ab_server(AbServerProfile profile)
|
||||
{
|
||||
var fixture = new AbServerFixture(profile);
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
var deviceUri = $"ab://127.0.0.1:{fixture.Port}/1,0";
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(deviceUri, profile.Family)],
|
||||
Tags = [new AbCipTagDefinition("Counter", deviceUri, "TestDINT", AbCipDataType.DInt)],
|
||||
Timeout = TimeSpan.FromSeconds(5),
|
||||
}, $"drv-smoke-{profile.Family}");
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
var snapshots = await drv.ReadAsync(["Counter"], CancellationToken.None);
|
||||
|
||||
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using System.Net.Sockets;
|
||||
using Xunit;
|
||||
using Xunit.Sdk;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Reachability probe for the <c>ab_server</c> Docker container (libplctag's CIP
|
||||
/// simulator built via <c>Docker/Dockerfile</c>) or any real AB PLC the
|
||||
/// <c>AB_SERVER_ENDPOINT</c> env var points at. Parses
|
||||
/// <c>AB_SERVER_ENDPOINT</c> (default <c>localhost:44818</c>) + TCP-connects
|
||||
/// once at fixture construction. Tests skip via <see cref="AbServerFactAttribute"/>
|
||||
/// / <see cref="AbServerTheoryAttribute"/> when the port isn't live, so
|
||||
/// <c>dotnet test</c> stays green on a fresh clone without Docker running.
|
||||
/// Matches the <see cref="ModbusSimulatorFixture"/> / <c>Snap7ServerFixture</c> /
|
||||
/// <c>OpcPlcFixture</c> shape.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Docker is the only supported launch path — no native-binary spawn + no
|
||||
/// PATH lookup. Bring the container up before <c>dotnet test</c>:
|
||||
/// <c>docker compose -f Docker/docker-compose.yml --profile controllogix up</c>.
|
||||
/// </remarks>
|
||||
public sealed class AbServerFixture : IAsyncLifetime
|
||||
{
|
||||
private const string EndpointEnvVar = "AB_SERVER_ENDPOINT";
|
||||
|
||||
/// <summary>The profile this fixture instance represents. Parallel family smoke tests
|
||||
/// instantiate the fixture with the profile matching their compose-file service.</summary>
|
||||
public AbServerProfile Profile { get; }
|
||||
|
||||
public string Host { get; } = "127.0.0.1";
|
||||
public int Port { get; } = AbServerProfile.DefaultPort;
|
||||
|
||||
public AbServerFixture() : this(KnownProfiles.ControlLogix) { }
|
||||
|
||||
public AbServerFixture(AbServerProfile profile)
|
||||
{
|
||||
Profile = profile ?? throw new ArgumentNullException(nameof(profile));
|
||||
|
||||
// Endpoint override applies to both host + port — targeting a real PLC at
|
||||
// non-default host or port shouldn't need fixture changes.
|
||||
if (Environment.GetEnvironmentVariable(EndpointEnvVar) is { Length: > 0 } raw)
|
||||
{
|
||||
var parts = raw.Split(':', 2);
|
||||
Host = parts[0];
|
||||
if (parts.Length == 2 && int.TryParse(parts[1], out var p)) Port = p;
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
/// <summary>
|
||||
/// <c>true</c> when ab_server is reachable at this fixture's Host/Port. Used by
|
||||
/// <see cref="AbServerFactAttribute"/> / <see cref="AbServerTheoryAttribute"/>
|
||||
/// to decide whether to skip tests on a fresh clone without a running container.
|
||||
/// </summary>
|
||||
public static bool IsServerAvailable() =>
|
||||
TcpProbe(ResolveHost(), ResolvePort());
|
||||
|
||||
private static string ResolveHost() =>
|
||||
Environment.GetEnvironmentVariable(EndpointEnvVar)?.Split(':', 2)[0] ?? "127.0.0.1";
|
||||
|
||||
private static int ResolvePort()
|
||||
{
|
||||
var raw = Environment.GetEnvironmentVariable(EndpointEnvVar);
|
||||
if (raw is null) return AbServerProfile.DefaultPort;
|
||||
var parts = raw.Split(':', 2);
|
||||
return parts.Length == 2 && int.TryParse(parts[1], out var p) ? p : AbServerProfile.DefaultPort;
|
||||
}
|
||||
|
||||
/// <summary>One-shot TCP probe; 500 ms budget so a missing container fails the probe fast.</summary>
|
||||
private static bool TcpProbe(string host, int port)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = new TcpClient();
|
||||
var task = client.ConnectAsync(host, port);
|
||||
return task.Wait(TimeSpan.FromMilliseconds(500)) && client.Connected;
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>[Fact]</c>-equivalent that skips when ab_server isn't reachable — accepts a
|
||||
/// live Docker listener on <c>localhost:44818</c> or an <c>AB_SERVER_ENDPOINT</c>
|
||||
/// override pointing at a real PLC.
|
||||
/// </summary>
|
||||
public sealed class AbServerFactAttribute : FactAttribute
|
||||
{
|
||||
public AbServerFactAttribute()
|
||||
{
|
||||
if (!AbServerFixture.IsServerAvailable())
|
||||
Skip = "ab_server not reachable. Start the Docker container " +
|
||||
"(docker compose -f Docker/docker-compose.yml --profile controllogix up) " +
|
||||
"or set AB_SERVER_ENDPOINT to a real PLC.";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>[Theory]</c>-equivalent with the same availability rules as
|
||||
/// <see cref="AbServerFactAttribute"/>. Pair with
|
||||
/// <c>[MemberData(nameof(KnownProfiles.All))]</c>-style providers to run one theory
|
||||
/// row per family.
|
||||
/// </summary>
|
||||
public sealed class AbServerTheoryAttribute : TheoryAttribute
|
||||
{
|
||||
public AbServerTheoryAttribute()
|
||||
{
|
||||
if (!AbServerFixture.IsServerAvailable())
|
||||
Skip = "ab_server not reachable. Start the Docker container " +
|
||||
"(docker compose -f Docker/docker-compose.yml --profile controllogix up) " +
|
||||
"or set AB_SERVER_ENDPOINT to a real PLC.";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Per-family marker for the <c>ab_server</c> Docker compose profile a given test
|
||||
/// targets. The compose file (<c>Docker/docker-compose.yml</c>) is the canonical
|
||||
/// source of truth for which tags a family seeds + which <c>--plc</c> mode the
|
||||
/// simulator boots in; this record just ties a family enum to operator-facing
|
||||
/// notes so fixture + test code can filter / branch by family.
|
||||
/// </summary>
|
||||
/// <param name="Family">OtOpcUa driver family this profile targets.</param>
|
||||
/// <param name="ComposeProfile">The <c>docker compose --profile</c> name that brings
|
||||
/// this family's ab_server up. Matches the service key in the compose file.</param>
|
||||
/// <param name="Notes">Operator-facing description of coverage + any quirks.</param>
|
||||
public sealed record AbServerProfile(
|
||||
AbCipPlcFamily Family,
|
||||
string ComposeProfile,
|
||||
string Notes)
|
||||
{
|
||||
/// <summary>Default ab_server port — matches the compose-file port-map + the
|
||||
/// CIP / EtherNet/IP standard.</summary>
|
||||
public const int DefaultPort = 44818;
|
||||
}
|
||||
|
||||
/// <summary>Canonical profiles covering every AB CIP family shipped in PRs 9–12.</summary>
|
||||
public static class KnownProfiles
|
||||
{
|
||||
public static readonly AbServerProfile ControlLogix = new(
|
||||
Family: AbCipPlcFamily.ControlLogix,
|
||||
ComposeProfile: "controllogix",
|
||||
Notes: "Widest-coverage profile — PR 9 baseline. UDTs unit-tested via golden Template Object buffers; ab_server lacks full UDT emulation.");
|
||||
|
||||
public static readonly AbServerProfile CompactLogix = new(
|
||||
Family: AbCipPlcFamily.CompactLogix,
|
||||
ComposeProfile: "compactlogix",
|
||||
Notes: "ab_server doesn't enforce the narrower ConnectionSize; driver-side profile caps it per PR 10.");
|
||||
|
||||
public static readonly AbServerProfile Micro800 = new(
|
||||
Family: AbCipPlcFamily.Micro800,
|
||||
ComposeProfile: "micro800",
|
||||
Notes: "--plc=Micro800 mode (unconnected-only, empty path). Driver-side enforcement verified in the unit suite.");
|
||||
|
||||
public static readonly AbServerProfile GuardLogix = new(
|
||||
Family: AbCipPlcFamily.GuardLogix,
|
||||
ComposeProfile: "guardlogix",
|
||||
Notes: "ab_server has no safety subsystem — _S-suffixed seed tag triggers driver-side ViewOnly classification only.");
|
||||
|
||||
public static IReadOnlyList<AbServerProfile> All { get; } =
|
||||
[ControlLogix, CompactLogix, Micro800, GuardLogix];
|
||||
|
||||
public static AbServerProfile ForFamily(AbCipPlcFamily family) =>
|
||||
All.FirstOrDefault(p => p.Family == family)
|
||||
?? throw new ArgumentOutOfRangeException(nameof(family), family, "No integration profile for this family.");
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Runtime gate that lets an integration-test class declare which target-server tier
|
||||
/// it requires. Reads <c>AB_SERVER_PROFILE</c> from the environment; tests call
|
||||
/// <see cref="SkipUnless"/> with the profile names they support + skip otherwise.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Two tiers today:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item><c>abserver</c> (default) — the Dockerized libplctag <c>ab_server</c>
|
||||
/// simulator. Covers atomic reads / writes / basic discovery across the four
|
||||
/// families (ControlLogix / CompactLogix / Micro800 / GuardLogix).</item>
|
||||
/// <item><c>emulate</c> — Rockwell Studio 5000 Logix Emulate on an operator's
|
||||
/// Windows box, exposed via <c>AB_SERVER_ENDPOINT</c>. Adds real UDT / ALMD /
|
||||
/// AOI / Program-scoped-tag coverage that ab_server can't emulate. Tier-gated
|
||||
/// because Emulate is per-seat licensed + Windows-only + manually launched;
|
||||
/// a stock `dotnet test` run against ab_server must skip Emulate-only classes
|
||||
/// cleanly.</item>
|
||||
/// </list>
|
||||
/// <para>Tests assert their target tier at the top of each <c>[Fact]</c> /
|
||||
/// <c>[Theory]</c> body, mirroring the <c>MODBUS_SIM_PROFILE</c> gate pattern in
|
||||
/// <c>tests/.../Modbus.IntegrationTests/DL205/DL205StringQuirkTests.cs</c>.</para>
|
||||
/// </remarks>
|
||||
public static class AbServerProfileGate
|
||||
{
|
||||
public const string Default = "abserver";
|
||||
public const string Emulate = "emulate";
|
||||
|
||||
/// <summary>Active profile from <c>AB_SERVER_PROFILE</c>; defaults to <see cref="Default"/>.</summary>
|
||||
public static string CurrentProfile =>
|
||||
Environment.GetEnvironmentVariable("AB_SERVER_PROFILE") is { Length: > 0 } raw
|
||||
? raw.Trim().ToLowerInvariant()
|
||||
: Default;
|
||||
|
||||
/// <summary>
|
||||
/// Skip the calling test via <c>Assert.Skip</c> when <see cref="CurrentProfile"/>
|
||||
/// isn't in <paramref name="requiredProfiles"/>. Case-insensitive match.
|
||||
/// </summary>
|
||||
public static void SkipUnless(params string[] requiredProfiles)
|
||||
{
|
||||
foreach (var p in requiredProfiles)
|
||||
if (string.Equals(p, CurrentProfile, StringComparison.OrdinalIgnoreCase))
|
||||
return;
|
||||
Assert.Skip(
|
||||
$"Test requires AB_SERVER_PROFILE in {{{string.Join(", ", requiredProfiles)}}}; " +
|
||||
$"current value is '{CurrentProfile}'. " +
|
||||
$"Set AB_SERVER_PROFILE=emulate + point AB_SERVER_ENDPOINT at a Logix Emulate instance to run the golden-box-tier tests.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Pure-unit tests for the profile catalog. Verifies <see cref="KnownProfiles"/>
|
||||
/// stays in sync with <see cref="AbCipPlcFamily"/> + with the compose-file service
|
||||
/// names — a typo in either would surface as a test failure rather than a silent
|
||||
/// "wrong family booted" at runtime. Runs without Docker, so CI without the
|
||||
/// container still exercises these contracts.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbServerProfileTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(AbCipPlcFamily.ControlLogix, "controllogix")]
|
||||
[InlineData(AbCipPlcFamily.CompactLogix, "compactlogix")]
|
||||
[InlineData(AbCipPlcFamily.Micro800, "micro800")]
|
||||
[InlineData(AbCipPlcFamily.GuardLogix, "guardlogix")]
|
||||
public void KnownProfiles_ForFamily_Returns_Expected_ComposeProfile(AbCipPlcFamily family, string expected)
|
||||
{
|
||||
KnownProfiles.ForFamily(family).ComposeProfile.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KnownProfiles_All_Covers_Every_Family()
|
||||
{
|
||||
var covered = KnownProfiles.All.Select(p => p.Family).ToHashSet();
|
||||
foreach (var family in Enum.GetValues<AbCipPlcFamily>())
|
||||
covered.ShouldContain(family, $"Family {family} is missing a KnownProfiles entry.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultPort_Matches_EtherNetIP_Standard()
|
||||
{
|
||||
AbServerProfile.DefaultPort.ShouldBe(44818);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
# ab_server container for the AB CIP integration suite.
|
||||
#
|
||||
# ab_server is a C program in libplctag/libplctag under src/tools/ab_server.
|
||||
# We clone at a pinned commit, build just the ab_server target via CMake,
|
||||
# and copy the resulting binary into a slim runtime stage so the published
|
||||
# image stays small (~60MB vs ~350MB for the build stage).
|
||||
|
||||
# -------- stage 1: build ab_server from source --------
|
||||
FROM debian:bookworm-slim AS build
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
build-essential \
|
||||
cmake \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Pinned tag matches the `ab_server` version AbServerFixture + CI treat as
|
||||
# canonical. Bump deliberately alongside a driver-side change that needs
|
||||
# something newer.
|
||||
ARG LIBPLCTAG_TAG=release
|
||||
RUN git clone --depth 1 --branch "${LIBPLCTAG_TAG}" https://github.com/libplctag/libplctag.git /src
|
||||
|
||||
WORKDIR /src
|
||||
RUN cmake -S . -B build -DCMAKE_BUILD_TYPE=Release \
|
||||
&& cmake --build build --target ab_server --parallel
|
||||
|
||||
# -------- stage 2: runtime --------
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
LABEL org.opencontainers.image.source="https://github.com/dohertj2/lmxopcua" \
|
||||
org.opencontainers.image.description="libplctag ab_server for OtOpcUa AB CIP driver integration tests"
|
||||
|
||||
# libplctag's ab_server is statically linked against libc / libstdc++ on
|
||||
# Debian bookworm; no runtime dependencies beyond what the slim image
|
||||
# already has.
|
||||
COPY --from=build /src/build/bin_dist/ab_server /usr/local/bin/ab_server
|
||||
|
||||
EXPOSE 44818
|
||||
|
||||
# docker-compose.yml overrides the command with per-family flags.
|
||||
CMD ["ab_server", "--plc=ControlLogix", "--path=1,0", "--port=44818", \
|
||||
"--tag=TestDINT:DINT[1]", "--tag=TestREAL:REAL[1]", "--tag=TestBOOL:BOOL[1]"]
|
||||
@@ -0,0 +1,89 @@
|
||||
# AB CIP integration-test fixture — `ab_server` (Docker)
|
||||
|
||||
[libplctag](https://github.com/libplctag/libplctag)'s `ab_server` — a
|
||||
MIT-licensed C program that emulates a ControlLogix / CompactLogix CIP
|
||||
endpoint over EtherNet/IP. Docker is the only supported launch path;
|
||||
`ab_server` ships as a source-only tool under libplctag's
|
||||
`src/tools/ab_server/` so the Dockerfile's multi-stage build is the only
|
||||
reproducible way to get a working binary across developer boxes. A fresh
|
||||
clone needs Docker Desktop and nothing else.
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| [`Dockerfile`](Dockerfile) | Multi-stage: build from libplctag at pinned tag → copy binary into `debian:bookworm-slim` runtime image |
|
||||
| [`docker-compose.yml`](docker-compose.yml) | One service per family (`controllogix` / `compactlogix` / `micro800` / `guardlogix`); all bind `:44818` |
|
||||
|
||||
## Run
|
||||
|
||||
From the repo root:
|
||||
|
||||
```powershell
|
||||
# ControlLogix — widest-coverage profile
|
||||
docker compose -f tests\ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests\Docker\docker-compose.yml --profile controllogix up
|
||||
|
||||
# Per-family
|
||||
docker compose -f tests\...\Docker\docker-compose.yml --profile compactlogix up
|
||||
docker compose -f tests\...\Docker\docker-compose.yml --profile micro800 up
|
||||
docker compose -f tests\...\Docker\docker-compose.yml --profile guardlogix up
|
||||
```
|
||||
|
||||
Detached + stop:
|
||||
|
||||
```powershell
|
||||
docker compose -f tests\...\Docker\docker-compose.yml --profile controllogix up -d
|
||||
docker compose -f tests\...\Docker\docker-compose.yml --profile controllogix down
|
||||
```
|
||||
|
||||
First run builds the image (~3-5 minutes — clones libplctag + compiles
|
||||
`ab_server` + its dependencies). Subsequent runs are fast because the
|
||||
multi-stage build layer-caches the checkout + compile.
|
||||
|
||||
## Endpoint
|
||||
|
||||
- Default: `localhost:44818` (EtherNet/IP standard; non-privileged)
|
||||
- Override with `AB_SERVER_ENDPOINT=host:port` to point at a real PLC.
|
||||
|
||||
## Run the integration tests
|
||||
|
||||
In a separate shell with a container up:
|
||||
|
||||
```powershell
|
||||
cd C:\Users\dohertj2\Desktop\lmxopcua
|
||||
dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
|
||||
```
|
||||
|
||||
`AbServerFixture` TCP-probes `localhost:44818` at collection init +
|
||||
records a skip reason when unreachable, so tests stay green on a fresh
|
||||
clone without the container running. Tests use `[AbServerFact]` /
|
||||
`[AbServerTheory]` which check the same probe.
|
||||
|
||||
## What each family seeds
|
||||
|
||||
Tag sets match `AbServerProfile.cs` exactly — changing seeds in one
|
||||
place means updating both.
|
||||
|
||||
| Family | Seeded tags | Notes |
|
||||
|---|---|---|
|
||||
| ControlLogix | `TestDINT` `TestREAL` `TestBOOL` `TestSINT` `TestString` `TestArray` | Widest-coverage; PR 9 baseline. UDT emulation missing from ab_server |
|
||||
| CompactLogix | `TestDINT` `TestREAL` `TestBOOL` | Narrow ConnectionSize cap enforced driver-side; ab_server accepts any size |
|
||||
| Micro800 | `TestDINT` `TestREAL` | ab_server has no `micro800` mode; falls back to `controllogix` emulation |
|
||||
| GuardLogix | `TestDINT` `SafetyDINT_S` | ab_server has no safety subsystem; `_S` suffix triggers driver-side classification only |
|
||||
|
||||
## Known limitations
|
||||
|
||||
- **No UDT / CIP Template Object emulation** — `ab_server` covers atomic
|
||||
types only. UDT reads + task #194 whole-UDT optimization verify via
|
||||
unit tests with golden byte buffers.
|
||||
- **Family-specific quirks trust driver-side code** — ab_server emulates
|
||||
a generic Logix CPU; the ConnectionSize cap, empty-path unconnected
|
||||
mode, and safety-partition write rejection all need lab rigs for
|
||||
wire-level proof.
|
||||
|
||||
See [`docs/drivers/AbServer-Test-Fixture.md`](../../../docs/drivers/AbServer-Test-Fixture.md)
|
||||
for the full coverage map.
|
||||
|
||||
## References
|
||||
|
||||
- [libplctag on GitHub](https://github.com/libplctag/libplctag)
|
||||
- [`docs/drivers/AbServer-Test-Fixture.md`](../../../docs/drivers/AbServer-Test-Fixture.md) — coverage map
|
||||
- [`docs/v2/dev-environment.md`](../../../docs/v2/dev-environment.md) §Docker fixtures
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
# AB CIP integration-test fixture — ab_server (libplctag).
|
||||
#
|
||||
# One service per family. All bind :44818 on the host; only one runs at a
|
||||
# time. Commands mirror the CLI args AbServerProfile.cs constructs for the
|
||||
# native-binary path.
|
||||
#
|
||||
# Usage:
|
||||
# docker compose --profile controllogix up
|
||||
# docker compose --profile compactlogix up
|
||||
# docker compose --profile micro800 up
|
||||
# docker compose --profile guardlogix up
|
||||
services:
|
||||
controllogix:
|
||||
profiles: ["controllogix"]
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: otopcua-ab-server:libplctag-release
|
||||
container_name: otopcua-ab-server-controllogix
|
||||
restart: "no"
|
||||
ports:
|
||||
- "44818:44818"
|
||||
command: [
|
||||
"ab_server",
|
||||
"--plc=ControlLogix",
|
||||
"--path=1,0",
|
||||
"--port=44818",
|
||||
"--tag=TestDINT:DINT[1]",
|
||||
"--tag=TestREAL:REAL[1]",
|
||||
"--tag=TestBOOL:BOOL[1]",
|
||||
"--tag=TestSINT:SINT[1]",
|
||||
"--tag=TestString:STRING[1]",
|
||||
"--tag=TestArray:DINT[16]"
|
||||
]
|
||||
|
||||
compactlogix:
|
||||
profiles: ["compactlogix"]
|
||||
image: otopcua-ab-server:libplctag-release
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: otopcua-ab-server-compactlogix
|
||||
restart: "no"
|
||||
ports:
|
||||
- "44818:44818"
|
||||
# ab_server doesn't distinguish CompactLogix from ControlLogix — no
|
||||
# dedicated --plc mode. Driver-side ConnectionSize cap is enforced
|
||||
# separately (see AbServerProfile.CompactLogix Notes).
|
||||
command: [
|
||||
"ab_server",
|
||||
"--plc=ControlLogix",
|
||||
"--path=1,0",
|
||||
"--port=44818",
|
||||
"--tag=TestDINT:DINT[1]",
|
||||
"--tag=TestREAL:REAL[1]",
|
||||
"--tag=TestBOOL:BOOL[1]"
|
||||
]
|
||||
|
||||
micro800:
|
||||
profiles: ["micro800"]
|
||||
image: otopcua-ab-server:libplctag-release
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: otopcua-ab-server-micro800
|
||||
restart: "no"
|
||||
ports:
|
||||
- "44818:44818"
|
||||
# ab_server does have a Micro800 plc mode (unconnected-only, empty path).
|
||||
command: [
|
||||
"ab_server",
|
||||
"--plc=Micro800",
|
||||
"--port=44818",
|
||||
"--tag=TestDINT:DINT[1]",
|
||||
"--tag=TestREAL:REAL[1]"
|
||||
]
|
||||
|
||||
guardlogix:
|
||||
profiles: ["guardlogix"]
|
||||
image: otopcua-ab-server:libplctag-release
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: otopcua-ab-server-guardlogix
|
||||
restart: "no"
|
||||
ports:
|
||||
- "44818:44818"
|
||||
# ab_server has no safety subsystem — _S suffix triggers driver-side
|
||||
# classification only.
|
||||
command: [
|
||||
"ab_server",
|
||||
"--plc=ControlLogix",
|
||||
"--path=1,0",
|
||||
"--port=44818",
|
||||
"--tag=TestDINT:DINT[1]",
|
||||
"--tag=SafetyDINT_S:DINT[1]"
|
||||
]
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Emulate;
|
||||
|
||||
/// <summary>
|
||||
/// Golden-box-tier ALMD alarm projection tests against Logix Emulate.
|
||||
/// Promotes the feature-flagged ALMD projection (task #177) from unit-only coverage
|
||||
/// (<c>AbCipAlarmProjectionTests</c> with faked InFaulted sequences) to end-to-end
|
||||
/// wire-level coverage — Emulate runs the real ALMD instruction, with real
|
||||
/// rising-edge semantics on <c>InFaulted</c> + <c>Ack</c>, so the driver's poll-based
|
||||
/// projection gets validated against the actual behaviour shops running FT View see.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Required Emulate project state</b> (see <c>LogixProject/README.md</c>):</para>
|
||||
/// <list type="bullet">
|
||||
/// <item>Controller-scope ALMD tag <c>HighTempAlarm</c> — a standard ALMD instruction
|
||||
/// with default member set (<c>In</c>, <c>InFaulted</c>, <c>Acked</c>,
|
||||
/// <c>Severity</c>, <c>Cfg_ProgTime</c>, …).</item>
|
||||
/// <item>A periodic task that drives <c>HighTempAlarm.In</c> false→true→false at a
|
||||
/// cadence the operator can script via a one-shot routine (e.g. a
|
||||
/// <c>SimulateAlarm</c> bit the test case pulses through
|
||||
/// <c>IWritable.WriteAsync</c>).</item>
|
||||
/// <item>Operator writes <c>1</c> to <c>SimulateAlarm</c> to trigger the rising
|
||||
/// edge on <c>HighTempAlarm.In</c>; ladder uses that as the alarm input.</item>
|
||||
/// </list>
|
||||
/// <para>Runs only when <c>AB_SERVER_PROFILE=emulate</c>. ab_server has no ALMD
|
||||
/// instruction + no alarm subsystem, so this tier-gated class couldn't produce a
|
||||
/// meaningful result against the default simulator.</para>
|
||||
/// </remarks>
|
||||
[Collection("AbServerEmulate")]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Tier", "Emulate")]
|
||||
public sealed class AbCipEmulateAlmdTests
|
||||
{
|
||||
[AbServerFact]
|
||||
public async Task Real_ALMD_raise_fires_OnAlarmEvent_through_the_driver_projection()
|
||||
{
|
||||
AbServerProfileGate.SkipUnless(AbServerProfileGate.Emulate);
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("AB_SERVER_ENDPOINT")
|
||||
?? throw new InvalidOperationException(
|
||||
"AB_SERVER_ENDPOINT must be set to the Logix Emulate instance when AB_SERVER_PROFILE=emulate.");
|
||||
|
||||
var options = new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions($"ab://{endpoint}/1,0")],
|
||||
EnableAlarmProjection = true,
|
||||
AlarmPollInterval = TimeSpan.FromMilliseconds(200),
|
||||
Tags = [
|
||||
new AbCipTagDefinition(
|
||||
Name: "HighTempAlarm",
|
||||
DeviceHostAddress: $"ab://{endpoint}/1,0",
|
||||
TagPath: "HighTempAlarm",
|
||||
DataType: AbCipDataType.Structure,
|
||||
Members: [
|
||||
new AbCipStructureMember("InFaulted", AbCipDataType.DInt),
|
||||
new AbCipStructureMember("Acked", AbCipDataType.DInt),
|
||||
new AbCipStructureMember("Severity", AbCipDataType.DInt),
|
||||
new AbCipStructureMember("In", AbCipDataType.DInt),
|
||||
]),
|
||||
// The "simulate the alarm input" bit the ladder watches.
|
||||
new AbCipTagDefinition(
|
||||
Name: "SimulateAlarm",
|
||||
DeviceHostAddress: $"ab://{endpoint}/1,0",
|
||||
TagPath: "SimulateAlarm",
|
||||
DataType: AbCipDataType.Bool,
|
||||
Writable: true),
|
||||
],
|
||||
};
|
||||
|
||||
await using var drv = new AbCipDriver(options, driverInstanceId: "emulate-almd");
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var raised = new TaskCompletionSource<AlarmEventArgs>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
drv.OnAlarmEvent += (_, e) =>
|
||||
{
|
||||
if (e.Message.Contains("raised")) raised.TrySetResult(e);
|
||||
};
|
||||
|
||||
var sub = await drv.SubscribeAlarmsAsync(
|
||||
["HighTempAlarm"], TestContext.Current.CancellationToken);
|
||||
|
||||
// Pulse the input bit the ladder watches, then wait for the driver's poll loop
|
||||
// to see InFaulted rise + fire the raise event.
|
||||
_ = await drv.WriteAsync(
|
||||
[new WriteRequest("SimulateAlarm", true)],
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
var got = await Task.WhenAny(raised.Task, Task.Delay(TimeSpan.FromSeconds(5)));
|
||||
got.ShouldBe(raised.Task, "driver must surface the ALMD raise within 5 s of the ladder-driven edge");
|
||||
var args = await raised.Task;
|
||||
args.SourceNodeId.ShouldBe("HighTempAlarm");
|
||||
args.AlarmType.ShouldBe("ALMD");
|
||||
|
||||
await drv.UnsubscribeAlarmsAsync(sub, TestContext.Current.CancellationToken);
|
||||
|
||||
// Reset the bit so the next test run starts from a known state.
|
||||
_ = await drv.WriteAsync(
|
||||
[new WriteRequest("SimulateAlarm", false)],
|
||||
TestContext.Current.CancellationToken);
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Emulate;
|
||||
|
||||
/// <summary>
|
||||
/// Golden-box-tier UDT read tests against Rockwell Studio 5000 Logix Emulate.
|
||||
/// Promotes the whole-UDT-read optimization (task #194) from unit-only coverage
|
||||
/// (golden Template Object byte buffers + <see cref="AbCipUdtMemberLayoutTests"/>)
|
||||
/// to end-to-end wire-level coverage — Emulate's firmware speaks the same CIP
|
||||
/// Template Object responses real hardware does, so the member-offset math + the
|
||||
/// <c>AbCipUdtReadPlanner</c> grouping get validated against production semantics.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Required Emulate project state</b> (see <c>LogixProject/README.md</c>
|
||||
/// for the L5X export that seeds this; ship the project once Emulate is on the
|
||||
/// integration host):</para>
|
||||
/// <list type="bullet">
|
||||
/// <item>UDT <c>Motor_UDT</c> with members <c>Speed : DINT</c>, <c>Torque : REAL</c>,
|
||||
/// <c>Status : DINT</c> — the member set <see cref="AbCipUdtMemberLayoutTests"/>
|
||||
/// uses as its declared-layout golden reference.</item>
|
||||
/// <item>Controller-scope tag <c>Motor1 : Motor_UDT</c> with seed values
|
||||
/// Speed=<c>1800</c>, Torque=<c>42.5f</c>, Status=<c>0x0001</c>.</item>
|
||||
/// </list>
|
||||
/// <para>Runs only when <c>AB_SERVER_PROFILE=emulate</c>. With ab_server
|
||||
/// (the default), skips cleanly — ab_server lacks UDT / Template Object emulation
|
||||
/// so this wire-level test couldn't pass against it regardless.</para>
|
||||
/// </remarks>
|
||||
[Collection("AbServerEmulate")]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Tier", "Emulate")]
|
||||
public sealed class AbCipEmulateUdtReadTests
|
||||
{
|
||||
[AbServerFact]
|
||||
public async Task WholeUdt_read_decodes_each_member_at_its_Template_Object_offset()
|
||||
{
|
||||
AbServerProfileGate.SkipUnless(AbServerProfileGate.Emulate);
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("AB_SERVER_ENDPOINT")
|
||||
?? throw new InvalidOperationException(
|
||||
"AB_SERVER_ENDPOINT must be set to the Logix Emulate instance " +
|
||||
"(e.g. '10.0.0.42:44818') when AB_SERVER_PROFILE=emulate.");
|
||||
|
||||
var options = new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions($"ab://{endpoint}/1,0")],
|
||||
Tags = [
|
||||
new AbCipTagDefinition(
|
||||
Name: "Motor1",
|
||||
DeviceHostAddress: $"ab://{endpoint}/1,0",
|
||||
TagPath: "Motor1",
|
||||
DataType: AbCipDataType.Structure,
|
||||
Members: [
|
||||
new AbCipStructureMember("Speed", AbCipDataType.DInt),
|
||||
new AbCipStructureMember("Torque", AbCipDataType.Real),
|
||||
new AbCipStructureMember("Status", AbCipDataType.DInt),
|
||||
]),
|
||||
],
|
||||
Timeout = TimeSpan.FromSeconds(5),
|
||||
};
|
||||
|
||||
await using var drv = new AbCipDriver(options, driverInstanceId: "emulate-udt-smoke");
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
// Whole-UDT read optimization from task #194: one libplctag read on the
|
||||
// parent tag, three decodes from the buffer at member offsets. Asserts
|
||||
// Emulate's Template Object response matches what AbCipUdtMemberLayout
|
||||
// computes from the declared member set.
|
||||
var snapshots = await drv.ReadAsync(
|
||||
["Motor1.Speed", "Motor1.Torque", "Motor1.Status"],
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
snapshots.Count.ShouldBe(3);
|
||||
foreach (var s in snapshots) s.StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
Convert.ToInt32(snapshots[0].Value).ShouldBe(1800);
|
||||
Convert.ToSingle(snapshots[1].Value).ShouldBe(42.5f, tolerance: 0.001f);
|
||||
Convert.ToInt32(snapshots[2].Value).ShouldBe(0x0001);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
# Logix Emulate project stub
|
||||
|
||||
This folder holds the Studio 5000 project that Logix Emulate loads when running
|
||||
the Emulate-tier integration tests
|
||||
(`tests/.../AbCip.IntegrationTests/Emulate/*.cs`, gated on
|
||||
`AB_SERVER_PROFILE=emulate`).
|
||||
|
||||
**Status today**: stub. The actual `.L5X` export isn't committed yet; once
|
||||
the Emulate PC is running + a project with the required state exists,
|
||||
export to L5X + drop it here as `OtOpcUaAbCipFixture.L5X`.
|
||||
|
||||
## Why L5X, not .ACD
|
||||
|
||||
Studio 5000 ships two save formats: `.ACD` (binary, the runtime project)
|
||||
and `.L5X` (XML export). Ship the L5X because:
|
||||
|
||||
- Text format — reviewable in PR diffs, diffable in git
|
||||
- Reproducible import across Studio 5000 versions
|
||||
- Doesn't carry per-installation state (license watermarks, revision history)
|
||||
|
||||
Reconstruction workflow: Studio 5000 → open project → File → Save As
|
||||
→ `.L5X`. On a fresh Emulate install: File → Open → select the L5X → it
|
||||
rebuilds the ACD from the XML.
|
||||
|
||||
## Required project state
|
||||
|
||||
The Emulate-tier tests rely on this exact tag / UDT set. Missing any of
|
||||
these makes the dependent test fail loudly (TagNotFound, wrong value,
|
||||
wrong type), not skip silently — Emulate is a tier above the Docker
|
||||
simulator; operators who opted into it get opt-in-level coverage
|
||||
expectations.
|
||||
|
||||
### UDT definitions
|
||||
|
||||
| UDT name | Members | Notes |
|
||||
|---|---|---|
|
||||
| `Motor_UDT` | `Speed : DINT`, `Torque : REAL`, `Status : DINT` | Matches `AbCipUdtMemberLayoutTests` declared-layout golden. Member order fixed — Logix Template Object offsets depend on it |
|
||||
|
||||
### Controller tags
|
||||
|
||||
| Tag | Type | Seed value | Purpose |
|
||||
|---|---|---|---|
|
||||
| `Motor1` | `Motor_UDT` | `{Speed=1800, Torque=42.5, Status=0x0001}` | `AbCipEmulateUdtReadTests.WholeUdt_read_decodes_each_member_at_its_Template_Object_offset` |
|
||||
| `HighTempAlarm` | `ALMD` | default ALMD config, `In` tied to `SimulateAlarm` bit | `AbCipEmulateAlmdTests.Real_ALMD_raise_fires_OnAlarmEvent_through_the_driver_projection` |
|
||||
| `SimulateAlarm` | `BOOL` | `0` | Operator-writable bit the ladder routes into `HighTempAlarm.In` — gives the test a clean way to drive the alarm edge without scripting Emulate directly |
|
||||
|
||||
### Program structure
|
||||
|
||||
- One periodic task `MainTask` @ 100 ms
|
||||
- One program `MainProgram`
|
||||
- One routine `MainRoutine` (Ladder) with a single rung:
|
||||
`XIC SimulateAlarm OTE HighTempAlarm.In`
|
||||
|
||||
That's enough ladder for `SimulateAlarm := 1` to raise the alarm + for
|
||||
`SimulateAlarm := 0` to clear it.
|
||||
|
||||
## Tier-level behaviours this project enables
|
||||
|
||||
Coverage the existing Dockerized `ab_server` fixture can't produce —
|
||||
each verified by an `Emulate/*Tests.cs` class gated on
|
||||
`AB_SERVER_PROFILE=emulate`:
|
||||
|
||||
- **CIP Template Object round-trip** — real Logix template bytes,
|
||||
reads produce the same offset layout the CIP Symbol Object decoder +
|
||||
`AbCipUdtMemberLayout` expect.
|
||||
- **ALMD rising-edge semantics** — real Logix ALMD instruction fires
|
||||
`InFaulted` / `Acked` transitions at cycle boundaries, not at our
|
||||
unit-test fake's timer boundaries.
|
||||
- **Optimized vs unoptimized DB behaviour** — Logix 5380/5580 series
|
||||
runs the Studio 5000 project with optimized-DB-equivalent member
|
||||
access; the driver's read path exercises that wire surface.
|
||||
|
||||
Not in scope even with Emulate — needs real hardware:
|
||||
|
||||
- EtherNet/IP embedded-switch behaviour (Stratix 5700, 1756-EN4TR)
|
||||
- CIP Safety across partitions (Emulate 5580 emulates safety within
|
||||
the chassis but not across nodes)
|
||||
- Redundant chassis failover (1756-RM)
|
||||
- Motion control timing
|
||||
- High-speed discrete-input scheduling
|
||||
|
||||
## How to run the Emulate-tier tests
|
||||
|
||||
On the dev box:
|
||||
|
||||
```powershell
|
||||
$env:AB_SERVER_PROFILE = 'emulate'
|
||||
$env:AB_SERVER_ENDPOINT = '10.0.0.42:44818' # replace with the Emulate PC IP
|
||||
dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
|
||||
```
|
||||
|
||||
With `AB_SERVER_PROFILE` unset or `abserver`, the `Emulate/*Tests.cs`
|
||||
classes skip cleanly; ab_server-backed tests run as usual.
|
||||
|
||||
## See also
|
||||
|
||||
- [`docs/drivers/AbServer-Test-Fixture.md`](../../../docs/drivers/AbServer-Test-Fixture.md)
|
||||
§Logix Emulate golden-box tier — coverage map
|
||||
- [`docs/v2/dev-environment.md`](../../../docs/v2/dev-environment.md)
|
||||
§Integration host — license + networking notes
|
||||
- Studio 5000 Logix Designer + Logix Emulate product pages on the
|
||||
Rockwell TechConnect portal (licensed; internal link only).
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.AbCip\ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="Docker\**\*" CopyToOutputDirectory="PreserveNewest"/>
|
||||
<None Update="LogixProject\**\*" CopyToOutputDirectory="PreserveNewest"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,190 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Task #177 — tests covering ALMD projection detection, feature-flag gate,
|
||||
/// subscribe/unsubscribe lifecycle, state-transition event emission, and acknowledge.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipAlarmProjectionTests
|
||||
{
|
||||
private const string Device = "ab://10.0.0.5/1,0";
|
||||
|
||||
private static AbCipTagDefinition AlmdTag(string name) => new(
|
||||
name, Device, name, AbCipDataType.Structure, Members:
|
||||
[
|
||||
new AbCipStructureMember("InFaulted", AbCipDataType.DInt), // Logix stores ALMD bools as DINT
|
||||
new AbCipStructureMember("Acked", AbCipDataType.DInt),
|
||||
new AbCipStructureMember("Severity", AbCipDataType.DInt),
|
||||
new AbCipStructureMember("In", AbCipDataType.DInt),
|
||||
]);
|
||||
|
||||
[Fact]
|
||||
public void AbCipAlarmDetector_Flags_AlmdSignature_As_Alarm()
|
||||
{
|
||||
var almd = AlmdTag("HighTemp");
|
||||
AbCipAlarmDetector.IsAlmd(almd).ShouldBeTrue();
|
||||
|
||||
var plainUdt = new AbCipTagDefinition("Plain", Device, "Plain", AbCipDataType.Structure, Members:
|
||||
[new AbCipStructureMember("X", AbCipDataType.DInt)]);
|
||||
AbCipAlarmDetector.IsAlmd(plainUdt).ShouldBeFalse();
|
||||
|
||||
var atomic = new AbCipTagDefinition("Plain", Device, "Plain", AbCipDataType.DInt);
|
||||
AbCipAlarmDetector.IsAlmd(atomic).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Severity_Mapping_Matches_OPC_UA_Convention()
|
||||
{
|
||||
// Logix severity 1–1000 — mirror the OpcUaClient ACAndC bucketing.
|
||||
AbCipAlarmProjection.MapSeverity(100).ShouldBe(AlarmSeverity.Low);
|
||||
AbCipAlarmProjection.MapSeverity(400).ShouldBe(AlarmSeverity.Medium);
|
||||
AbCipAlarmProjection.MapSeverity(600).ShouldBe(AlarmSeverity.High);
|
||||
AbCipAlarmProjection.MapSeverity(900).ShouldBe(AlarmSeverity.Critical);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FeatureFlag_Off_SubscribeAlarms_Returns_Handle_But_Never_Polls()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var opts = new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(Device)],
|
||||
Tags = [AlmdTag("HighTemp")],
|
||||
EnableAlarmProjection = false, // explicit; also the default
|
||||
};
|
||||
var drv = new AbCipDriver(opts, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var handle = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
|
||||
handle.ShouldNotBeNull();
|
||||
handle.DiagnosticId.ShouldContain("abcip-alarm-sub-");
|
||||
|
||||
// Wait a touch — if polling were active, a fake member-read would be triggered.
|
||||
await Task.Delay(100);
|
||||
factory.Tags.ShouldNotContainKey("HighTemp.InFaulted");
|
||||
factory.Tags.ShouldNotContainKey("HighTemp.Severity");
|
||||
|
||||
await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FeatureFlag_On_Subscribe_Starts_Polling_And_Fires_Raise_On_0_to_1()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var opts = new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(Device)],
|
||||
Tags = [AlmdTag("HighTemp")],
|
||||
EnableAlarmProjection = true,
|
||||
AlarmPollInterval = TimeSpan.FromMilliseconds(20),
|
||||
};
|
||||
var drv = new AbCipDriver(opts, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var events = new List<AlarmEventArgs>();
|
||||
drv.OnAlarmEvent += (_, e) => { lock (events) events.Add(e); };
|
||||
|
||||
var handle = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
|
||||
|
||||
// The ALMD UDT is declared so whole-UDT grouping kicks in; the parent HighTemp runtime
|
||||
// gets created + polled. Set InFaulted offset-value to 0 first (clear), wait a tick,
|
||||
// then flip to 1 (fault) + wait for the raise event.
|
||||
await WaitForTagCreation(factory, "HighTemp");
|
||||
factory.Tags["HighTemp"].ValuesByOffset[0] = 0; // InFaulted=false at offset 0
|
||||
factory.Tags["HighTemp"].ValuesByOffset[8] = 500; // Severity at offset 8 (after InFaulted+Acked)
|
||||
await Task.Delay(80); // let a tick seed the "last-seen false" state
|
||||
|
||||
factory.Tags["HighTemp"].ValuesByOffset[0] = 1; // flip to faulted
|
||||
await Task.Delay(200); // allow several polls to be safe
|
||||
|
||||
lock (events)
|
||||
{
|
||||
events.ShouldContain(e => e.SourceNodeId == "HighTemp" && e.AlarmType == "ALMD"
|
||||
&& e.Message.Contains("raised"));
|
||||
}
|
||||
|
||||
await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Clear_Event_Fires_On_1_to_0_Transition()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var opts = new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(Device)],
|
||||
Tags = [AlmdTag("HighTemp")],
|
||||
EnableAlarmProjection = true,
|
||||
AlarmPollInterval = TimeSpan.FromMilliseconds(20),
|
||||
};
|
||||
var drv = new AbCipDriver(opts, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var events = new List<AlarmEventArgs>();
|
||||
drv.OnAlarmEvent += (_, e) => { lock (events) events.Add(e); };
|
||||
|
||||
var handle = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
|
||||
await WaitForTagCreation(factory, "HighTemp");
|
||||
|
||||
factory.Tags["HighTemp"].ValuesByOffset[0] = 1;
|
||||
factory.Tags["HighTemp"].ValuesByOffset[8] = 500;
|
||||
await Task.Delay(80); // observe raise
|
||||
|
||||
factory.Tags["HighTemp"].ValuesByOffset[0] = 0;
|
||||
await Task.Delay(200);
|
||||
|
||||
lock (events)
|
||||
{
|
||||
events.ShouldContain(e => e.Message.Contains("raised"));
|
||||
events.ShouldContain(e => e.Message.Contains("cleared"));
|
||||
}
|
||||
|
||||
await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unsubscribe_Stops_The_Poll_Loop()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var opts = new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(Device)],
|
||||
Tags = [AlmdTag("HighTemp")],
|
||||
EnableAlarmProjection = true,
|
||||
AlarmPollInterval = TimeSpan.FromMilliseconds(20),
|
||||
};
|
||||
var drv = new AbCipDriver(opts, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var handle = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
|
||||
await WaitForTagCreation(factory, "HighTemp");
|
||||
var preUnsubReadCount = factory.Tags["HighTemp"].ReadCount;
|
||||
|
||||
await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None);
|
||||
await Task.Delay(100); // well past several poll intervals if the loop were still alive
|
||||
|
||||
var postDelayReadCount = factory.Tags["HighTemp"].ReadCount;
|
||||
// Allow at most one straggler read between the unsubscribe-cancel + the loop exit.
|
||||
(postDelayReadCount - preUnsubReadCount).ShouldBeLessThanOrEqualTo(1);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
private static async Task WaitForTagCreation(FakeAbCipTagFactory factory, string tagName)
|
||||
{
|
||||
var deadline = DateTime.UtcNow.AddSeconds(2);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (factory.Tags.ContainsKey(tagName)) return;
|
||||
await Task.Delay(10);
|
||||
}
|
||||
throw new TimeoutException($"Tag {tagName} was never created by the fake factory.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipBoolInDIntRmwTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Fake tag runtime that stores a DINT value + exposes Read/Write/EncodeValue/DecodeValue
|
||||
/// for DInt. RMW tests use one instance as the "parent" runtime (tag name "Motor.Flags")
|
||||
/// which the driver's WriteBitInDIntAsync reads + writes.
|
||||
/// </summary>
|
||||
private sealed class ParentDintFake(AbCipTagCreateParams p) : FakeAbCipTag(p)
|
||||
{
|
||||
// Uses the base FakeAbCipTag's Value + ReadCount + WriteCount.
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bit_set_reads_parent_ORs_bit_writes_back()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory
|
||||
{
|
||||
Customise = p => new ParentDintFake(p) { Value = 0b0001 },
|
||||
};
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags =
|
||||
[
|
||||
new AbCipTagDefinition("Flag3", "ab://10.0.0.5/1,0", "Motor.Flags.3", AbCipDataType.Bool),
|
||||
],
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("Flag3", true)], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
|
||||
// Parent runtime created under name "Motor.Flags" — distinct from the bit-selector tag.
|
||||
factory.Tags.ShouldContainKey("Motor.Flags");
|
||||
factory.Tags["Motor.Flags"].Value.ShouldBe(0b1001); // bit 3 set, bit 0 preserved
|
||||
factory.Tags["Motor.Flags"].ReadCount.ShouldBe(1);
|
||||
factory.Tags["Motor.Flags"].WriteCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bit_clear_preserves_other_bits()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory
|
||||
{
|
||||
Customise = p => new ParentDintFake(p) { Value = unchecked((int)0xFFFFFFFF) },
|
||||
};
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags = [new AbCipTagDefinition("F", "ab://10.0.0.5/1,0", "Motor.Flags.3", AbCipDataType.Bool)],
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.WriteAsync([new WriteRequest("F", false)], CancellationToken.None);
|
||||
|
||||
var updated = Convert.ToInt32(factory.Tags["Motor.Flags"].Value);
|
||||
(updated & (1 << 3)).ShouldBe(0); // bit 3 cleared
|
||||
(updated & ~(1 << 3)).ShouldBe(unchecked((int)0xFFFFFFF7)); // every other bit preserved
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Concurrent_bit_writes_to_same_parent_compose_correctly()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory
|
||||
{
|
||||
Customise = p => new ParentDintFake(p) { Value = 0 },
|
||||
};
|
||||
var tags = Enumerable.Range(0, 8)
|
||||
.Select(b => new AbCipTagDefinition($"Bit{b}", "ab://10.0.0.5/1,0", $"Flags.{b}", AbCipDataType.Bool))
|
||||
.ToArray();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags = tags,
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await Task.WhenAll(Enumerable.Range(0, 8).Select(b =>
|
||||
drv.WriteAsync([new WriteRequest($"Bit{b}", true)], CancellationToken.None)));
|
||||
|
||||
Convert.ToInt32(factory.Tags["Flags"].Value).ShouldBe(0xFF);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bit_writes_to_different_parents_each_get_own_runtime()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory
|
||||
{
|
||||
Customise = p => new ParentDintFake(p) { Value = 0 },
|
||||
};
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags =
|
||||
[
|
||||
new AbCipTagDefinition("A", "ab://10.0.0.5/1,0", "Motor1.Flags.0", AbCipDataType.Bool),
|
||||
new AbCipTagDefinition("B", "ab://10.0.0.5/1,0", "Motor2.Flags.0", AbCipDataType.Bool),
|
||||
],
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.WriteAsync([new WriteRequest("A", true)], CancellationToken.None);
|
||||
await drv.WriteAsync([new WriteRequest("B", true)], CancellationToken.None);
|
||||
|
||||
factory.Tags.ShouldContainKey("Motor1.Flags");
|
||||
factory.Tags.ShouldContainKey("Motor2.Flags");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Repeat_bit_writes_reuse_one_parent_runtime()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory
|
||||
{
|
||||
Customise = p => new ParentDintFake(p) { Value = 0 },
|
||||
};
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags =
|
||||
[
|
||||
new AbCipTagDefinition("Bit0", "ab://10.0.0.5/1,0", "Flags.0", AbCipDataType.Bool),
|
||||
new AbCipTagDefinition("Bit5", "ab://10.0.0.5/1,0", "Flags.5", AbCipDataType.Bool),
|
||||
],
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.WriteAsync([new WriteRequest("Bit0", true)], CancellationToken.None);
|
||||
await drv.WriteAsync([new WriteRequest("Bit5", true)], CancellationToken.None);
|
||||
|
||||
// Three factory invocations: two bit-selector tags (never used for writes, but the
|
||||
// driver may create them opportunistically) + one shared parent. Assert the parent was
|
||||
// init'd exactly once + used for both writes.
|
||||
factory.Tags["Flags"].InitializeCount.ShouldBe(1);
|
||||
factory.Tags["Flags"].WriteCount.ShouldBe(2);
|
||||
Convert.ToInt32(factory.Tags["Flags"].Value).ShouldBe(0x21); // bits 0 + 5
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipDriverDiscoveryTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task PreDeclared_tags_emit_as_variables_under_device_folder()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0", DeviceName: "Line1-PLC")],
|
||||
Tags =
|
||||
[
|
||||
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Motor1.Speed", AbCipDataType.DInt),
|
||||
new AbCipTagDefinition("Temperature", "ab://10.0.0.5/1,0", "T", AbCipDataType.Real, Writable: false),
|
||||
],
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "AbCip");
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "ab://10.0.0.5/1,0" && f.DisplayName == "Line1-PLC");
|
||||
builder.Variables.Count.ShouldBe(2);
|
||||
builder.Variables.Single(v => v.BrowseName == "Speed").Info.SecurityClass.ShouldBe(SecurityClassification.Operate);
|
||||
builder.Variables.Single(v => v.BrowseName == "Temperature").Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Device_folder_displayname_falls_back_to_host_address()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], // no DeviceName
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "ab://10.0.0.5/1,0"
|
||||
&& f.DisplayName == "ab://10.0.0.5/1,0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreDeclared_system_tags_are_filtered_out()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags =
|
||||
[
|
||||
new AbCipTagDefinition("__DEFVAL_X", "ab://10.0.0.5/1,0", "__DEFVAL_X", AbCipDataType.DInt),
|
||||
new AbCipTagDefinition("Routine:SomeRoutine", "ab://10.0.0.5/1,0", "R", AbCipDataType.DInt),
|
||||
new AbCipTagDefinition("UserTag", "ab://10.0.0.5/1,0", "U", AbCipDataType.DInt),
|
||||
],
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Variables.Select(v => v.BrowseName).ShouldBe(["UserTag"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tags_for_mismatched_device_are_ignored()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags = [new AbCipTagDefinition("Orphan", "ab://10.0.0.99/1,0", "O", AbCipDataType.DInt)],
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Variables.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Controller_enumeration_adds_tags_under_Discovered_folder()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var enumeratorFactory = new FakeEnumeratorFactory(
|
||||
new AbCipDiscoveredTag("Pressure", null, AbCipDataType.Real, ReadOnly: false),
|
||||
new AbCipDiscoveredTag("StepIndex", ProgramScope: "MainProgram", AbCipDataType.DInt, ReadOnly: false));
|
||||
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
EnableControllerBrowse = true,
|
||||
}, "drv-1", enumeratorFactory: enumeratorFactory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "Discovered");
|
||||
builder.Variables.Select(v => v.Info.FullName).ShouldContain("Pressure");
|
||||
builder.Variables.Select(v => v.Info.FullName).ShouldContain("Program:MainProgram.StepIndex");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Controller_enumeration_honours_system_tag_hint_and_filter()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var factory = new FakeEnumeratorFactory(
|
||||
new AbCipDiscoveredTag("HiddenByHint", null, AbCipDataType.DInt, ReadOnly: false, IsSystemTag: true),
|
||||
new AbCipDiscoveredTag("Routine:Foo", null, AbCipDataType.DInt, ReadOnly: false, IsSystemTag: false),
|
||||
new AbCipDiscoveredTag("KeepMe", null, AbCipDataType.DInt, ReadOnly: false));
|
||||
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
EnableControllerBrowse = true,
|
||||
}, "drv-1", enumeratorFactory: factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Variables.Select(v => v.Info.FullName).ShouldBe(["KeepMe"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Controller_enumeration_ReadOnly_surfaces_ViewOnly_classification()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var factory = new FakeEnumeratorFactory(
|
||||
new AbCipDiscoveredTag("SafetyTag", null, AbCipDataType.DInt, ReadOnly: true));
|
||||
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
EnableControllerBrowse = true,
|
||||
}, "drv-1", enumeratorFactory: factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Variables.Single().Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Controller_enumeration_receives_correct_device_params()
|
||||
{
|
||||
var factory = new FakeEnumeratorFactory();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5:44818/1,2,3", AbCipPlcFamily.ControlLogix)],
|
||||
Timeout = TimeSpan.FromSeconds(7),
|
||||
EnableControllerBrowse = true,
|
||||
}, "drv-1", enumeratorFactory: factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(new RecordingBuilder(), CancellationToken.None);
|
||||
|
||||
var capturedParams = factory.LastDeviceParams.ShouldNotBeNull();
|
||||
capturedParams.Gateway.ShouldBe("10.0.0.5");
|
||||
capturedParams.Port.ShouldBe(44818);
|
||||
capturedParams.CipPath.ShouldBe("1,2,3");
|
||||
capturedParams.LibplctagPlcAttribute.ShouldBe("controllogix");
|
||||
capturedParams.TagName.ShouldBe("@tags");
|
||||
capturedParams.Timeout.ShouldBe(TimeSpan.FromSeconds(7));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Default_enumerator_factory_is_used_when_not_injected()
|
||||
{
|
||||
// Sanity — absent enumerator factory does not crash discovery + uses EmptyAbCipTagEnumerator
|
||||
// (covered by the other tests which instantiate without injecting a factory).
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
}, "drv-1");
|
||||
drv.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("__DEFVAL_X", true)]
|
||||
[InlineData("__DEFAULT_Y", true)]
|
||||
[InlineData("Routine:Main", true)]
|
||||
[InlineData("Task:MainTask", true)]
|
||||
[InlineData("Local:1:I", true)]
|
||||
[InlineData("Map:Alias", true)]
|
||||
[InlineData("Axis:MoveX", true)]
|
||||
[InlineData("Cam:Profile1", true)]
|
||||
[InlineData("MotionGroup:MG0", true)]
|
||||
[InlineData("Motor1", false)]
|
||||
[InlineData("Program:Main.Step", false)]
|
||||
[InlineData("Recipe_2", false)]
|
||||
[InlineData("", true)]
|
||||
[InlineData(" ", true)]
|
||||
public void SystemTagFilter_rejects_infrastructure_names(string name, bool expected)
|
||||
{
|
||||
AbCipSystemTagFilter.IsSystemTag(name).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TemplateCache_roundtrip_put_get()
|
||||
{
|
||||
var cache = new AbCipTemplateCache();
|
||||
var shape = new AbCipUdtShape("MyUdt", 32,
|
||||
[
|
||||
new AbCipUdtMember("A", 0, AbCipDataType.DInt, ArrayLength: 1),
|
||||
new AbCipUdtMember("B", 4, AbCipDataType.Real, ArrayLength: 1),
|
||||
]);
|
||||
cache.Put("ab://10.0.0.5/1,0", 42, shape);
|
||||
|
||||
cache.TryGet("ab://10.0.0.5/1,0", 42).ShouldBe(shape);
|
||||
cache.TryGet("ab://10.0.0.5/1,0", 99).ShouldBeNull();
|
||||
cache.Count.ShouldBe(1);
|
||||
|
||||
cache.Clear();
|
||||
cache.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FlushOptionalCachesAsync_clears_template_cache()
|
||||
{
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions(), "drv-1");
|
||||
drv.TemplateCache.Put("dev", 1, new AbCipUdtShape("T", 4, []));
|
||||
drv.TemplateCache.Count.ShouldBe(1);
|
||||
|
||||
await drv.FlushOptionalCachesAsync(CancellationToken.None);
|
||||
drv.TemplateCache.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private sealed class RecordingBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
|
||||
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
|
||||
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||
{ Folders.Add((browseName, displayName)); return this; }
|
||||
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
|
||||
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
|
||||
|
||||
public void AddProperty(string _, DriverDataType __, object? ___) { }
|
||||
|
||||
private sealed class Handle(string fullRef) : IVariableHandle
|
||||
{
|
||||
public string FullReference => fullRef;
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
|
||||
}
|
||||
private sealed class NullSink : IAlarmConditionSink
|
||||
{
|
||||
public void OnTransition(AlarmEventArgs args) { }
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeEnumeratorFactory : IAbCipTagEnumeratorFactory
|
||||
{
|
||||
private readonly AbCipDiscoveredTag[] _tags;
|
||||
public AbCipTagCreateParams? LastDeviceParams { get; private set; }
|
||||
public FakeEnumeratorFactory(params AbCipDiscoveredTag[] tags) => _tags = tags;
|
||||
public IAbCipTagEnumerator Create() => new FakeEnumerator(this);
|
||||
|
||||
private sealed class FakeEnumerator(FakeEnumeratorFactory outer) : IAbCipTagEnumerator
|
||||
{
|
||||
public async IAsyncEnumerable<AbCipDiscoveredTag> EnumerateAsync(
|
||||
AbCipTagCreateParams deviceParams,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
outer.LastDeviceParams = deviceParams;
|
||||
await Task.CompletedTask;
|
||||
foreach (var t in outer._tags) yield return t;
|
||||
}
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipDriverReadTests
|
||||
{
|
||||
private static (AbCipDriver drv, FakeAbCipTagFactory factory) NewDriver(params AbCipTagDefinition[] tags)
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var opts = new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags = tags,
|
||||
};
|
||||
var drv = new AbCipDriver(opts, "drv-1", factory);
|
||||
return (drv, factory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_reference_maps_to_BadNodeIdUnknown()
|
||||
{
|
||||
var (drv, _) = NewDriver();
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var snapshots = await drv.ReadAsync(["does-not-exist"], CancellationToken.None);
|
||||
|
||||
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
|
||||
snapshots.Single().Value.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tag_on_unknown_device_maps_to_BadNodeIdUnknown()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var opts = new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags = [new AbCipTagDefinition("Orphan", "ab://10.0.0.99/1,0", "Tag1", AbCipDataType.DInt)],
|
||||
};
|
||||
var drv = new AbCipDriver(opts, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Orphan"], CancellationToken.None);
|
||||
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Successful_DInt_read_returns_Good_with_value()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Motor1.Speed", AbCipDataType.DInt));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// Customise the fake before the first read so the tag returns 4200.
|
||||
factory.Customise = p => new FakeAbCipTag(p) { Value = 4200 };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Speed"], CancellationToken.None);
|
||||
|
||||
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
snapshots.Single().Value.ShouldBe(4200);
|
||||
factory.Tags["Motor1.Speed"].InitializeCount.ShouldBe(1);
|
||||
factory.Tags["Motor1.Speed"].ReadCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Repeat_read_reuses_runtime_without_reinitialise()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Motor1.Speed", AbCipDataType.DInt));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbCipTag(p) { Value = 1 };
|
||||
|
||||
await drv.ReadAsync(["Speed"], CancellationToken.None);
|
||||
await drv.ReadAsync(["Speed"], CancellationToken.None);
|
||||
await drv.ReadAsync(["Speed"], CancellationToken.None);
|
||||
|
||||
factory.Tags["Motor1.Speed"].InitializeCount.ShouldBe(1); // lazy init happens once
|
||||
factory.Tags["Motor1.Speed"].ReadCount.ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NonZero_libplctag_status_maps_via_AbCipStatusMapper()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("Ghost", "ab://10.0.0.5/1,0", "Missing.Tag", AbCipDataType.DInt));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbCipTag(p) { Status = -14 /* PLCTAG_ERR_NOT_FOUND */ };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Ghost"], CancellationToken.None);
|
||||
|
||||
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
|
||||
snapshots.Single().Value.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Exception_during_read_surfaces_BadCommunicationError()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("Broken", "ab://10.0.0.5/1,0", "Broken", AbCipDataType.Real));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbCipTag(p) { ThrowOnRead = true };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Broken"], CancellationToken.None);
|
||||
|
||||
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadCommunicationError);
|
||||
snapshots.Single().Value.ShouldBeNull();
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Batched_reads_preserve_order_and_per_tag_status()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("A", "ab://10.0.0.5/1,0", "A", AbCipDataType.DInt),
|
||||
new AbCipTagDefinition("B", "ab://10.0.0.5/1,0", "B", AbCipDataType.Real),
|
||||
new AbCipTagDefinition("C", "ab://10.0.0.5/1,0", "C", AbCipDataType.String));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => p.TagName switch
|
||||
{
|
||||
"A" => new FakeAbCipTag(p) { Value = 42 },
|
||||
"B" => new FakeAbCipTag(p) { Value = 3.14f },
|
||||
_ => new FakeAbCipTag(p) { Value = "hello" },
|
||||
};
|
||||
|
||||
var snapshots = await drv.ReadAsync(["A", "B", "C"], CancellationToken.None);
|
||||
|
||||
snapshots.Count.ShouldBe(3);
|
||||
snapshots[0].Value.ShouldBe(42);
|
||||
snapshots[1].Value.ShouldBe(3.14f);
|
||||
snapshots[2].Value.ShouldBe("hello");
|
||||
snapshots.ShouldAllBe(s => s.StatusCode == AbCipStatusMapper.Good);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Successful_read_marks_health_Healthy()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("Pressure", "ab://10.0.0.5/1,0", "PT_101", AbCipDataType.Real));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbCipTag(p) { Value = 14.7f };
|
||||
|
||||
await drv.ReadAsync(["Pressure"], CancellationToken.None);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
drv.GetHealth().LastSuccessfulRead.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TagCreateParams_are_built_from_device_and_profile()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("Counter", "ab://10.0.0.5/1,0", "Program:P.Counter", AbCipDataType.DInt));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.ReadAsync(["Counter"], CancellationToken.None);
|
||||
|
||||
var p = factory.Tags["Program:P.Counter"].CreationParams;
|
||||
p.Gateway.ShouldBe("10.0.0.5");
|
||||
p.Port.ShouldBe(44818);
|
||||
p.CipPath.ShouldBe("1,0");
|
||||
p.LibplctagPlcAttribute.ShouldBe("controllogix");
|
||||
p.TagName.ShouldBe("Program:P.Counter");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cancellation_propagates_from_read()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("Slow", "ab://10.0.0.5/1,0", "Slow", AbCipDataType.DInt));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbCipTag(p)
|
||||
{
|
||||
ThrowOnRead = true,
|
||||
Exception = new OperationCanceledException(),
|
||||
};
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
await Should.ThrowAsync<OperationCanceledException>(
|
||||
() => drv.ReadAsync(["Slow"], cts.Token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShutdownAsync_disposes_each_tag_runtime()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("A", "ab://10.0.0.5/1,0", "A", AbCipDataType.DInt),
|
||||
new AbCipTagDefinition("B", "ab://10.0.0.5/1,0", "B", AbCipDataType.DInt));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbCipTag(p) { Value = 1 };
|
||||
await drv.ReadAsync(["A", "B"], CancellationToken.None);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
|
||||
factory.Tags["A"].Disposed.ShouldBeTrue();
|
||||
factory.Tags["B"].Disposed.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Initialize_failure_disposes_tag_and_surfaces_communication_error()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("DoomedTag", "ab://10.0.0.5/1,0", "Nope", AbCipDataType.DInt));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbCipTag(p) { ThrowOnInitialize = true };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["DoomedTag"], CancellationToken.None);
|
||||
|
||||
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadCommunicationError);
|
||||
factory.Tags["Nope"].Disposed.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.PlcFamilies;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipDriverTests
|
||||
{
|
||||
[Fact]
|
||||
public void DriverType_is_AbCip()
|
||||
{
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions(), "drv-1");
|
||||
drv.DriverType.ShouldBe("AbCip");
|
||||
drv.DriverInstanceId.ShouldBe("drv-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InitializeAsync_with_empty_devices_succeeds_and_marks_healthy()
|
||||
{
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions(), "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InitializeAsync_registers_each_device_with_its_family_profile()
|
||||
{
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices =
|
||||
[
|
||||
new AbCipDeviceOptions("ab://10.0.0.5/1,0", AbCipPlcFamily.ControlLogix),
|
||||
new AbCipDeviceOptions("ab://10.0.0.6/", AbCipPlcFamily.Micro800),
|
||||
],
|
||||
}, "drv-1");
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.DeviceCount.ShouldBe(2);
|
||||
drv.GetDeviceState("ab://10.0.0.5/1,0")!.Profile.ShouldBe(AbCipPlcFamilyProfile.ControlLogix);
|
||||
drv.GetDeviceState("ab://10.0.0.6/")!.Profile.ShouldBe(AbCipPlcFamilyProfile.Micro800);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InitializeAsync_with_malformed_host_address_faults()
|
||||
{
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("not-a-valid-address")],
|
||||
}, "drv-1");
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(
|
||||
() => drv.InitializeAsync("{}", CancellationToken.None));
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShutdownAsync_clears_devices_and_marks_unknown()
|
||||
{
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
}, "drv-1");
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
drv.DeviceCount.ShouldBe(1);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
drv.DeviceCount.ShouldBe(0);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReinitializeAsync_cycles_devices()
|
||||
{
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
}, "drv-1");
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await drv.ReinitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.DeviceCount.ShouldBe(1);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Family_profiles_expose_expected_defaults()
|
||||
{
|
||||
AbCipPlcFamilyProfile.ControlLogix.LibplctagPlcAttribute.ShouldBe("controllogix");
|
||||
AbCipPlcFamilyProfile.ControlLogix.DefaultConnectionSize.ShouldBe(4002);
|
||||
AbCipPlcFamilyProfile.ControlLogix.DefaultCipPath.ShouldBe("1,0");
|
||||
|
||||
AbCipPlcFamilyProfile.Micro800.DefaultCipPath.ShouldBe(""); // no backplane routing
|
||||
AbCipPlcFamilyProfile.Micro800.SupportsRequestPacking.ShouldBeFalse();
|
||||
AbCipPlcFamilyProfile.Micro800.SupportsConnectedMessaging.ShouldBeFalse();
|
||||
|
||||
AbCipPlcFamilyProfile.CompactLogix.DefaultConnectionSize.ShouldBe(504);
|
||||
AbCipPlcFamilyProfile.GuardLogix.LibplctagPlcAttribute.ShouldBe("controllogix");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlcTagHandle_IsInvalid_for_zero_or_negative_native_id()
|
||||
{
|
||||
PlcTagHandle.FromNative(-5).IsInvalid.ShouldBeTrue();
|
||||
PlcTagHandle.FromNative(0).IsInvalid.ShouldBeTrue();
|
||||
PlcTagHandle.FromNative(42).IsInvalid.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlcTagHandle_Dispose_is_idempotent()
|
||||
{
|
||||
var h = PlcTagHandle.FromNative(42);
|
||||
h.Dispose();
|
||||
h.Dispose(); // must not throw
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AbCipDataType_maps_atomics_to_driver_types()
|
||||
{
|
||||
AbCipDataType.Bool.ToDriverDataType().ShouldBe(DriverDataType.Boolean);
|
||||
AbCipDataType.DInt.ToDriverDataType().ShouldBe(DriverDataType.Int32);
|
||||
AbCipDataType.Real.ToDriverDataType().ShouldBe(DriverDataType.Float32);
|
||||
AbCipDataType.LReal.ToDriverDataType().ShouldBe(DriverDataType.Float64);
|
||||
AbCipDataType.String.ToDriverDataType().ShouldBe(DriverDataType.String);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Task #194 — ReadAsync integration tests for the whole-UDT grouping path. The fake
|
||||
/// runtime records ReadCount + surfaces member values by byte offset so we can assert
|
||||
/// both "one read per parent UDT" and "each member decoded at the correct offset."
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipDriverWholeUdtReadTests
|
||||
{
|
||||
private const string Device = "ab://10.0.0.5/1,0";
|
||||
|
||||
private static (AbCipDriver drv, FakeAbCipTagFactory factory) NewDriver(params AbCipTagDefinition[] tags)
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var opts = new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(Device)],
|
||||
Tags = tags,
|
||||
};
|
||||
return (new AbCipDriver(opts, "drv-1", factory), factory);
|
||||
}
|
||||
|
||||
private static AbCipTagDefinition MotorUdt() => new(
|
||||
"Motor", Device, "Motor", AbCipDataType.Structure, Members:
|
||||
[
|
||||
new AbCipStructureMember("Speed", AbCipDataType.DInt), // offset 0
|
||||
new AbCipStructureMember("Torque", AbCipDataType.Real), // offset 4
|
||||
]);
|
||||
|
||||
[Fact]
|
||||
public async Task Two_members_of_same_udt_trigger_one_parent_read()
|
||||
{
|
||||
var (drv, factory) = NewDriver(MotorUdt());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Motor.Speed", "Motor.Torque"], CancellationToken.None);
|
||||
|
||||
snapshots.Count.ShouldBe(2);
|
||||
snapshots[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
snapshots[1].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
|
||||
// Factory should have created ONE runtime (for the parent "Motor") + issued ONE read.
|
||||
// Without the optimization two runtimes (one per member) + two reads would appear.
|
||||
factory.Tags.Count.ShouldBe(1);
|
||||
factory.Tags.ShouldContainKey("Motor");
|
||||
factory.Tags["Motor"].ReadCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Each_member_decodes_at_its_own_offset()
|
||||
{
|
||||
var (drv, factory) = NewDriver(MotorUdt());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// Arrange the offset-keyed values before the read fires — the planner places
|
||||
// Speed at offset 0 (DInt) and Torque at offset 4 (Real).
|
||||
// The fake records CreationParams so we fetch it up front by the parent name.
|
||||
var snapshotsTask = drv.ReadAsync(["Motor.Speed", "Motor.Torque"], CancellationToken.None);
|
||||
// The factory creates the runtime inside ReadAsync; we need to set the offset map
|
||||
// AFTER creation. Easier path: create the runtime on demand by reading once then
|
||||
// re-arming. Instead: seed via a pre-read by constructing the fake in the factory's
|
||||
// customise hook.
|
||||
var snapshots = await snapshotsTask;
|
||||
|
||||
// First run establishes the runtime + gives the fake a chance to hold its reference.
|
||||
factory.Tags["Motor"].ValuesByOffset[0] = 1234; // Speed
|
||||
factory.Tags["Motor"].ValuesByOffset[4] = 9.5f; // Torque
|
||||
|
||||
snapshots = await drv.ReadAsync(["Motor.Speed", "Motor.Torque"], CancellationToken.None);
|
||||
snapshots[0].Value.ShouldBe(1234);
|
||||
snapshots[1].Value.ShouldBe(9.5f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Parent_read_failure_stamps_every_grouped_member_Bad()
|
||||
{
|
||||
var (drv, factory) = NewDriver(MotorUdt());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// Prime runtime existence via a first (successful) read so we can flip it to error.
|
||||
await drv.ReadAsync(["Motor.Speed", "Motor.Torque"], CancellationToken.None);
|
||||
factory.Tags["Motor"].Status = -3; // libplctag BadTimeout — mapped in AbCipStatusMapper
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Motor.Speed", "Motor.Torque"], CancellationToken.None);
|
||||
|
||||
snapshots.Count.ShouldBe(2);
|
||||
snapshots[0].StatusCode.ShouldNotBe(AbCipStatusMapper.Good);
|
||||
snapshots[0].Value.ShouldBeNull();
|
||||
snapshots[1].StatusCode.ShouldNotBe(AbCipStatusMapper.Good);
|
||||
snapshots[1].Value.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Mixed_batch_groups_udt_and_falls_back_atomics()
|
||||
{
|
||||
var plain = new AbCipTagDefinition("PlainDint", Device, "PlainDint", AbCipDataType.DInt);
|
||||
var (drv, factory) = NewDriver(MotorUdt(), plain);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var snapshots = await drv.ReadAsync(
|
||||
["Motor.Speed", "PlainDint", "Motor.Torque"], CancellationToken.None);
|
||||
|
||||
snapshots.Count.ShouldBe(3);
|
||||
// Motor parent ran one read, PlainDint ran its own read = 2 runtimes, 2 reads total.
|
||||
factory.Tags.Count.ShouldBe(2);
|
||||
factory.Tags.ShouldContainKey("Motor");
|
||||
factory.Tags.ShouldContainKey("PlainDint");
|
||||
factory.Tags["Motor"].ReadCount.ShouldBe(1);
|
||||
factory.Tags["PlainDint"].ReadCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Single_member_of_Udt_uses_per_tag_read_path()
|
||||
{
|
||||
// One member of a UDT doesn't benefit from grouping — the planner demotes to
|
||||
// fallback so the member-level runtime (distinct from the parent runtime) is used,
|
||||
// matching pre-#194 behavior.
|
||||
var (drv, factory) = NewDriver(MotorUdt());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.ReadAsync(["Motor.Speed"], CancellationToken.None);
|
||||
|
||||
factory.Tags.ShouldContainKey("Motor.Speed");
|
||||
factory.Tags.ShouldNotContainKey("Motor");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipDriverWriteTests
|
||||
{
|
||||
private static (AbCipDriver drv, FakeAbCipTagFactory factory) NewDriver(params AbCipTagDefinition[] tags)
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags = tags,
|
||||
}, "drv-1", factory);
|
||||
return (drv, factory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_reference_maps_to_BadNodeIdUnknown()
|
||||
{
|
||||
var (drv, _) = NewDriver();
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("does-not-exist", 1)], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Non_writable_tag_maps_to_BadNotWritable()
|
||||
{
|
||||
var (drv, _) = NewDriver(
|
||||
new AbCipTagDefinition("ReadOnly", "ab://10.0.0.5/1,0", "RO", AbCipDataType.DInt, Writable: false));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("ReadOnly", 7)], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNotWritable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Successful_DInt_write_encodes_and_flushes()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Motor1.Speed", AbCipDataType.DInt));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("Speed", 4200)], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
factory.Tags["Motor1.Speed"].Value.ShouldBe(4200);
|
||||
factory.Tags["Motor1.Speed"].WriteCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bit_in_dint_write_now_succeeds_via_RMW()
|
||||
{
|
||||
// Task #181 pass 2 lifted this gap — BOOL-within-DINT writes now go through
|
||||
// WriteBitInDIntAsync + a parallel parent-DINT runtime, so the result is Good rather
|
||||
// than BadNotSupported. Full RMW semantics covered by AbCipBoolInDIntRmwTests.
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags = [new AbCipTagDefinition("Flag3", "ab://10.0.0.5/1,0", "Flags.3", AbCipDataType.Bool)],
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("Flag3", true)], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Non_zero_libplctag_status_after_write_maps_via_AbCipStatusMapper()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("Broken", "ab://10.0.0.5/1,0", "Broken", AbCipDataType.DInt));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbCipTag(p) { Status = -5 /* timeout */ };
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("Broken", 1)], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadTimeout);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Type_mismatch_surfaces_BadTypeMismatch()
|
||||
{
|
||||
var (drv, _) = NewDriver(
|
||||
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// Force a FormatException inside Convert.ToInt32 via a runtime that forwards to real Convert.
|
||||
var factory = new FakeAbCipTagFactory
|
||||
{
|
||||
Customise = p => new RealConvertFake(p),
|
||||
};
|
||||
var drv2 = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags = [new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt)],
|
||||
}, "drv-2", factory);
|
||||
await drv2.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv2.WriteAsync(
|
||||
[new WriteRequest("Speed", "not-a-number")], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadTypeMismatch);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Overflow_surfaces_BadOutOfRange()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory { Customise = p => new RealConvertFake(p) };
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags = [new AbCipTagDefinition("Narrow", "ab://10.0.0.5/1,0", "N", AbCipDataType.Int)],
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("Narrow", 1_000_000)], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadOutOfRange);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Exception_during_write_surfaces_BadCommunicationError()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("Broken", "ab://10.0.0.5/1,0", "Broken", AbCipDataType.DInt));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new ThrowOnWriteFake(p);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("Broken", 1)], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadCommunicationError);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Batch_preserves_order_across_success_and_failure()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags =
|
||||
[
|
||||
new AbCipTagDefinition("A", "ab://10.0.0.5/1,0", "A", AbCipDataType.DInt),
|
||||
new AbCipTagDefinition("B", "ab://10.0.0.5/1,0", "B", AbCipDataType.DInt, Writable: false),
|
||||
new AbCipTagDefinition("C", "ab://10.0.0.5/1,0", "C", AbCipDataType.DInt),
|
||||
],
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[
|
||||
new WriteRequest("A", 1),
|
||||
new WriteRequest("B", 2),
|
||||
new WriteRequest("UnknownTag", 3),
|
||||
new WriteRequest("C", 4),
|
||||
], CancellationToken.None);
|
||||
|
||||
results.Count.ShouldBe(4);
|
||||
results[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
results[1].StatusCode.ShouldBe(AbCipStatusMapper.BadNotWritable);
|
||||
results[2].StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
|
||||
results[3].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cancellation_propagates_from_write()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("Slow", "ab://10.0.0.5/1,0", "Slow", AbCipDataType.DInt));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new CancelOnWriteFake(p);
|
||||
|
||||
await Should.ThrowAsync<OperationCanceledException>(
|
||||
() => drv.WriteAsync([new WriteRequest("Slow", 1)], CancellationToken.None));
|
||||
}
|
||||
|
||||
// ---- test-fake variants that exercise the real type / error handling ----
|
||||
|
||||
private sealed class RealConvertFake(AbCipTagCreateParams p) : FakeAbCipTag(p)
|
||||
{
|
||||
public override void EncodeValue(AbCipDataType type, int? bitIndex, object? value)
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case AbCipDataType.Int: _ = Convert.ToInt16(value); break;
|
||||
case AbCipDataType.DInt: _ = Convert.ToInt32(value); break;
|
||||
default: _ = Convert.ToInt32(value); break;
|
||||
}
|
||||
Value = value;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ThrowingBoolBitFake(AbCipTagCreateParams p) : FakeAbCipTag(p)
|
||||
{
|
||||
public override void EncodeValue(AbCipDataType type, int? bitIndex, object? value)
|
||||
{
|
||||
if (type == AbCipDataType.Bool && bitIndex is not null)
|
||||
throw new NotSupportedException("bit-in-DINT deferred");
|
||||
Value = value;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ThrowOnWriteFake(AbCipTagCreateParams p) : FakeAbCipTag(p)
|
||||
{
|
||||
public override Task WriteAsync(CancellationToken ct) =>
|
||||
Task.FromException(new InvalidOperationException("wire dropped"));
|
||||
}
|
||||
|
||||
private sealed class CancelOnWriteFake(AbCipTagCreateParams p) : FakeAbCipTag(p)
|
||||
{
|
||||
public override Task WriteAsync(CancellationToken ct) =>
|
||||
Task.FromException(new OperationCanceledException());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipFetchUdtShapeTests
|
||||
{
|
||||
private sealed class FakeTemplateReader : IAbCipTemplateReader
|
||||
{
|
||||
public byte[] Response { get; set; } = [];
|
||||
public int ReadCount { get; private set; }
|
||||
public bool Disposed { get; private set; }
|
||||
public uint LastTemplateId { get; private set; }
|
||||
|
||||
public Task<byte[]> ReadAsync(AbCipTagCreateParams deviceParams, uint templateInstanceId, CancellationToken ct)
|
||||
{
|
||||
ReadCount++;
|
||||
LastTemplateId = templateInstanceId;
|
||||
return Task.FromResult(Response);
|
||||
}
|
||||
|
||||
public void Dispose() => Disposed = true;
|
||||
}
|
||||
|
||||
private sealed class FakeTemplateReaderFactory : IAbCipTemplateReaderFactory
|
||||
{
|
||||
public List<IAbCipTemplateReader> Readers { get; } = new();
|
||||
public Func<IAbCipTemplateReader>? Customise { get; set; }
|
||||
|
||||
public IAbCipTemplateReader Create()
|
||||
{
|
||||
var r = Customise?.Invoke() ?? new FakeTemplateReader();
|
||||
Readers.Add(r);
|
||||
return r;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] BuildSimpleTemplate(string name, uint instanceSize, params (string n, ushort info, ushort arr, uint off)[] members)
|
||||
{
|
||||
var headerSize = 12;
|
||||
var blockSize = 8;
|
||||
var strings = new MemoryStream();
|
||||
void Add(string s) { var b = Encoding.ASCII.GetBytes(s + ";\0"); strings.Write(b, 0, b.Length); }
|
||||
Add(name);
|
||||
foreach (var m in members) Add(m.n);
|
||||
var stringsArr = strings.ToArray();
|
||||
|
||||
var buf = new byte[headerSize + blockSize * members.Length + stringsArr.Length];
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(0), (ushort)members.Length);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(2), 0x1234);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(4), instanceSize);
|
||||
for (var i = 0; i < members.Length; i++)
|
||||
{
|
||||
var o = headerSize + i * blockSize;
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(o), members[i].info);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(o + 2), members[i].arr);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(o + 4), members[i].off);
|
||||
}
|
||||
Buffer.BlockCopy(stringsArr, 0, buf, headerSize + blockSize * members.Length, stringsArr.Length);
|
||||
return buf;
|
||||
}
|
||||
|
||||
private static Task<AbCipUdtShape?> InvokeFetch(AbCipDriver drv, string deviceHostAddress, uint templateId)
|
||||
{
|
||||
var mi = typeof(AbCipDriver).GetMethod("FetchUdtShapeAsync",
|
||||
BindingFlags.NonPublic | BindingFlags.Instance)!;
|
||||
return (Task<AbCipUdtShape?>)mi.Invoke(drv, [deviceHostAddress, templateId, CancellationToken.None])!;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchUdtShapeAsync_decodes_blob_and_caches_result()
|
||||
{
|
||||
var factory = new FakeTemplateReaderFactory
|
||||
{
|
||||
Customise = () => new FakeTemplateReader
|
||||
{
|
||||
Response = BuildSimpleTemplate("MotorUdt", 8,
|
||||
("Speed", 0xC4, 0, 0),
|
||||
("Enabled", 0xC1, 0, 4)),
|
||||
},
|
||||
};
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
}, "drv-1", templateReaderFactory: factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var shape = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 42);
|
||||
|
||||
shape.ShouldNotBeNull();
|
||||
shape.TypeName.ShouldBe("MotorUdt");
|
||||
shape.Members.Count.ShouldBe(2);
|
||||
|
||||
// Second fetch must hit the cache — no second reader created.
|
||||
_ = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 42);
|
||||
factory.Readers.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchUdtShapeAsync_different_templateIds_each_fetch()
|
||||
{
|
||||
var callCount = 0;
|
||||
var factory = new FakeTemplateReaderFactory
|
||||
{
|
||||
Customise = () =>
|
||||
{
|
||||
callCount++;
|
||||
var name = callCount == 1 ? "UdtA" : "UdtB";
|
||||
return new FakeTemplateReader
|
||||
{
|
||||
Response = BuildSimpleTemplate(name, 4, ("X", 0xC4, 0, 0)),
|
||||
};
|
||||
},
|
||||
};
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
}, "drv-1", templateReaderFactory: factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var a = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 1);
|
||||
var b = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 2);
|
||||
|
||||
a!.TypeName.ShouldBe("UdtA");
|
||||
b!.TypeName.ShouldBe("UdtB");
|
||||
factory.Readers.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchUdtShapeAsync_unknown_device_returns_null()
|
||||
{
|
||||
var factory = new FakeTemplateReaderFactory();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
}, "drv-1", templateReaderFactory: factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var shape = await InvokeFetch(drv, "ab://10.0.0.99/1,0", 1);
|
||||
shape.ShouldBeNull();
|
||||
factory.Readers.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchUdtShapeAsync_decode_failure_returns_null_and_does_not_cache()
|
||||
{
|
||||
var factory = new FakeTemplateReaderFactory
|
||||
{
|
||||
Customise = () => new FakeTemplateReader { Response = [0x00, 0x00] }, // too short
|
||||
};
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
}, "drv-1", templateReaderFactory: factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var shape = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 1);
|
||||
shape.ShouldBeNull();
|
||||
|
||||
// Next call retries (not cached as a failure).
|
||||
var shape2 = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 1);
|
||||
shape2.ShouldBeNull();
|
||||
factory.Readers.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchUdtShapeAsync_reader_exception_returns_null()
|
||||
{
|
||||
var factory = new FakeTemplateReaderFactory
|
||||
{
|
||||
Customise = () => new ThrowingTemplateReader(),
|
||||
};
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
}, "drv-1", templateReaderFactory: factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var shape = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 1);
|
||||
shape.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FlushOptionalCachesAsync_empties_template_cache()
|
||||
{
|
||||
var factory = new FakeTemplateReaderFactory
|
||||
{
|
||||
Customise = () => new FakeTemplateReader
|
||||
{
|
||||
Response = BuildSimpleTemplate("U", 4, ("X", 0xC4, 0, 0)),
|
||||
},
|
||||
};
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
}, "drv-1", templateReaderFactory: factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
_ = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 99);
|
||||
drv.TemplateCache.Count.ShouldBe(1);
|
||||
|
||||
await drv.FlushOptionalCachesAsync(CancellationToken.None);
|
||||
drv.TemplateCache.Count.ShouldBe(0);
|
||||
|
||||
// Next fetch hits the network again.
|
||||
_ = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 99);
|
||||
factory.Readers.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
private sealed class ThrowingTemplateReader : IAbCipTemplateReader
|
||||
{
|
||||
public Task<byte[]> ReadAsync(AbCipTagCreateParams p, uint id, CancellationToken ct) =>
|
||||
throw new InvalidOperationException("fake read failure");
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipHostAddressTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("ab://10.0.0.5/1,0", "10.0.0.5", 44818, "1,0")]
|
||||
[InlineData("ab://10.0.0.5/1,4", "10.0.0.5", 44818, "1,4")]
|
||||
[InlineData("ab://10.0.0.5/1,2,2,192.168.50.20,1,0", "10.0.0.5", 44818, "1,2,2,192.168.50.20,1,0")]
|
||||
[InlineData("ab://10.0.0.5/", "10.0.0.5", 44818, "")]
|
||||
[InlineData("ab://plc-01.factory.internal/1,0", "plc-01.factory.internal", 44818, "1,0")]
|
||||
[InlineData("ab://10.0.0.5:44818/1,0", "10.0.0.5", 44818, "1,0")]
|
||||
[InlineData("ab://10.0.0.5:2222/1,0", "10.0.0.5", 2222, "1,0")]
|
||||
[InlineData("AB://10.0.0.5/1,0", "10.0.0.5", 44818, "1,0")] // case-insensitive scheme
|
||||
public void TryParse_accepts_valid_forms(string input, string gateway, int port, string cipPath)
|
||||
{
|
||||
var parsed = AbCipHostAddress.TryParse(input);
|
||||
parsed.ShouldNotBeNull();
|
||||
parsed.Gateway.ShouldBe(gateway);
|
||||
parsed.Port.ShouldBe(port);
|
||||
parsed.CipPath.ShouldBe(cipPath);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("http://10.0.0.5/1,0")] // wrong scheme
|
||||
[InlineData("ab:10.0.0.5/1,0")] // missing //
|
||||
[InlineData("ab://10.0.0.5")] // no path slash
|
||||
[InlineData("ab:///1,0")] // no gateway
|
||||
[InlineData("ab://10.0.0.5:0/1,0")] // invalid port
|
||||
[InlineData("ab://10.0.0.5:65536/1,0")] // port out of range
|
||||
[InlineData("ab://10.0.0.5:abc/1,0")] // non-numeric port
|
||||
public void TryParse_rejects_invalid_forms(string? input)
|
||||
{
|
||||
AbCipHostAddress.TryParse(input).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("10.0.0.5", 44818, "1,0", "ab://10.0.0.5/1,0")]
|
||||
[InlineData("10.0.0.5", 2222, "1,0", "ab://10.0.0.5:2222/1,0")]
|
||||
[InlineData("10.0.0.5", 44818, "", "ab://10.0.0.5/")]
|
||||
public void ToString_canonicalises(string gateway, int port, string path, string expected)
|
||||
{
|
||||
var addr = new AbCipHostAddress(gateway, port, path);
|
||||
addr.ToString().ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoundTrip_is_stable()
|
||||
{
|
||||
const string input = "ab://plc-01:44818/1,2,2,10.0.0.10,1,0";
|
||||
var parsed = AbCipHostAddress.TryParse(input)!;
|
||||
// Default port is stripped in canonical form; explicit 44818 → becomes default form.
|
||||
parsed.ToString().ShouldBe("ab://plc-01/1,2,2,10.0.0.10,1,0");
|
||||
|
||||
var parsedAgain = AbCipHostAddress.TryParse(parsed.ToString())!;
|
||||
parsedAgain.ShouldBe(parsed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipHostProbeTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetHostStatuses_returns_one_entry_per_device()
|
||||
{
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices =
|
||||
[
|
||||
new AbCipDeviceOptions("ab://10.0.0.5/1,0"),
|
||||
new AbCipDeviceOptions("ab://10.0.0.6/1,0"),
|
||||
],
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var statuses = drv.GetHostStatuses();
|
||||
statuses.Count.ShouldBe(2);
|
||||
statuses.Select(s => s.HostName).ShouldBe(["ab://10.0.0.5/1,0", "ab://10.0.0.6/1,0"], ignoreOrder: true);
|
||||
statuses.ShouldAllBe(s => s.State == HostState.Unknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_with_successful_read_transitions_to_Running()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory { Customise = p => new FakeAbCipTag(p) { Status = 0 } };
|
||||
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Probe = new AbCipProbeOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Interval = TimeSpan.FromMilliseconds(100),
|
||||
Timeout = TimeSpan.FromMilliseconds(50),
|
||||
ProbeTagPath = "@raw_cpu_type",
|
||||
},
|
||||
}, "drv-1", factory);
|
||||
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await WaitForAsync(() => transitions.Any(t => t.NewState == HostState.Running), TimeSpan.FromSeconds(2));
|
||||
|
||||
transitions.Select(t => t.NewState).ShouldContain(HostState.Running);
|
||||
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Running);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_with_read_failure_transitions_to_Stopped()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory
|
||||
{
|
||||
Customise = p => new FakeAbCipTag(p) { ThrowOnRead = true },
|
||||
};
|
||||
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Probe = new AbCipProbeOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Interval = TimeSpan.FromMilliseconds(100),
|
||||
Timeout = TimeSpan.FromMilliseconds(50),
|
||||
ProbeTagPath = "@raw_cpu_type",
|
||||
},
|
||||
}, "drv-1", factory);
|
||||
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await WaitForAsync(() => transitions.Any(t => t.NewState == HostState.Stopped), TimeSpan.FromSeconds(2));
|
||||
|
||||
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Stopped);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_disabled_when_Enabled_is_false()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Probe = new AbCipProbeOptions { Enabled = false, ProbeTagPath = "@raw_cpu_type" },
|
||||
}, "drv-1", factory);
|
||||
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await Task.Delay(300);
|
||||
|
||||
transitions.ShouldBeEmpty();
|
||||
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Unknown);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_skipped_when_ProbeTagPath_is_null()
|
||||
{
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Probe = new AbCipProbeOptions { Enabled = true, ProbeTagPath = null },
|
||||
}, "drv-1");
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await Task.Delay(200);
|
||||
|
||||
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Unknown);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_loops_across_multiple_devices_independently()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory
|
||||
{
|
||||
// Device A returns ok, Device B throws on read.
|
||||
Customise = p => p.Gateway == "10.0.0.5"
|
||||
? new FakeAbCipTag(p)
|
||||
: new FakeAbCipTag(p) { ThrowOnRead = true },
|
||||
};
|
||||
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices =
|
||||
[
|
||||
new AbCipDeviceOptions("ab://10.0.0.5/1,0"),
|
||||
new AbCipDeviceOptions("ab://10.0.0.6/1,0"),
|
||||
],
|
||||
Probe = new AbCipProbeOptions
|
||||
{
|
||||
Enabled = true, Interval = TimeSpan.FromMilliseconds(100),
|
||||
Timeout = TimeSpan.FromMilliseconds(50), ProbeTagPath = "@raw_cpu_type",
|
||||
},
|
||||
}, "drv-1", factory);
|
||||
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await WaitForAsync(() => transitions.Count >= 2, TimeSpan.FromSeconds(3));
|
||||
|
||||
transitions.ShouldContain(t => t.HostName == "ab://10.0.0.5/1,0" && t.NewState == HostState.Running);
|
||||
transitions.ShouldContain(t => t.HostName == "ab://10.0.0.6/1,0" && t.NewState == HostState.Stopped);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
// ---- IPerCallHostResolver ----
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveHost_returns_declared_device_for_known_tag()
|
||||
{
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices =
|
||||
[
|
||||
new AbCipDeviceOptions("ab://10.0.0.5/1,0"),
|
||||
new AbCipDeviceOptions("ab://10.0.0.6/1,0"),
|
||||
],
|
||||
Tags =
|
||||
[
|
||||
new AbCipTagDefinition("A", "ab://10.0.0.5/1,0", "A", AbCipDataType.DInt),
|
||||
new AbCipTagDefinition("B", "ab://10.0.0.6/1,0", "B", AbCipDataType.DInt),
|
||||
],
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.ResolveHost("A").ShouldBe("ab://10.0.0.5/1,0");
|
||||
drv.ResolveHost("B").ShouldBe("ab://10.0.0.6/1,0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveHost_falls_back_to_first_device_for_unknown_reference()
|
||||
{
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.ResolveHost("does-not-exist").ShouldBe("ab://10.0.0.5/1,0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveHost_falls_back_to_DriverInstanceId_when_no_devices()
|
||||
{
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions(), "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.ResolveHost("anything").ShouldBe("drv-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveHost_for_UDT_member_walks_to_synthesised_definition()
|
||||
{
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.7/1,0")],
|
||||
Tags =
|
||||
[
|
||||
new AbCipTagDefinition("Motor1", "ab://10.0.0.7/1,0", "Motor1", AbCipDataType.Structure,
|
||||
Members: [new AbCipStructureMember("Speed", AbCipDataType.DInt)]),
|
||||
],
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.ResolveHost("Motor1.Speed").ShouldBe("ab://10.0.0.7/1,0");
|
||||
}
|
||||
|
||||
private static async Task WaitForAsync(Func<bool> condition, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (!condition() && DateTime.UtcNow < deadline)
|
||||
await Task.Delay(20);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.PlcFamilies;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipPlcFamilyTests
|
||||
{
|
||||
// ---- ControlLogix ----
|
||||
|
||||
[Fact]
|
||||
public void ControlLogix_profile_defaults_match_large_forward_open_baseline()
|
||||
{
|
||||
var p = AbCipPlcFamilyProfile.ControlLogix;
|
||||
p.LibplctagPlcAttribute.ShouldBe("controllogix");
|
||||
p.DefaultConnectionSize.ShouldBe(4002); // LFO — FW20+
|
||||
p.DefaultCipPath.ShouldBe("1,0");
|
||||
p.SupportsRequestPacking.ShouldBeTrue();
|
||||
p.SupportsConnectedMessaging.ShouldBeTrue();
|
||||
p.MaxFragmentBytes.ShouldBe(4000);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ControlLogix_device_initialises_with_correct_profile()
|
||||
{
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0", AbCipPlcFamily.ControlLogix)],
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.GetDeviceState("ab://10.0.0.5/1,0")!.Profile.LibplctagPlcAttribute.ShouldBe("controllogix");
|
||||
}
|
||||
|
||||
// ---- CompactLogix ----
|
||||
|
||||
[Fact]
|
||||
public void CompactLogix_profile_uses_narrower_connection_size()
|
||||
{
|
||||
var p = AbCipPlcFamilyProfile.CompactLogix;
|
||||
p.LibplctagPlcAttribute.ShouldBe("compactlogix");
|
||||
p.DefaultConnectionSize.ShouldBe(504); // 5069-L3x narrow-window safety
|
||||
p.DefaultCipPath.ShouldBe("1,0");
|
||||
p.SupportsRequestPacking.ShouldBeTrue();
|
||||
p.SupportsConnectedMessaging.ShouldBeTrue();
|
||||
p.MaxFragmentBytes.ShouldBe(500);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactLogix_device_initialises_with_narrow_ConnectionSize()
|
||||
{
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://192.168.1.10/1,0", AbCipPlcFamily.CompactLogix)],
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var profile = drv.GetDeviceState("ab://192.168.1.10/1,0")!.Profile;
|
||||
profile.DefaultConnectionSize.ShouldBeLessThan(AbCipPlcFamilyProfile.ControlLogix.DefaultConnectionSize);
|
||||
profile.MaxFragmentBytes.ShouldBeLessThan(AbCipPlcFamilyProfile.ControlLogix.MaxFragmentBytes);
|
||||
}
|
||||
|
||||
// ---- Micro800 ----
|
||||
|
||||
[Fact]
|
||||
public void Micro800_profile_is_unconnected_only_with_empty_path()
|
||||
{
|
||||
var p = AbCipPlcFamilyProfile.Micro800;
|
||||
p.LibplctagPlcAttribute.ShouldBe("micro800");
|
||||
p.DefaultConnectionSize.ShouldBe(488);
|
||||
p.DefaultCipPath.ShouldBe(""); // no backplane routing
|
||||
p.SupportsRequestPacking.ShouldBeFalse();
|
||||
p.SupportsConnectedMessaging.ShouldBeFalse();
|
||||
p.MaxFragmentBytes.ShouldBe(484);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Micro800_device_with_empty_cip_path_parses_correctly()
|
||||
{
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://192.168.1.20/", AbCipPlcFamily.Micro800)],
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var state = drv.GetDeviceState("ab://192.168.1.20/")!;
|
||||
state.ParsedAddress.CipPath.ShouldBe("");
|
||||
state.Profile.SupportsRequestPacking.ShouldBeFalse();
|
||||
state.Profile.SupportsConnectedMessaging.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Micro800_read_forwards_empty_path_to_tag_create_params()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory { Customise = p => new FakeAbCipTag(p) { Value = 123 } };
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://192.168.1.20/", AbCipPlcFamily.Micro800)],
|
||||
Tags = [new AbCipTagDefinition("X", "ab://192.168.1.20/", "X", AbCipDataType.DInt)],
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
factory.Tags["X"].CreationParams.CipPath.ShouldBe("");
|
||||
factory.Tags["X"].CreationParams.LibplctagPlcAttribute.ShouldBe("micro800");
|
||||
}
|
||||
|
||||
// ---- GuardLogix ----
|
||||
|
||||
[Fact]
|
||||
public void GuardLogix_profile_wire_protocol_mirrors_ControlLogix()
|
||||
{
|
||||
var p = AbCipPlcFamilyProfile.GuardLogix;
|
||||
// Wire protocol is identical to ControlLogix — only the safety-partition semantics differ,
|
||||
// which is a per-tag concern surfaced via AbCipTagDefinition.SafetyTag.
|
||||
p.LibplctagPlcAttribute.ShouldBe("controllogix");
|
||||
p.DefaultConnectionSize.ShouldBe(AbCipPlcFamilyProfile.ControlLogix.DefaultConnectionSize);
|
||||
p.DefaultCipPath.ShouldBe(AbCipPlcFamilyProfile.ControlLogix.DefaultCipPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GuardLogix_safety_tag_surfaces_as_ViewOnly_in_discovery()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0", AbCipPlcFamily.GuardLogix)],
|
||||
Tags =
|
||||
[
|
||||
new AbCipTagDefinition("NormalTag", "ab://10.0.0.5/1,0", "N", AbCipDataType.DInt),
|
||||
new AbCipTagDefinition("SafetyTag", "ab://10.0.0.5/1,0", "S", AbCipDataType.DInt,
|
||||
Writable: true, SafetyTag: true),
|
||||
],
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Variables.Single(v => v.BrowseName == "NormalTag").Info.SecurityClass
|
||||
.ShouldBe(SecurityClassification.Operate);
|
||||
builder.Variables.Single(v => v.BrowseName == "SafetyTag").Info.SecurityClass
|
||||
.ShouldBe(SecurityClassification.ViewOnly);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GuardLogix_safety_tag_writes_rejected_even_when_Writable_is_true()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0", AbCipPlcFamily.GuardLogix)],
|
||||
Tags =
|
||||
[
|
||||
new AbCipTagDefinition("SafetySet", "ab://10.0.0.5/1,0", "S", AbCipDataType.DInt,
|
||||
Writable: true, SafetyTag: true),
|
||||
],
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("SafetySet", 42)], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNotWritable);
|
||||
}
|
||||
|
||||
// ---- ForFamily dispatch ----
|
||||
|
||||
[Theory]
|
||||
[InlineData(AbCipPlcFamily.ControlLogix, "controllogix")]
|
||||
[InlineData(AbCipPlcFamily.CompactLogix, "compactlogix")]
|
||||
[InlineData(AbCipPlcFamily.Micro800, "micro800")]
|
||||
[InlineData(AbCipPlcFamily.GuardLogix, "controllogix")]
|
||||
public void ForFamily_dispatches_to_correct_profile(AbCipPlcFamily family, string expectedAttribute)
|
||||
{
|
||||
AbCipPlcFamilyProfile.ForFamily(family).LibplctagPlcAttribute.ShouldBe(expectedAttribute);
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private sealed class RecordingBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
|
||||
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
|
||||
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||
{ Folders.Add((browseName, displayName)); return this; }
|
||||
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
|
||||
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
|
||||
|
||||
public void AddProperty(string _, DriverDataType __, object? ___) { }
|
||||
|
||||
private sealed class Handle(string fullRef) : IVariableHandle
|
||||
{
|
||||
public string FullReference => fullRef;
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
|
||||
}
|
||||
private sealed class NullSink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipStatusMapperTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData((byte)0x00, AbCipStatusMapper.Good)]
|
||||
[InlineData((byte)0x04, AbCipStatusMapper.BadNodeIdUnknown)]
|
||||
[InlineData((byte)0x05, AbCipStatusMapper.BadNodeIdUnknown)]
|
||||
[InlineData((byte)0x06, AbCipStatusMapper.GoodMoreData)]
|
||||
[InlineData((byte)0x08, AbCipStatusMapper.BadNotSupported)]
|
||||
[InlineData((byte)0x0A, AbCipStatusMapper.BadOutOfRange)]
|
||||
[InlineData((byte)0x13, AbCipStatusMapper.BadOutOfRange)]
|
||||
[InlineData((byte)0x0B, AbCipStatusMapper.Good)]
|
||||
[InlineData((byte)0x0E, AbCipStatusMapper.BadNotWritable)]
|
||||
[InlineData((byte)0x10, AbCipStatusMapper.BadDeviceFailure)]
|
||||
[InlineData((byte)0x16, AbCipStatusMapper.BadNodeIdUnknown)]
|
||||
[InlineData((byte)0xFF, AbCipStatusMapper.BadInternalError)]
|
||||
public void MapCipGeneralStatus_maps_known_codes(byte status, uint expected)
|
||||
{
|
||||
AbCipStatusMapper.MapCipGeneralStatus(status).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, AbCipStatusMapper.Good)]
|
||||
[InlineData(1, AbCipStatusMapper.GoodMoreData)] // PLCTAG_STATUS_PENDING
|
||||
[InlineData(-5, AbCipStatusMapper.BadTimeout)]
|
||||
[InlineData(-7, AbCipStatusMapper.BadCommunicationError)]
|
||||
[InlineData(-14, AbCipStatusMapper.BadNodeIdUnknown)]
|
||||
[InlineData(-16, AbCipStatusMapper.BadNotWritable)]
|
||||
[InlineData(-17, AbCipStatusMapper.BadOutOfRange)]
|
||||
[InlineData(-99, AbCipStatusMapper.BadCommunicationError)] // unknown negative → generic comms failure
|
||||
public void MapLibplctagStatus_maps_known_codes(int status, uint expected)
|
||||
{
|
||||
AbCipStatusMapper.MapLibplctagStatus(status).ShouldBe(expected);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipSubscriptionTests
|
||||
{
|
||||
private static (AbCipDriver drv, FakeAbCipTagFactory factory) NewDriver(params AbCipTagDefinition[] tags)
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags = tags,
|
||||
}, "drv-1", factory);
|
||||
return (drv, factory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Initial_poll_raises_OnDataChange_for_every_tag()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt),
|
||||
new AbCipTagDefinition("Temp", "ab://10.0.0.5/1,0", "Temp", AbCipDataType.Real));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => p.TagName switch
|
||||
{
|
||||
"Speed" => new FakeAbCipTag(p) { Value = 1800 },
|
||||
"Temp" => new FakeAbCipTag(p) { Value = 72.5f },
|
||||
_ => new FakeAbCipTag(p),
|
||||
};
|
||||
|
||||
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
||||
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
||||
|
||||
var handle = await drv.SubscribeAsync(["Speed", "Temp"], TimeSpan.FromMilliseconds(200), CancellationToken.None);
|
||||
await WaitForAsync(() => events.Count >= 2, TimeSpan.FromSeconds(2));
|
||||
|
||||
events.Select(e => e.FullReference).ShouldContain("Speed");
|
||||
events.Select(e => e.FullReference).ShouldContain("Temp");
|
||||
await drv.UnsubscribeAsync(handle, CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unchanged_value_raises_only_once()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbCipTag(p) { Value = 1800 };
|
||||
|
||||
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
||||
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
||||
|
||||
var handle = await drv.SubscribeAsync(["Speed"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
|
||||
await Task.Delay(500);
|
||||
await drv.UnsubscribeAsync(handle, CancellationToken.None);
|
||||
|
||||
events.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Value_change_between_polls_raises_OnDataChange()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
var tagRef = new FakeAbCipTag(new AbCipTagCreateParams("10.0.0.5", 44818, "1,0", "controllogix", "Speed", TimeSpan.FromSeconds(2))) { Value = 100 };
|
||||
factory.Customise = _ => tagRef;
|
||||
|
||||
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
||||
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
||||
|
||||
var handle = await drv.SubscribeAsync(["Speed"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
|
||||
await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(1));
|
||||
tagRef.Value = 200; // simulate PLC change
|
||||
await WaitForAsync(() => events.Count >= 2, TimeSpan.FromSeconds(2));
|
||||
|
||||
await drv.UnsubscribeAsync(handle, CancellationToken.None);
|
||||
events.Count.ShouldBeGreaterThanOrEqualTo(2);
|
||||
events.Last().Snapshot.Value.ShouldBe(200);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unsubscribe_halts_polling()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
var tagRef = new FakeAbCipTag(new AbCipTagCreateParams("10.0.0.5", 44818, "1,0", "controllogix", "Speed", TimeSpan.FromSeconds(2))) { Value = 1 };
|
||||
factory.Customise = _ => tagRef;
|
||||
|
||||
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
||||
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
||||
|
||||
var handle = await drv.SubscribeAsync(["Speed"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
|
||||
await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(1));
|
||||
await drv.UnsubscribeAsync(handle, CancellationToken.None);
|
||||
|
||||
var afterUnsub = events.Count;
|
||||
tagRef.Value = 999;
|
||||
await Task.Delay(400);
|
||||
events.Count.ShouldBe(afterUnsub);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Interval_below_100ms_is_floored()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbCipTag(p) { Value = 1 };
|
||||
|
||||
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
||||
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
||||
|
||||
var handle = await drv.SubscribeAsync(["Speed"], TimeSpan.FromMilliseconds(5), CancellationToken.None);
|
||||
await Task.Delay(300);
|
||||
await drv.UnsubscribeAsync(handle, CancellationToken.None);
|
||||
|
||||
// Value is stable → only the initial-data push fires; the 100 ms floor keeps polls sparse enough
|
||||
// that no extra event is produced against a stable value.
|
||||
events.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShutdownAsync_cancels_active_subscriptions()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbCipTag(p) { Value = 1 };
|
||||
|
||||
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
||||
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
||||
|
||||
_ = await drv.SubscribeAsync(["Speed"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
|
||||
await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(1));
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
|
||||
var afterShutdown = events.Count;
|
||||
await Task.Delay(300);
|
||||
events.Count.ShouldBe(afterShutdown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Subscription_on_UDT_member_uses_synthesised_full_reference()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags =
|
||||
[
|
||||
new AbCipTagDefinition("Motor1", "ab://10.0.0.5/1,0", "Motor1", AbCipDataType.Structure,
|
||||
Members: [new AbCipStructureMember("Speed", AbCipDataType.DInt)]),
|
||||
],
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => p.TagName == "Motor1.Speed"
|
||||
? new FakeAbCipTag(p) { Value = 77 }
|
||||
: new FakeAbCipTag(p);
|
||||
|
||||
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
||||
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
||||
|
||||
var handle = await drv.SubscribeAsync(["Motor1.Speed"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
|
||||
await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(2));
|
||||
|
||||
events.First().Snapshot.Value.ShouldBe(77);
|
||||
await drv.UnsubscribeAsync(handle, CancellationToken.None);
|
||||
}
|
||||
|
||||
private static async Task WaitForAsync(Func<bool> condition, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (!condition() && DateTime.UtcNow < deadline)
|
||||
await Task.Delay(20);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipTagPathTests
|
||||
{
|
||||
[Fact]
|
||||
public void Controller_scope_single_segment()
|
||||
{
|
||||
var p = AbCipTagPath.TryParse("Motor1_Speed");
|
||||
p.ShouldNotBeNull();
|
||||
p.ProgramScope.ShouldBeNull();
|
||||
p.Segments.Count.ShouldBe(1);
|
||||
p.Segments[0].Name.ShouldBe("Motor1_Speed");
|
||||
p.Segments[0].Subscripts.ShouldBeEmpty();
|
||||
p.BitIndex.ShouldBeNull();
|
||||
p.ToLibplctagName().ShouldBe("Motor1_Speed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Program_scope_parses()
|
||||
{
|
||||
var p = AbCipTagPath.TryParse("Program:MainProgram.StepIndex");
|
||||
p.ShouldNotBeNull();
|
||||
p.ProgramScope.ShouldBe("MainProgram");
|
||||
p.Segments.Single().Name.ShouldBe("StepIndex");
|
||||
p.ToLibplctagName().ShouldBe("Program:MainProgram.StepIndex");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Structured_member_access_splits_segments()
|
||||
{
|
||||
var p = AbCipTagPath.TryParse("Motor1.Speed.Setpoint");
|
||||
p.ShouldNotBeNull();
|
||||
p.Segments.Select(s => s.Name).ShouldBe(["Motor1", "Speed", "Setpoint"]);
|
||||
p.ToLibplctagName().ShouldBe("Motor1.Speed.Setpoint");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Single_dim_array_subscript()
|
||||
{
|
||||
var p = AbCipTagPath.TryParse("Data[7]");
|
||||
p.ShouldNotBeNull();
|
||||
p.Segments.Single().Name.ShouldBe("Data");
|
||||
p.Segments.Single().Subscripts.ShouldBe([7]);
|
||||
p.ToLibplctagName().ShouldBe("Data[7]");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Multi_dim_array_subscript()
|
||||
{
|
||||
var p = AbCipTagPath.TryParse("Matrix[1,2,3]");
|
||||
p.ShouldNotBeNull();
|
||||
p.Segments.Single().Subscripts.ShouldBe([1, 2, 3]);
|
||||
p.ToLibplctagName().ShouldBe("Matrix[1,2,3]");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bit_in_dint_captured_as_bit_index()
|
||||
{
|
||||
var p = AbCipTagPath.TryParse("Flags.3");
|
||||
p.ShouldNotBeNull();
|
||||
p.Segments.Single().Name.ShouldBe("Flags");
|
||||
p.BitIndex.ShouldBe(3);
|
||||
p.ToLibplctagName().ShouldBe("Flags.3");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bit_in_dint_after_member()
|
||||
{
|
||||
var p = AbCipTagPath.TryParse("Motor.Status.12");
|
||||
p.ShouldNotBeNull();
|
||||
p.Segments.Select(s => s.Name).ShouldBe(["Motor", "Status"]);
|
||||
p.BitIndex.ShouldBe(12);
|
||||
p.ToLibplctagName().ShouldBe("Motor.Status.12");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bit_index_32_rejected_out_of_range()
|
||||
{
|
||||
// 32 exceeds the DINT bit width — treated as a member name rather than bit selector,
|
||||
// which fails ident validation and returns null.
|
||||
AbCipTagPath.TryParse("Flags.32").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Program_scope_with_members_and_subscript_and_bit()
|
||||
{
|
||||
var p = AbCipTagPath.TryParse("Program:MainProgram.Motors[0].Status.5");
|
||||
p.ShouldNotBeNull();
|
||||
p.ProgramScope.ShouldBe("MainProgram");
|
||||
p.Segments.Select(s => s.Name).ShouldBe(["Motors", "Status"]);
|
||||
p.Segments[0].Subscripts.ShouldBe([0]);
|
||||
p.BitIndex.ShouldBe(5);
|
||||
p.ToLibplctagName().ShouldBe("Program:MainProgram.Motors[0].Status.5");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("Program:")] // empty scope
|
||||
[InlineData("Program:MP")] // no body after scope
|
||||
[InlineData("1InvalidStart")] // ident starts with digit
|
||||
[InlineData("Bad Name")] // space in ident
|
||||
[InlineData("Motor[]")] // empty subscript
|
||||
[InlineData("Motor[-1]")] // negative subscript
|
||||
[InlineData("Motor[a]")] // non-numeric subscript
|
||||
[InlineData("Motor[")] // unbalanced bracket
|
||||
[InlineData("Motor.")] // trailing dot
|
||||
[InlineData(".Motor")] // leading dot
|
||||
public void Invalid_shapes_return_null(string? input)
|
||||
{
|
||||
AbCipTagPath.TryParse(input).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ident_with_underscore_accepted()
|
||||
{
|
||||
AbCipTagPath.TryParse("_private_tag")!.Segments.Single().Name.ShouldBe("_private_tag");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToLibplctagName_recomposes_round_trip()
|
||||
{
|
||||
var cases = new[]
|
||||
{
|
||||
"Motor1_Speed",
|
||||
"Program:Main.Counter",
|
||||
"Array[5]",
|
||||
"Matrix[1,2]",
|
||||
"Obj.Member.Sub",
|
||||
"Flags.0",
|
||||
"Program:P.Obj[2].Flags.15",
|
||||
};
|
||||
foreach (var c in cases)
|
||||
{
|
||||
var parsed = AbCipTagPath.TryParse(c);
|
||||
parsed.ShouldNotBeNull(c);
|
||||
parsed.ToLibplctagName().ShouldBe(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipUdtMemberLayoutTests
|
||||
{
|
||||
[Fact]
|
||||
public void Packed_Atomics_Get_Natural_Alignment_Offsets()
|
||||
{
|
||||
// DInt (4 align) + Real (4) + Int (2) + LInt (8 — forces 2-byte pad before it)
|
||||
var members = new[]
|
||||
{
|
||||
new AbCipStructureMember("A", AbCipDataType.DInt),
|
||||
new AbCipStructureMember("B", AbCipDataType.Real),
|
||||
new AbCipStructureMember("C", AbCipDataType.Int),
|
||||
new AbCipStructureMember("D", AbCipDataType.LInt),
|
||||
};
|
||||
|
||||
var offsets = AbCipUdtMemberLayout.TryBuild(members);
|
||||
offsets.ShouldNotBeNull();
|
||||
offsets!["A"].ShouldBe(0);
|
||||
offsets["B"].ShouldBe(4);
|
||||
offsets["C"].ShouldBe(8);
|
||||
// cursor at 10 after Int; LInt needs 8-byte alignment → pad to 16
|
||||
offsets["D"].ShouldBe(16);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SInt_Packed_Without_Padding()
|
||||
{
|
||||
var members = new[]
|
||||
{
|
||||
new AbCipStructureMember("X", AbCipDataType.SInt),
|
||||
new AbCipStructureMember("Y", AbCipDataType.SInt),
|
||||
new AbCipStructureMember("Z", AbCipDataType.SInt),
|
||||
};
|
||||
var offsets = AbCipUdtMemberLayout.TryBuild(members);
|
||||
offsets!["X"].ShouldBe(0);
|
||||
offsets["Y"].ShouldBe(1);
|
||||
offsets["Z"].ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Returns_Null_When_Member_Is_Bool()
|
||||
{
|
||||
// BOOL storage in Logix UDTs is packed into a hidden host byte; declaration-only
|
||||
// layout can't place it. Grouping opts out; per-tag read path handles the member.
|
||||
var members = new[]
|
||||
{
|
||||
new AbCipStructureMember("A", AbCipDataType.DInt),
|
||||
new AbCipStructureMember("Flag", AbCipDataType.Bool),
|
||||
};
|
||||
AbCipUdtMemberLayout.TryBuild(members).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Returns_Null_When_Member_Is_String_Or_Structure()
|
||||
{
|
||||
AbCipUdtMemberLayout.TryBuild(
|
||||
new[] { new AbCipStructureMember("Name", AbCipDataType.String) }).ShouldBeNull();
|
||||
AbCipUdtMemberLayout.TryBuild(
|
||||
new[] { new AbCipStructureMember("Nested", AbCipDataType.Structure) }).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Returns_Null_On_Empty_Members()
|
||||
{
|
||||
AbCipUdtMemberLayout.TryBuild(Array.Empty<AbCipStructureMember>()).ShouldBeNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipUdtMemberTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task UDT_with_declared_members_fans_out_to_member_variables()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags =
|
||||
[
|
||||
new AbCipTagDefinition(
|
||||
Name: "Motor1",
|
||||
DeviceHostAddress: "ab://10.0.0.5/1,0",
|
||||
TagPath: "Motor1",
|
||||
DataType: AbCipDataType.Structure,
|
||||
Members:
|
||||
[
|
||||
new AbCipStructureMember("Speed", AbCipDataType.DInt),
|
||||
new AbCipStructureMember("Running", AbCipDataType.Bool, Writable: false),
|
||||
new AbCipStructureMember("SetPoint", AbCipDataType.Real, WriteIdempotent: true),
|
||||
]),
|
||||
],
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "Motor1");
|
||||
var variables = builder.Variables.Select(v => (v.BrowseName, v.Info.FullName)).ToList();
|
||||
variables.ShouldContain(("Speed", "Motor1.Speed"));
|
||||
variables.ShouldContain(("Running", "Motor1.Running"));
|
||||
variables.ShouldContain(("SetPoint", "Motor1.SetPoint"));
|
||||
|
||||
builder.Variables.Single(v => v.BrowseName == "Running").Info.SecurityClass
|
||||
.ShouldBe(SecurityClassification.ViewOnly);
|
||||
builder.Variables.Single(v => v.BrowseName == "SetPoint").Info.WriteIdempotent
|
||||
.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UDT_members_resolvable_for_read_via_synthesised_full_reference()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory
|
||||
{
|
||||
Customise = p => p.TagName switch
|
||||
{
|
||||
"Motor1.Speed" => new FakeAbCipTag(p) { Value = 1800 },
|
||||
"Motor1.Running" => new FakeAbCipTag(p) { Value = true },
|
||||
_ => new FakeAbCipTag(p),
|
||||
},
|
||||
};
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags =
|
||||
[
|
||||
new AbCipTagDefinition("Motor1", "ab://10.0.0.5/1,0", "Motor1", AbCipDataType.Structure,
|
||||
Members:
|
||||
[
|
||||
new AbCipStructureMember("Speed", AbCipDataType.DInt),
|
||||
new AbCipStructureMember("Running", AbCipDataType.Bool),
|
||||
]),
|
||||
],
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Motor1.Speed", "Motor1.Running"], CancellationToken.None);
|
||||
|
||||
snapshots[0].Value.ShouldBe(1800);
|
||||
snapshots[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
snapshots[1].Value.ShouldBe(true);
|
||||
snapshots[1].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UDT_member_write_routes_through_synthesised_tagpath()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags =
|
||||
[
|
||||
new AbCipTagDefinition("Motor1", "ab://10.0.0.5/1,0", "Motor1", AbCipDataType.Structure,
|
||||
Members:
|
||||
[
|
||||
new AbCipStructureMember("SetPoint", AbCipDataType.Real),
|
||||
]),
|
||||
],
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("Motor1.SetPoint", 42.5f)], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
factory.Tags["Motor1.SetPoint"].Value.ShouldBe(42.5f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UDT_member_read_write_honours_member_Writable_flag()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags =
|
||||
[
|
||||
new AbCipTagDefinition("Motor1", "ab://10.0.0.5/1,0", "Motor1", AbCipDataType.Structure,
|
||||
Members:
|
||||
[
|
||||
new AbCipStructureMember("Status", AbCipDataType.DInt, Writable: false),
|
||||
]),
|
||||
],
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("Motor1.Status", 1)], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNotWritable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Structure_tag_without_members_is_emitted_as_single_variable()
|
||||
{
|
||||
// Fallback path: a Structure tag with no declared Members still appears as a Variable so
|
||||
// downstream configuration can address it manually. This matches the "black box" note in
|
||||
// AbCipTagDefinition's docstring.
|
||||
var builder = new RecordingBuilder();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags = [new AbCipTagDefinition("OpaqueUdt", "ab://10.0.0.5/1,0", "OpaqueUdt", AbCipDataType.Structure)],
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Variables.ShouldContain(v => v.BrowseName == "OpaqueUdt");
|
||||
builder.Folders.ShouldNotContain(f => f.BrowseName == "OpaqueUdt");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Empty_Members_list_is_treated_like_null()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags = [new AbCipTagDefinition("EmptyUdt", "ab://10.0.0.5/1,0", "E", AbCipDataType.Structure, Members: [])],
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Folders.ShouldNotContain(f => f.BrowseName == "EmptyUdt");
|
||||
builder.Variables.ShouldContain(v => v.BrowseName == "EmptyUdt");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UDT_members_mixed_with_flat_tags_coexist()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags =
|
||||
[
|
||||
new AbCipTagDefinition("FlatA", "ab://10.0.0.5/1,0", "A", AbCipDataType.DInt),
|
||||
new AbCipTagDefinition("Motor1", "ab://10.0.0.5/1,0", "Motor1", AbCipDataType.Structure,
|
||||
Members:
|
||||
[
|
||||
new AbCipStructureMember("Speed", AbCipDataType.DInt),
|
||||
]),
|
||||
new AbCipTagDefinition("FlatB", "ab://10.0.0.5/1,0", "B", AbCipDataType.Real),
|
||||
],
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Variables.Select(v => v.BrowseName).ShouldBe(["FlatA", "Speed", "FlatB"], ignoreOrder: true);
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private sealed class RecordingBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
|
||||
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
|
||||
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||
{ Folders.Add((browseName, displayName)); return this; }
|
||||
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
|
||||
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
|
||||
|
||||
public void AddProperty(string _, DriverDataType __, object? ___) { }
|
||||
|
||||
private sealed class Handle(string fullRef) : IVariableHandle
|
||||
{
|
||||
public string FullReference => fullRef;
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
|
||||
}
|
||||
private sealed class NullSink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipUdtReadPlannerTests
|
||||
{
|
||||
private const string Device = "ab://10.0.0.1/1,0";
|
||||
|
||||
[Fact]
|
||||
public void Groups_Two_Members_Of_The_Same_Udt_Parent()
|
||||
{
|
||||
var tags = BuildUdtTagMap(out var _);
|
||||
var plan = AbCipUdtReadPlanner.Build(new[] { "Motor.Speed", "Motor.Torque" }, tags);
|
||||
|
||||
plan.Groups.Count.ShouldBe(1);
|
||||
plan.Groups[0].ParentName.ShouldBe("Motor");
|
||||
plan.Groups[0].Members.Count.ShouldBe(2);
|
||||
plan.Fallbacks.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Single_Member_Reference_Falls_Back_To_Per_Tag_Path()
|
||||
{
|
||||
// Reading just one member of a UDT gains nothing from grouping — one whole-UDT read
|
||||
// vs one member read is equivalent cost but more client-side work. Planner demotes.
|
||||
var tags = BuildUdtTagMap(out var _);
|
||||
var plan = AbCipUdtReadPlanner.Build(new[] { "Motor.Speed" }, tags);
|
||||
|
||||
plan.Groups.ShouldBeEmpty();
|
||||
plan.Fallbacks.Count.ShouldBe(1);
|
||||
plan.Fallbacks[0].Reference.ShouldBe("Motor.Speed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_References_Fall_Back_Without_Affecting_Groups()
|
||||
{
|
||||
var tags = BuildUdtTagMap(out var _);
|
||||
var plan = AbCipUdtReadPlanner.Build(
|
||||
new[] { "Motor.Speed", "Motor.Torque", "DoesNotExist", "Motor.NonMember" }, tags);
|
||||
|
||||
plan.Groups.Count.ShouldBe(1);
|
||||
plan.Groups[0].Members.Count.ShouldBe(2);
|
||||
plan.Fallbacks.Count.ShouldBe(2);
|
||||
plan.Fallbacks.ShouldContain(f => f.Reference == "DoesNotExist");
|
||||
plan.Fallbacks.ShouldContain(f => f.Reference == "Motor.NonMember");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Atomic_Top_Level_Tag_Falls_Back_Untouched()
|
||||
{
|
||||
var tags = BuildUdtTagMap(out var _);
|
||||
tags = new Dictionary<string, AbCipTagDefinition>(tags, StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["PlainDint"] = new("PlainDint", Device, "PlainDint", AbCipDataType.DInt),
|
||||
};
|
||||
var plan = AbCipUdtReadPlanner.Build(new[] { "Motor.Speed", "Motor.Torque", "PlainDint" }, tags);
|
||||
|
||||
plan.Groups.Count.ShouldBe(1);
|
||||
plan.Fallbacks.Count.ShouldBe(1);
|
||||
plan.Fallbacks[0].Reference.ShouldBe("PlainDint");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Udt_With_Bool_Member_Does_Not_Group()
|
||||
{
|
||||
// Any BOOL in the declared members disqualifies the group — offset rules for BOOL
|
||||
// can't be determined from declaration alone (Logix packs them into a hidden host
|
||||
// byte). Fallback path reads each member individually.
|
||||
var members = new[]
|
||||
{
|
||||
new AbCipStructureMember("Run", AbCipDataType.Bool),
|
||||
new AbCipStructureMember("Speed", AbCipDataType.DInt),
|
||||
};
|
||||
var parent = new AbCipTagDefinition("Motor", Device, "Motor", AbCipDataType.Structure,
|
||||
Members: members);
|
||||
var tags = new Dictionary<string, AbCipTagDefinition>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Motor"] = parent,
|
||||
["Motor.Run"] = new("Motor.Run", Device, "Motor.Run", AbCipDataType.Bool),
|
||||
["Motor.Speed"] = new("Motor.Speed", Device, "Motor.Speed", AbCipDataType.DInt),
|
||||
};
|
||||
|
||||
var plan = AbCipUdtReadPlanner.Build(new[] { "Motor.Run", "Motor.Speed" }, tags);
|
||||
|
||||
plan.Groups.ShouldBeEmpty();
|
||||
plan.Fallbacks.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Original_Indices_Preserved_For_Out_Of_Order_Batches()
|
||||
{
|
||||
var tags = BuildUdtTagMap(out var _);
|
||||
var plan = AbCipUdtReadPlanner.Build(
|
||||
new[] { "Other", "Motor.Speed", "DoesNotExist", "Motor.Torque" }, tags);
|
||||
|
||||
// Motor.Speed was at index 1, Motor.Torque at 3 — must survive through the plan so
|
||||
// ReadAsync can write decoded values back at the right output slot.
|
||||
plan.Groups.ShouldHaveSingleItem();
|
||||
var group = plan.Groups[0];
|
||||
group.Members.ShouldContain(m => m.OriginalIndex == 1 && m.Definition.Name == "Motor.Speed");
|
||||
group.Members.ShouldContain(m => m.OriginalIndex == 3 && m.Definition.Name == "Motor.Torque");
|
||||
plan.Fallbacks.ShouldContain(f => f.OriginalIndex == 0 && f.Reference == "Other");
|
||||
plan.Fallbacks.ShouldContain(f => f.OriginalIndex == 2 && f.Reference == "DoesNotExist");
|
||||
}
|
||||
|
||||
private static Dictionary<string, AbCipTagDefinition> BuildUdtTagMap(out AbCipTagDefinition parent)
|
||||
{
|
||||
var members = new[]
|
||||
{
|
||||
new AbCipStructureMember("Speed", AbCipDataType.DInt),
|
||||
new AbCipStructureMember("Torque", AbCipDataType.Real),
|
||||
};
|
||||
parent = new AbCipTagDefinition("Motor", Device, "Motor", AbCipDataType.Structure, Members: members);
|
||||
return new Dictionary<string, AbCipTagDefinition>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Motor"] = parent,
|
||||
["Motor.Speed"] = new("Motor.Speed", Device, "Motor.Speed", AbCipDataType.DInt),
|
||||
["Motor.Torque"] = new("Motor.Torque", Device, "Motor.Torque", AbCipDataType.Real),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class CipSymbolObjectDecoderTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Build one Symbol Object entry in the byte layout
|
||||
/// <c>instance_id(u32) symbol_type(u16) element_length(u16) array_dims(u32×3) name_len(u16) name[len] pad</c>.
|
||||
/// </summary>
|
||||
private static byte[] BuildEntry(
|
||||
uint instanceId,
|
||||
ushort symbolType,
|
||||
ushort elementLength,
|
||||
(uint, uint, uint) arrayDims,
|
||||
string name)
|
||||
{
|
||||
var nameBytes = Encoding.ASCII.GetBytes(name);
|
||||
var nameLen = nameBytes.Length;
|
||||
var totalLen = 22 + nameLen;
|
||||
if ((totalLen & 1) != 0) totalLen++; // pad to even
|
||||
|
||||
var buf = new byte[totalLen];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(0), instanceId);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(4), symbolType);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(6), elementLength);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(8), arrayDims.Item1);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(12), arrayDims.Item2);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(16), arrayDims.Item3);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(20), (ushort)nameLen);
|
||||
Buffer.BlockCopy(nameBytes, 0, buf, 22, nameLen);
|
||||
return buf;
|
||||
}
|
||||
|
||||
private static byte[] Concat(params byte[][] chunks)
|
||||
{
|
||||
var total = chunks.Sum(c => c.Length);
|
||||
var result = new byte[total];
|
||||
var pos = 0;
|
||||
foreach (var c in chunks)
|
||||
{
|
||||
Buffer.BlockCopy(c, 0, result, pos, c.Length);
|
||||
pos += c.Length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Single_DInt_entry_decodes_to_scalar_DInt_tag()
|
||||
{
|
||||
var bytes = BuildEntry(
|
||||
instanceId: 42,
|
||||
symbolType: 0xC4,
|
||||
elementLength: 4,
|
||||
arrayDims: (0, 0, 0),
|
||||
name: "Counter");
|
||||
|
||||
var tags = CipSymbolObjectDecoder.Decode(bytes).ToList();
|
||||
|
||||
tags.Count.ShouldBe(1);
|
||||
tags[0].Name.ShouldBe("Counter");
|
||||
tags[0].ProgramScope.ShouldBeNull();
|
||||
tags[0].DataType.ShouldBe(AbCipDataType.DInt);
|
||||
tags[0].IsSystemTag.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData((byte)0xC1, AbCipDataType.Bool)]
|
||||
[InlineData((byte)0xC2, AbCipDataType.SInt)]
|
||||
[InlineData((byte)0xC3, AbCipDataType.Int)]
|
||||
[InlineData((byte)0xC4, AbCipDataType.DInt)]
|
||||
[InlineData((byte)0xC5, AbCipDataType.LInt)]
|
||||
[InlineData((byte)0xC6, AbCipDataType.USInt)]
|
||||
[InlineData((byte)0xC7, AbCipDataType.UInt)]
|
||||
[InlineData((byte)0xC8, AbCipDataType.UDInt)]
|
||||
[InlineData((byte)0xC9, AbCipDataType.ULInt)]
|
||||
[InlineData((byte)0xCA, AbCipDataType.Real)]
|
||||
[InlineData((byte)0xCB, AbCipDataType.LReal)]
|
||||
[InlineData((byte)0xD0, AbCipDataType.String)]
|
||||
public void Every_known_atomic_type_code_maps_to_correct_AbCipDataType(byte typeCode, AbCipDataType expected)
|
||||
{
|
||||
CipSymbolObjectDecoder.MapTypeCode(typeCode).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_type_code_returns_null_so_caller_treats_as_opaque()
|
||||
{
|
||||
CipSymbolObjectDecoder.MapTypeCode(0xFF).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Struct_flag_overrides_type_code_and_yields_Structure()
|
||||
{
|
||||
// 0x8000 (struct) + 0x1234 (template instance id in lower 12 bits; uses 0x234)
|
||||
var bytes = BuildEntry(
|
||||
instanceId: 5,
|
||||
symbolType: 0x8000 | 0x0234,
|
||||
elementLength: 16,
|
||||
arrayDims: (0, 0, 0),
|
||||
name: "Motor1");
|
||||
|
||||
var tag = CipSymbolObjectDecoder.Decode(bytes).Single();
|
||||
tag.DataType.ShouldBe(AbCipDataType.Structure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void System_flag_surfaces_as_IsSystemTag_true()
|
||||
{
|
||||
var bytes = BuildEntry(
|
||||
instanceId: 99,
|
||||
symbolType: 0x1000 | 0xC4, // system flag + DINT
|
||||
elementLength: 4,
|
||||
arrayDims: (0, 0, 0),
|
||||
name: "__Reserved_1");
|
||||
|
||||
var tag = CipSymbolObjectDecoder.Decode(bytes).Single();
|
||||
tag.IsSystemTag.ShouldBeTrue();
|
||||
tag.DataType.ShouldBe(AbCipDataType.DInt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Program_scope_name_splits_prefix_into_ProgramScope()
|
||||
{
|
||||
var bytes = BuildEntry(
|
||||
instanceId: 1,
|
||||
symbolType: 0xC4,
|
||||
elementLength: 4,
|
||||
arrayDims: (0, 0, 0),
|
||||
name: "Program:MainProgram.StepIndex");
|
||||
|
||||
var tag = CipSymbolObjectDecoder.Decode(bytes).Single();
|
||||
tag.ProgramScope.ShouldBe("MainProgram");
|
||||
tag.Name.ShouldBe("StepIndex");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Multiple_entries_decode_in_wire_order_with_even_padding()
|
||||
{
|
||||
// Name "Abc" is 3 bytes — triggers the even-pad branch between entries.
|
||||
var bytes = Concat(
|
||||
BuildEntry(1, 0xC4, 4, (0, 0, 0), "Abc"), // DINT named "Abc" (3-byte name, pads to 4)
|
||||
BuildEntry(2, 0xCA, 4, (0, 0, 0), "Pi")); // REAL named "Pi"
|
||||
|
||||
var tags = CipSymbolObjectDecoder.Decode(bytes).ToList();
|
||||
tags.Count.ShouldBe(2);
|
||||
tags[0].Name.ShouldBe("Abc");
|
||||
tags[0].DataType.ShouldBe(AbCipDataType.DInt);
|
||||
tags[1].Name.ShouldBe("Pi");
|
||||
tags[1].DataType.ShouldBe(AbCipDataType.Real);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Truncated_buffer_stops_decoding_gracefully()
|
||||
{
|
||||
var full = BuildEntry(7, 0xC4, 4, (0, 0, 0), "Counter");
|
||||
// Deliberately chop off the last 5 bytes — decoder should bail cleanly, not throw.
|
||||
var truncated = full.Take(full.Length - 5).ToArray();
|
||||
|
||||
CipSymbolObjectDecoder.Decode(truncated).ToList().Count.ShouldBeLessThan(1); // 0 — didn't parse the broken entry
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_buffer_yields_no_tags()
|
||||
{
|
||||
CipSymbolObjectDecoder.Decode([]).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Counter", null, "Counter")]
|
||||
[InlineData("Program:MainProgram.Step", "MainProgram", "Step")]
|
||||
[InlineData("Program:MyProg.a.b.c", "MyProg", "a.b.c")]
|
||||
[InlineData("Program:", null, "Program:")] // malformed — no dot
|
||||
[InlineData("Program:OnlyProg", null, "Program:OnlyProg")]
|
||||
[InlineData("Motor.Status.Running", null, "Motor.Status.Running")]
|
||||
public void SplitProgramScope_handles_every_shape(string input, string? expectedScope, string expectedName)
|
||||
{
|
||||
var (scope, name) = CipSymbolObjectDecoder.SplitProgramScope(input);
|
||||
scope.ShouldBe(expectedScope);
|
||||
name.ShouldBe(expectedName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class CipTemplateObjectDecoderTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Construct a Template Object blob — header + member blocks + semicolon-delimited
|
||||
/// strings (UDT name first, then member names).
|
||||
/// </summary>
|
||||
private static byte[] BuildTemplate(
|
||||
string udtName,
|
||||
uint instanceSize,
|
||||
params (string name, ushort info, ushort arraySize, uint offset)[] members)
|
||||
{
|
||||
var memberCount = (ushort)members.Length;
|
||||
var headerSize = 12;
|
||||
var memberBlockSize = 8;
|
||||
var blocksSize = memberBlockSize * members.Length;
|
||||
|
||||
var stringsBuf = new MemoryStream();
|
||||
void AppendString(string s)
|
||||
{
|
||||
var bytes = Encoding.ASCII.GetBytes(s + ";\0");
|
||||
stringsBuf.Write(bytes, 0, bytes.Length);
|
||||
}
|
||||
AppendString(udtName);
|
||||
foreach (var m in members) AppendString(m.name);
|
||||
var strings = stringsBuf.ToArray();
|
||||
|
||||
var buf = new byte[headerSize + blocksSize + strings.Length];
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(0), memberCount);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(2), 0x1234);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(4), instanceSize);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(8), 0);
|
||||
|
||||
for (var i = 0; i < members.Length; i++)
|
||||
{
|
||||
var o = headerSize + (i * memberBlockSize);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(o), members[i].info);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(o + 2), members[i].arraySize);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(o + 4), members[i].offset);
|
||||
}
|
||||
Buffer.BlockCopy(strings, 0, buf, headerSize + blocksSize, strings.Length);
|
||||
return buf;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Simple_two_member_UDT_decodes_correctly()
|
||||
{
|
||||
var bytes = BuildTemplate("MotorUdt", instanceSize: 8,
|
||||
("Speed", info: 0xC4, arraySize: 0, offset: 0), // DINT at offset 0
|
||||
("Enabled", info: 0xC1, arraySize: 0, offset: 4)); // BOOL at offset 4
|
||||
|
||||
var shape = CipTemplateObjectDecoder.Decode(bytes);
|
||||
|
||||
shape.ShouldNotBeNull();
|
||||
shape.TypeName.ShouldBe("MotorUdt");
|
||||
shape.TotalSize.ShouldBe(8);
|
||||
shape.Members.Count.ShouldBe(2);
|
||||
shape.Members[0].Name.ShouldBe("Speed");
|
||||
shape.Members[0].DataType.ShouldBe(AbCipDataType.DInt);
|
||||
shape.Members[0].Offset.ShouldBe(0);
|
||||
shape.Members[0].ArrayLength.ShouldBe(1);
|
||||
shape.Members[1].Name.ShouldBe("Enabled");
|
||||
shape.Members[1].DataType.ShouldBe(AbCipDataType.Bool);
|
||||
shape.Members[1].Offset.ShouldBe(4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Struct_member_flag_surfaces_Structure_type()
|
||||
{
|
||||
var bytes = BuildTemplate("ContainerUdt", instanceSize: 32,
|
||||
("InnerStruct", info: 0x8042, arraySize: 0, offset: 0)); // struct flag + template-id 0x42
|
||||
|
||||
var shape = CipTemplateObjectDecoder.Decode(bytes);
|
||||
|
||||
shape.ShouldNotBeNull();
|
||||
shape.Members.Single().DataType.ShouldBe(AbCipDataType.Structure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Array_member_carries_non_one_ArrayLength()
|
||||
{
|
||||
var bytes = BuildTemplate("ArrayUdt", instanceSize: 40,
|
||||
("Values", info: 0xC4, arraySize: 10, offset: 0));
|
||||
|
||||
var shape = CipTemplateObjectDecoder.Decode(bytes);
|
||||
shape.ShouldNotBeNull();
|
||||
shape.Members.Single().ArrayLength.ShouldBe(10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Multiple_atomic_types_preserve_offsets_and_types()
|
||||
{
|
||||
var bytes = BuildTemplate("MixedUdt", instanceSize: 24,
|
||||
("A", 0xC1, 0, 0), // BOOL
|
||||
("B", 0xC2, 0, 1), // SINT
|
||||
("C", 0xC3, 0, 2), // INT
|
||||
("D", 0xC4, 0, 4), // DINT
|
||||
("E", 0xCA, 0, 8), // REAL
|
||||
("F", 0xCB, 0, 16)); // LREAL
|
||||
|
||||
var shape = CipTemplateObjectDecoder.Decode(bytes);
|
||||
|
||||
shape.ShouldNotBeNull();
|
||||
shape.Members.Count.ShouldBe(6);
|
||||
shape.Members.Select(m => m.DataType).ShouldBe(
|
||||
[AbCipDataType.Bool, AbCipDataType.SInt, AbCipDataType.Int,
|
||||
AbCipDataType.DInt, AbCipDataType.Real, AbCipDataType.LReal]);
|
||||
shape.Members.Select(m => m.Offset).ShouldBe([0, 1, 2, 4, 8, 16]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_atomic_type_code_falls_back_to_Structure()
|
||||
{
|
||||
var bytes = BuildTemplate("WeirdUdt", instanceSize: 4,
|
||||
("Unknown", info: 0xFF, 0, 0));
|
||||
|
||||
var shape = CipTemplateObjectDecoder.Decode(bytes);
|
||||
shape.ShouldNotBeNull();
|
||||
shape.Members.Single().DataType.ShouldBe(AbCipDataType.Structure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Zero_member_count_returns_null()
|
||||
{
|
||||
var buf = new byte[12];
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(0), 0);
|
||||
CipTemplateObjectDecoder.Decode(buf).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Short_buffer_returns_null()
|
||||
{
|
||||
CipTemplateObjectDecoder.Decode([0x01, 0x00]).ShouldBeNull(); // only 2 bytes — less than header
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Missing_member_name_surfaces_placeholder()
|
||||
{
|
||||
// Header says 3 members but strings list has only UDT name + 2 member names.
|
||||
var memberCount = (ushort)3;
|
||||
var buf = new byte[12 + 8 * 3];
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(0), memberCount);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(4), 12);
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
var o = 12 + i * 8;
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(o), 0xC4);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(o + 4), (uint)(i * 4));
|
||||
}
|
||||
// strings: only UDT + 2 members, missing the third.
|
||||
var strings = Encoding.ASCII.GetBytes("MyUdt;\0A;\0B;\0");
|
||||
var combined = buf.Concat(strings).ToArray();
|
||||
|
||||
var shape = CipTemplateObjectDecoder.Decode(combined);
|
||||
shape.ShouldNotBeNull();
|
||||
shape.Members.Count.ShouldBe(3);
|
||||
shape.Members[2].Name.ShouldBe("<member_2>");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Foo;\0Bar;\0", new[] { "Foo", "Bar" })]
|
||||
[InlineData("Foo;Bar;", new[] { "Foo", "Bar" })] // no nulls
|
||||
[InlineData("Only;\0", new[] { "Only" })]
|
||||
[InlineData(";\0", new string[] { })] // empty
|
||||
[InlineData("", new string[] { })]
|
||||
public void ParseSemicolonTerminatedStrings_handles_shapes(string input, string[] expected)
|
||||
{
|
||||
var bytes = Encoding.ASCII.GetBytes(input);
|
||||
var result = CipTemplateObjectDecoder.ParseSemicolonTerminatedStrings(bytes);
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Test fake for <see cref="IAbCipTagRuntime"/>. Stores the mock PLC value in
|
||||
/// <see cref="Value"/> + returns it from <see cref="DecodeValue"/>. Use
|
||||
/// <see cref="Status"/> to simulate libplctag error codes,
|
||||
/// <see cref="ThrowOnInitialize"/> / <see cref="ThrowOnRead"/> to simulate exceptions.
|
||||
/// </summary>
|
||||
internal class FakeAbCipTag : IAbCipTagRuntime
|
||||
{
|
||||
public AbCipTagCreateParams CreationParams { get; }
|
||||
public object? Value { get; set; }
|
||||
public int Status { get; set; }
|
||||
public bool ThrowOnInitialize { get; set; }
|
||||
public bool ThrowOnRead { get; set; }
|
||||
public Exception? Exception { get; set; }
|
||||
public int InitializeCount { get; private set; }
|
||||
public int ReadCount { get; private set; }
|
||||
public int WriteCount { get; private set; }
|
||||
public bool Disposed { get; private set; }
|
||||
|
||||
public FakeAbCipTag(AbCipTagCreateParams createParams) => CreationParams = createParams;
|
||||
|
||||
public virtual Task InitializeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
InitializeCount++;
|
||||
if (ThrowOnInitialize) throw Exception ?? new InvalidOperationException("fake initialize failure");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public virtual Task ReadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
ReadCount++;
|
||||
if (ThrowOnRead) throw Exception ?? new InvalidOperationException("fake read failure");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public virtual Task WriteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
WriteCount++;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public virtual int GetStatus() => Status;
|
||||
|
||||
public virtual object? DecodeValue(AbCipDataType type, int? bitIndex) => Value;
|
||||
|
||||
/// <summary>
|
||||
/// Task #194 whole-UDT read support. Tests drive multi-member decoding by setting
|
||||
/// <see cref="ValuesByOffset"/> — keyed by member byte offset — before invoking
|
||||
/// <see cref="AbCipDriver.ReadAsync"/>. Falls back to <see cref="Value"/> when the
|
||||
/// offset is zero or unmapped so existing tests that never set the offset map keep
|
||||
/// working unchanged.
|
||||
/// </summary>
|
||||
public Dictionary<int, object?> ValuesByOffset { get; } = new();
|
||||
|
||||
public virtual object? DecodeValueAt(AbCipDataType type, int offset, int? bitIndex)
|
||||
{
|
||||
if (ValuesByOffset.TryGetValue(offset, out var v)) return v;
|
||||
return offset == 0 ? Value : null;
|
||||
}
|
||||
|
||||
public virtual void EncodeValue(AbCipDataType type, int? bitIndex, object? value) => Value = value;
|
||||
|
||||
public virtual void Dispose() => Disposed = true;
|
||||
}
|
||||
|
||||
/// <summary>Test factory that produces <see cref="FakeAbCipTag"/>s and indexes them for assertion.</summary>
|
||||
internal sealed class FakeAbCipTagFactory : IAbCipTagFactory
|
||||
{
|
||||
public Dictionary<string, FakeAbCipTag> Tags { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public Func<AbCipTagCreateParams, FakeAbCipTag>? Customise { get; set; }
|
||||
|
||||
public IAbCipTagRuntime Create(AbCipTagCreateParams createParams)
|
||||
{
|
||||
var fake = Customise?.Invoke(createParams) ?? new FakeAbCipTag(createParams);
|
||||
Tags[createParams.TagName] = fake;
|
||||
return fake;
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.AbCip\ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end smoke tests against the <c>ab_server</c> PCCC Docker container.
|
||||
/// Promotes the AB Legacy driver from unit-only coverage (<c>FakeAbLegacyTag</c>)
|
||||
/// to wire-level: real libplctag PCCC stack over real TCP against the ab_server
|
||||
/// simulator. Parametrised over all three families (SLC 500 / MicroLogix / PLC-5)
|
||||
/// via <c>[AbLegacyTheory]</c> + <c>[MemberData]</c>.
|
||||
/// </summary>
|
||||
[Collection(AbLegacyServerCollection.Name)]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Simulator", "ab_server-PCCC")]
|
||||
public sealed class AbLegacyReadSmokeTests(AbLegacyServerFixture sim)
|
||||
{
|
||||
// Only one ab_server container binds :44818 at a time and `--plc=SLC500` only
|
||||
// answers SLC-mode PCCC, etc. When `AB_LEGACY_COMPOSE_PROFILE` is set, the theory
|
||||
// filters to that profile alone so the suite matches the running container. Unset
|
||||
// (the default for real-hardware runs) parameterises across every family the driver
|
||||
// supports.
|
||||
public static IEnumerable<object[]> Profiles
|
||||
{
|
||||
get
|
||||
{
|
||||
var only = Environment.GetEnvironmentVariable("AB_LEGACY_COMPOSE_PROFILE");
|
||||
var profiles = KnownProfiles.All.Where(p =>
|
||||
string.IsNullOrEmpty(only) ||
|
||||
string.Equals(p.ComposeProfile, only, StringComparison.OrdinalIgnoreCase));
|
||||
return profiles.Select(p => new object[] { p });
|
||||
}
|
||||
}
|
||||
|
||||
[AbLegacyTheory]
|
||||
[MemberData(nameof(Profiles))]
|
||||
public async Task Driver_reads_seeded_N_file_from_ab_server_PCCC(AbLegacyServerProfile profile)
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
|
||||
// PCCC semantics allow an empty cip-path (real SLC/PLC-5 hardware takes nothing
|
||||
// after the `/`), but libplctag's ab_server requires a non-empty path at the
|
||||
// CIP unconnected-send layer before the PCCC dispatcher runs. Default `1,0`
|
||||
// against the Docker fixture; set AB_LEGACY_CIP_PATH= (empty) against real HW.
|
||||
var deviceUri = $"ab://{sim.Host}:{sim.Port}/{sim.CipPath}";
|
||||
await using var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions(deviceUri, profile.Family)],
|
||||
Tags = [
|
||||
new AbLegacyTagDefinition(
|
||||
Name: "IntCounter",
|
||||
DeviceHostAddress: deviceUri,
|
||||
Address: "N7:0",
|
||||
DataType: AbLegacyDataType.Int),
|
||||
],
|
||||
Timeout = TimeSpan.FromSeconds(5),
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, driverInstanceId: $"ablegacy-smoke-{profile.Family}");
|
||||
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var snapshots = await drv.ReadAsync(
|
||||
["IntCounter"], TestContext.Current.CancellationToken);
|
||||
|
||||
snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good,
|
||||
$"N7:0 read must succeed against the {profile.Family} compose profile");
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
[AbLegacyFact]
|
||||
public async Task Slc500_write_then_read_round_trip_on_N7_scratch_register()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
|
||||
// Skip when the running compose profile isn't SLC500 — ab_server's `--plc=`
|
||||
// flag selects exactly one family per process, so a write against a plc5-mode
|
||||
// container with SLC500 semantics always fails at the wire.
|
||||
var only = Environment.GetEnvironmentVariable("AB_LEGACY_COMPOSE_PROFILE");
|
||||
if (!string.IsNullOrEmpty(only) &&
|
||||
!string.Equals(only, "slc500", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Assert.Skip($"Test targets the SLC500 compose profile; AB_LEGACY_COMPOSE_PROFILE='{only}'.");
|
||||
}
|
||||
|
||||
// PCCC semantics allow an empty cip-path (real SLC/PLC-5 hardware takes nothing
|
||||
// after the `/`), but libplctag's ab_server requires a non-empty path at the
|
||||
// CIP unconnected-send layer before the PCCC dispatcher runs. Default `1,0`
|
||||
// against the Docker fixture; set AB_LEGACY_CIP_PATH= (empty) against real HW.
|
||||
var deviceUri = $"ab://{sim.Host}:{sim.Port}/{sim.CipPath}";
|
||||
await using var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions(deviceUri, AbLegacyPlcFamily.Slc500)],
|
||||
Tags = [
|
||||
new AbLegacyTagDefinition(
|
||||
Name: "Scratch",
|
||||
DeviceHostAddress: deviceUri,
|
||||
Address: "N7:5",
|
||||
DataType: AbLegacyDataType.Int,
|
||||
Writable: true),
|
||||
],
|
||||
Timeout = TimeSpan.FromSeconds(5),
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, driverInstanceId: "ablegacy-smoke-rw");
|
||||
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
const short probe = 0x1234;
|
||||
var writeResults = await drv.WriteAsync(
|
||||
[new WriteRequest("Scratch", probe)],
|
||||
TestContext.Current.CancellationToken);
|
||||
writeResults.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good,
|
||||
"PCCC N7:5 write must succeed end-to-end");
|
||||
|
||||
var readResults = await drv.ReadAsync(
|
||||
["Scratch"], TestContext.Current.CancellationToken);
|
||||
readResults.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
Convert.ToInt32(readResults.Single().Value).ShouldBe(probe);
|
||||
}
|
||||
}
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
using System.Net.Sockets;
|
||||
using Xunit;
|
||||
using Xunit.Sdk;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Reachability probe for the <c>ab_server</c> Docker container running in a PCCC
|
||||
/// plc mode (<c>SLC500</c> / <c>Micrologix</c> / <c>PLC/5</c>). Same container image
|
||||
/// the AB CIP integration suite uses — libplctag's <c>ab_server</c> supports both
|
||||
/// CIP + PCCC families from one binary. Tests skip via
|
||||
/// <see cref="AbLegacyFactAttribute"/> / <see cref="AbLegacyTheoryAttribute"/> when
|
||||
/// the port isn't live, so <c>dotnet test</c> stays green on a fresh clone without
|
||||
/// Docker running.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Env-var overrides:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>AB_LEGACY_ENDPOINT</c> — <c>host:port</c> of the PCCC-mode simulator.
|
||||
/// Defaults to <c>localhost:44818</c> (EtherNet/IP port; ab_server's PCCC
|
||||
/// emulation exposes PCCC-over-CIP on the same port as CIP itself).</item>
|
||||
/// <item><c>AB_LEGACY_CIP_PATH</c> — routing path appended to the <c>ab://host:port/</c>
|
||||
/// URI. Defaults to <c>1,0</c> (port-1/slot-0 backplane), required by ab_server
|
||||
/// which rejects unconnected Send_RR_Data with an empty path at the CIP layer
|
||||
/// before the PCCC dispatcher runs. Real SLC 5/05 / MicroLogix / PLC-5 hardware
|
||||
/// use an empty path — set <c>AB_LEGACY_CIP_PATH=</c> (empty) when pointing at
|
||||
/// real hardware.</item>
|
||||
/// </list>
|
||||
/// Distinct from <c>AB_SERVER_ENDPOINT</c> used by the AB CIP fixture so both
|
||||
/// can point at different containers simultaneously during a combined test run.
|
||||
/// </remarks>
|
||||
public sealed class AbLegacyServerFixture : IAsyncLifetime
|
||||
{
|
||||
private const string EndpointEnvVar = "AB_LEGACY_ENDPOINT";
|
||||
private const string CipPathEnvVar = "AB_LEGACY_CIP_PATH";
|
||||
|
||||
/// <summary>Standard EtherNet/IP port. PCCC-over-CIP rides on the same port as
|
||||
/// native CIP; the differentiator is the <c>--plc</c> flag ab_server was started
|
||||
/// with, not a different TCP listener.</summary>
|
||||
public const int DefaultPort = 44818;
|
||||
|
||||
/// <summary>
|
||||
/// ab_server rejects unconnected Send_RR_Data with an empty CIP routing path
|
||||
/// at the CIP layer — the PCCC dispatcher never runs. <c>1,0</c> is the generic
|
||||
/// port-1/slot-0 backplane path; any well-formed path passes the gate. Real
|
||||
/// hardware (SLC 5/05 / MicroLogix / PLC-5) uses an empty path because there's
|
||||
/// no backplane to cross, so point <c>AB_LEGACY_CIP_PATH=</c> (empty) at real
|
||||
/// hardware to exercise the authentic wire semantics.
|
||||
/// </summary>
|
||||
public const string DefaultCipPath = "1,0";
|
||||
|
||||
public string Host { get; } = "127.0.0.1";
|
||||
public int Port { get; } = DefaultPort;
|
||||
|
||||
/// <summary>CIP routing path portion of the device URI (after the <c>/</c> separator).
|
||||
/// May be empty when targeting real hardware; non-empty against ab_server.</summary>
|
||||
public string CipPath { get; } = DefaultCipPath;
|
||||
|
||||
public string? SkipReason { get; }
|
||||
|
||||
public AbLegacyServerFixture()
|
||||
{
|
||||
if (Environment.GetEnvironmentVariable(EndpointEnvVar) is { Length: > 0 } raw)
|
||||
{
|
||||
var parts = raw.Split(':', 2);
|
||||
Host = parts[0];
|
||||
if (parts.Length == 2 && int.TryParse(parts[1], out var p)) Port = p;
|
||||
}
|
||||
|
||||
// Empty override is intentional (real hardware); treat `null` as "not set, use
|
||||
// default" but preserve an explicit empty-string override.
|
||||
var cipOverride = Environment.GetEnvironmentVariable(CipPathEnvVar);
|
||||
if (cipOverride is not null) CipPath = cipOverride;
|
||||
|
||||
SkipReason = ResolveSkipReason(Host, Port);
|
||||
}
|
||||
|
||||
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
/// <summary>
|
||||
/// Used by <see cref="AbLegacyFactAttribute"/> + <see cref="AbLegacyTheoryAttribute"/>
|
||||
/// during test-class construction — gates whether the test runs at all. Duplicates the
|
||||
/// fixture logic because attribute ctors fire before the collection fixture instance
|
||||
/// exists.
|
||||
/// </summary>
|
||||
public static bool IsServerAvailable()
|
||||
{
|
||||
var (host, port) = ResolveEndpoint();
|
||||
return ResolveSkipReason(host, port) is null;
|
||||
}
|
||||
|
||||
private static string? ResolveSkipReason(string host, int port)
|
||||
{
|
||||
if (!TcpProbe(host, port))
|
||||
{
|
||||
return $"AB Legacy PCCC endpoint at {host}:{port} not reachable within 2 s. " +
|
||||
$"Start the Docker container (docker compose -f Docker/docker-compose.yml " +
|
||||
$"--profile slc500 up -d), attach real hardware, or override {EndpointEnvVar}.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static (string Host, int Port) ResolveEndpoint()
|
||||
{
|
||||
var raw = Environment.GetEnvironmentVariable(EndpointEnvVar);
|
||||
if (raw is null) return ("127.0.0.1", DefaultPort);
|
||||
var parts = raw.Split(':', 2);
|
||||
var port = parts.Length == 2 && int.TryParse(parts[1], out var p) ? p : DefaultPort;
|
||||
return (parts[0], port);
|
||||
}
|
||||
|
||||
private static bool TcpProbe(string host, int port)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = new TcpClient();
|
||||
var task = client.ConnectAsync(host, port);
|
||||
return task.Wait(TimeSpan.FromSeconds(2)) && client.Connected;
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-family marker for the PCCC-mode compose profile a given test targets. The
|
||||
/// compose file (<c>Docker/docker-compose.yml</c>) is the canonical source of truth
|
||||
/// for which <c>--plc</c> mode + tags each family seeds; this record just ties a
|
||||
/// family enum to its compose-profile name + operator-facing notes.
|
||||
/// </summary>
|
||||
public sealed record AbLegacyServerProfile(
|
||||
AbLegacyPlcFamily Family,
|
||||
string ComposeProfile,
|
||||
string Notes);
|
||||
|
||||
/// <summary>Canonical profiles covering every PCCC family the driver supports.</summary>
|
||||
public static class KnownProfiles
|
||||
{
|
||||
public static readonly AbLegacyServerProfile Slc500 = new(
|
||||
Family: AbLegacyPlcFamily.Slc500,
|
||||
ComposeProfile: "slc500",
|
||||
Notes: "SLC 500 / 5/05 family. ab_server SLC500 mode covers N/F/B/L files.");
|
||||
|
||||
public static readonly AbLegacyServerProfile MicroLogix = new(
|
||||
Family: AbLegacyPlcFamily.MicroLogix,
|
||||
ComposeProfile: "micrologix",
|
||||
Notes: "MicroLogix 1000 / 1100 / 1400. Shares N/F/B file-type coverage with SLC500; ST (ASCII strings) included.");
|
||||
|
||||
public static readonly AbLegacyServerProfile Plc5 = new(
|
||||
Family: AbLegacyPlcFamily.Plc5,
|
||||
ComposeProfile: "plc5",
|
||||
Notes: "PLC-5 family. ab_server PLC/5 mode covers N/F/B; per-family quirks on ST / timer file layouts unit-tested only.");
|
||||
|
||||
public static IReadOnlyList<AbLegacyServerProfile> All { get; } =
|
||||
[Slc500, MicroLogix, Plc5];
|
||||
|
||||
public static AbLegacyServerProfile ForFamily(AbLegacyPlcFamily family) =>
|
||||
All.FirstOrDefault(p => p.Family == family)
|
||||
?? throw new ArgumentOutOfRangeException(nameof(family), family, "No integration profile for this family.");
|
||||
}
|
||||
|
||||
[Xunit.CollectionDefinition(Name)]
|
||||
public sealed class AbLegacyServerCollection : Xunit.ICollectionFixture<AbLegacyServerFixture>
|
||||
{
|
||||
public const string Name = "AbLegacyServer";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>[Fact]</c>-equivalent that skips when the PCCC endpoint isn't reachable.
|
||||
/// See <see cref="AbLegacyServerFixture"/> for the exact skip semantics.
|
||||
/// </summary>
|
||||
public sealed class AbLegacyFactAttribute : FactAttribute
|
||||
{
|
||||
public AbLegacyFactAttribute()
|
||||
{
|
||||
if (!AbLegacyServerFixture.IsServerAvailable())
|
||||
Skip = "AB Legacy PCCC endpoint not reachable. Start the Docker fixture " +
|
||||
"(docker compose -f Docker/docker-compose.yml --profile slc500 up -d) " +
|
||||
"or point AB_LEGACY_ENDPOINT at real hardware.";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>[Theory]</c>-equivalent with the same gate as <see cref="AbLegacyFactAttribute"/>.
|
||||
/// </summary>
|
||||
public sealed class AbLegacyTheoryAttribute : TheoryAttribute
|
||||
{
|
||||
public AbLegacyTheoryAttribute()
|
||||
{
|
||||
if (!AbLegacyServerFixture.IsServerAvailable())
|
||||
Skip = "AB Legacy PCCC endpoint not reachable. Start the Docker fixture " +
|
||||
"(docker compose -f Docker/docker-compose.yml --profile slc500 up -d) " +
|
||||
"or point AB_LEGACY_ENDPOINT at real hardware.";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
# AB Legacy PCCC integration-test fixture — `ab_server` (Docker)
|
||||
|
||||
[libplctag](https://github.com/libplctag/libplctag)'s `ab_server` supports
|
||||
both CIP (ControlLogix / CompactLogix / Micro800) and PCCC (SLC 500 /
|
||||
MicroLogix / PLC-5) families from one binary. This fixture reuses the AB
|
||||
CIP Docker image (`otopcua-ab-server:libplctag-release`) with different
|
||||
`--plc` flags. No new Dockerfile needed — the compose file's `build:`
|
||||
block points at the AB CIP `Docker/` folder so `docker compose build`
|
||||
from here reuses the same multi-stage build.
|
||||
|
||||
**Docker is the only supported launch path**; a fresh clone needs Docker
|
||||
Desktop and nothing else.
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| [`docker-compose.yml`](docker-compose.yml) | Three per-family services (`slc500` / `micrologix` / `plc5`); all bind `:44818` |
|
||||
|
||||
## Run
|
||||
|
||||
From the repo root:
|
||||
|
||||
```powershell
|
||||
# SLC 500 family — widest PCCC coverage
|
||||
docker compose -f tests\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests\Docker\docker-compose.yml --profile slc500 up
|
||||
|
||||
# Per-family
|
||||
docker compose -f tests\...\Docker\docker-compose.yml --profile micrologix up
|
||||
docker compose -f tests\...\Docker\docker-compose.yml --profile plc5 up
|
||||
```
|
||||
|
||||
Detached + stop:
|
||||
|
||||
```powershell
|
||||
docker compose -f tests\...\Docker\docker-compose.yml --profile slc500 up -d
|
||||
docker compose -f tests\...\Docker\docker-compose.yml --profile slc500 down
|
||||
```
|
||||
|
||||
First run builds the `otopcua-ab-server:libplctag-release` image (~3-5
|
||||
min — clones libplctag + compiles `ab_server`). If the AB CIP fixture
|
||||
already built the image locally, docker reuses the cached layers + this
|
||||
runs in seconds. Only one family binds `:44818` at a time; to switch
|
||||
families stop the current service + start another.
|
||||
|
||||
## Endpoint
|
||||
|
||||
- Default: `localhost:44818` (EtherNet/IP standard)
|
||||
- Override with `AB_LEGACY_ENDPOINT=host:port` to point at a real SLC /
|
||||
MicroLogix / PLC-5 PLC on its native port.
|
||||
|
||||
## Env vars
|
||||
|
||||
| Var | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `AB_LEGACY_ENDPOINT` | `localhost:44818` | `host:port` of the PCCC endpoint. |
|
||||
| `AB_LEGACY_CIP_PATH` | `1,0` | CIP routing path portion of the `ab://host:port/<path>` URI. ab_server rejects empty paths at the CIP unconnected-send layer; real SLC/MicroLogix/PLC-5 hardware accepts empty (no backplane). Set to empty (`AB_LEGACY_CIP_PATH=`) when pointing at real hardware. |
|
||||
| `AB_LEGACY_COMPOSE_PROFILE` | *unset* | When set (e.g. `slc500`), the parametric theory filters to that profile. Only one compose container binds `:44818` at a time; set this to the profile currently up so the suite doesn't try to hit e.g. the Slc500 family against the PLC-5 container. Leave unset for real-hardware runs (all 3 families parameterize). |
|
||||
|
||||
## Run the integration tests
|
||||
|
||||
In a separate shell with a container up, tell the suite which profile is
|
||||
running so only the matching theory-parameterization executes:
|
||||
|
||||
```powershell
|
||||
cd C:\Users\dohertj2\Desktop\lmxopcua
|
||||
$env:AB_LEGACY_COMPOSE_PROFILE = "slc500" # or "micrologix" / "plc5"
|
||||
dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests
|
||||
```
|
||||
|
||||
Against real SLC / MicroLogix / PLC-5 hardware, set the endpoint + an
|
||||
empty cip-path + leave the profile unset so all 3 parameterizations
|
||||
run (real PLCs answer any valid family):
|
||||
|
||||
```powershell
|
||||
$env:AB_LEGACY_ENDPOINT = "10.0.1.50:44818"
|
||||
$env:AB_LEGACY_CIP_PATH = "" # empty — real hardware has no backplane
|
||||
dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests
|
||||
```
|
||||
|
||||
`AbLegacyServerFixture` TCP-probes the endpoint at collection init and
|
||||
sets a skip reason when the listener isn't reachable. Tests use
|
||||
`[AbLegacyFact]` / `[AbLegacyTheory]` which check the same gate.
|
||||
|
||||
## What each family seeds
|
||||
|
||||
PCCC tag format is `<file>[<size>]` without a type suffix — file letter
|
||||
implies type:
|
||||
|
||||
- `N` = 16-bit signed integer
|
||||
- `F` = 32-bit IEEE 754 float
|
||||
- `B` = 1-bit boolean (stored as uint16, bit-addressable via `/n`)
|
||||
- `L` = 32-bit signed integer (SLC 5/05 V15+ only)
|
||||
- `ST` = 82-byte ASCII string (MicroLogix-specific extension)
|
||||
|
||||
| Family | Seeded tags | Notes |
|
||||
|---|---|---|
|
||||
| SLC 500 | `N7[10]`, `F8[10]`, `B3[10]`, `L19[10]` | Baseline; covers the four numeric file types a typical SLC project uses |
|
||||
| MicroLogix | `B3[10]`, `N7[10]`, `L19[10]` | No `F8` — MicroLogix 1000 has no float file; use L19 when scaled integers aren't enough |
|
||||
| PLC-5 | `N7[10]`, `F8[10]`, `B3[10]` | No `L` — PLC-5 predates the L file type; DINT equivalents went in integer files |
|
||||
|
||||
## Known limitations
|
||||
|
||||
### ab_server rejects empty CIP paths
|
||||
|
||||
libplctag's `ab_server` enforces a non-empty CIP routing path at the
|
||||
unconnected-send layer before forwarding to the PCCC dispatcher; a
|
||||
client-side `ab://host:port/` with nothing after the `/` surfaces as
|
||||
`BadCommunicationError` (`0x80050000`) with no server-side log line.
|
||||
|
||||
Real SLC/PLC-5 hardware has no backplane routing, so an empty path is
|
||||
how field devices are addressed. The fixture defaults to `/1,0`
|
||||
(port-1/slot-0 — the conventional ControlLogix backplane path) which
|
||||
the ab_server accepts; operators targeting real hardware set
|
||||
`AB_LEGACY_CIP_PATH=` (empty) to exercise authentic wire semantics.
|
||||
|
||||
Previous versions of this README described PCCC as "upstream-broken" —
|
||||
the root cause turned out to be the cip-path gate above, not a gap in
|
||||
`pccc.c`. N-file (Int16), F-file (Float32), and L-file (Int32) round-
|
||||
trip cleanly across SLC500, MicroLogix, and PLC-5 modes.
|
||||
|
||||
### Bit-file writes on ab_server
|
||||
|
||||
`B3:0/5`-style bit-in-boolean writes currently surface `0x803D0000`
|
||||
against `ab_server --plc=SLC500`; bit reads work. Non-blocking for the
|
||||
smoke suite (which targets N-file Int16 + F-file float reads), but
|
||||
bit-write fidelity isn't simulator-verified — route operator-critical
|
||||
bit writes to real hardware or RSEmulate 500 until upstream resolves.
|
||||
|
||||
### Other known gaps (unchanged from ab_server)
|
||||
|
||||
- **Timer / Counter file decomposition** — PCCC T4 / C5 files contain
|
||||
three-field structs (`.ACC` / `.PRE` / `.DN`). Not in ab_server's
|
||||
scope; tests targeting `T4:0.ACC` stay unit-only.
|
||||
- **ST (ASCII string) files** — real MicroLogix ST files have a length
|
||||
field plus CRLF-sensitive semantics that don't round-trip cleanly.
|
||||
- **Indirect addressing** (`N7:[N10:5]`) — not in ab_server's scope.
|
||||
- **DF1 serial wire behaviour** — the whole ab_server path is TCP;
|
||||
DF1 radio / serial fidelity needs real hardware.
|
||||
|
||||
See [`docs/drivers/AbLegacy-Test-Fixture.md`](../../../docs/drivers/AbLegacy-Test-Fixture.md)
|
||||
for the full coverage map.
|
||||
|
||||
## References
|
||||
|
||||
- [libplctag on GitHub](https://github.com/libplctag/libplctag) — `ab_server`
|
||||
lives under `src/tools/ab_server/`
|
||||
- [`docs/drivers/AbLegacy-Test-Fixture.md`](../../../docs/drivers/AbLegacy-Test-Fixture.md)
|
||||
— coverage map + gap inventory
|
||||
- [`docs/v2/dev-environment.md`](../../../docs/v2/dev-environment.md)
|
||||
§Docker fixtures — full fixture inventory
|
||||
- [`../../ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker/`](../../ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker/)
|
||||
— the shared Dockerfile this compose file's `build:` block references
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
# AB Legacy PCCC integration-test fixture — ab_server in PCCC mode.
|
||||
#
|
||||
# Same image as the AB CIP fixture (otopcua-ab-server:libplctag-release).
|
||||
# The build context points at the AB CIP Docker folder one directory over
|
||||
# so `docker compose build` from here produces the same image if it
|
||||
# doesn't already exist; if it does, docker's cache reuses the layer.
|
||||
#
|
||||
# One service per PCCC family. All bind :44818 on the host; run one at a
|
||||
# time. PCCC tag format differs from CIP: `<file>[<size>]` without a
|
||||
# type suffix since the type is implicit in the file letter (N = INT,
|
||||
# F = REAL, B = bit-packed, L = DINT).
|
||||
#
|
||||
# Usage:
|
||||
# docker compose --profile slc500 up
|
||||
# docker compose --profile micrologix up
|
||||
# docker compose --profile plc5 up
|
||||
services:
|
||||
slc500:
|
||||
profiles: ["slc500"]
|
||||
build:
|
||||
context: ../../ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker
|
||||
dockerfile: Dockerfile
|
||||
image: otopcua-ab-server:libplctag-release
|
||||
container_name: otopcua-ab-server-slc500
|
||||
restart: "no"
|
||||
ports:
|
||||
- "44818:44818"
|
||||
command: [
|
||||
"ab_server",
|
||||
"--plc=SLC500",
|
||||
"--port=44818",
|
||||
"--tag=N7[10]",
|
||||
"--tag=F8[10]",
|
||||
"--tag=B3[10]",
|
||||
"--tag=L19[10]"
|
||||
]
|
||||
|
||||
micrologix:
|
||||
profiles: ["micrologix"]
|
||||
image: otopcua-ab-server:libplctag-release
|
||||
build:
|
||||
context: ../../ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker
|
||||
dockerfile: Dockerfile
|
||||
container_name: otopcua-ab-server-micrologix
|
||||
restart: "no"
|
||||
ports:
|
||||
- "44818:44818"
|
||||
command: [
|
||||
"ab_server",
|
||||
"--plc=Micrologix",
|
||||
"--port=44818",
|
||||
"--tag=B3[10]",
|
||||
"--tag=N7[10]",
|
||||
"--tag=L19[10]"
|
||||
]
|
||||
|
||||
plc5:
|
||||
profiles: ["plc5"]
|
||||
image: otopcua-ab-server:libplctag-release
|
||||
build:
|
||||
context: ../../ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker
|
||||
dockerfile: Dockerfile
|
||||
container_name: otopcua-ab-server-plc5
|
||||
restart: "no"
|
||||
ports:
|
||||
- "44818:44818"
|
||||
command: [
|
||||
"ab_server",
|
||||
"--plc=PLC/5",
|
||||
"--port=44818",
|
||||
"--tag=N7[10]",
|
||||
"--tag=F8[10]",
|
||||
"--tag=B3[10]"
|
||||
]
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
<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.IntegrationTests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="Docker\**\*" CopyToOutputDirectory="PreserveNewest"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,68 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbLegacyAddressTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("N7:0", "N", 7, 0, null, null)]
|
||||
[InlineData("N7:15", "N", 7, 15, null, null)]
|
||||
[InlineData("F8:5", "F", 8, 5, null, null)]
|
||||
[InlineData("B3:0/0", "B", 3, 0, 0, null)]
|
||||
[InlineData("B3:2/7", "B", 3, 2, 7, null)]
|
||||
[InlineData("ST9:0", "ST", 9, 0, null, null)]
|
||||
[InlineData("L9:3", "L", 9, 3, null, null)]
|
||||
[InlineData("I:0/0", "I", null, 0, 0, null)]
|
||||
[InlineData("O:1/2", "O", null, 1, 2, null)]
|
||||
[InlineData("S:1", "S", null, 1, null, null)]
|
||||
[InlineData("T4:0.ACC", "T", 4, 0, null, "ACC")]
|
||||
[InlineData("T4:0.PRE", "T", 4, 0, null, "PRE")]
|
||||
[InlineData("C5:2.CU", "C", 5, 2, null, "CU")]
|
||||
[InlineData("R6:0.LEN", "R", 6, 0, null, "LEN")]
|
||||
[InlineData("N7:0/3", "N", 7, 0, 3, null)]
|
||||
public void TryParse_accepts_valid_pccc_addresses(string input, string letter, int? file, int word, int? bit, string? sub)
|
||||
{
|
||||
var a = AbLegacyAddress.TryParse(input);
|
||||
a.ShouldNotBeNull();
|
||||
a.FileLetter.ShouldBe(letter);
|
||||
a.FileNumber.ShouldBe(file);
|
||||
a.WordNumber.ShouldBe(word);
|
||||
a.BitIndex.ShouldBe(bit);
|
||||
a.SubElement.ShouldBe(sub);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("N7")] // missing :word
|
||||
[InlineData(":0")] // missing file
|
||||
[InlineData("X7:0")] // unknown file letter
|
||||
[InlineData("N7:-1")] // negative word
|
||||
[InlineData("N7:abc")] // non-numeric word
|
||||
[InlineData("N7:0/-1")] // negative bit
|
||||
[InlineData("N7:0/32")] // bit out of range
|
||||
[InlineData("Nabc:0")] // non-numeric file number
|
||||
public void TryParse_rejects_invalid_forms(string? input)
|
||||
{
|
||||
AbLegacyAddress.TryParse(input).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("N7:0")]
|
||||
[InlineData("F8:5")]
|
||||
[InlineData("B3:0/0")]
|
||||
[InlineData("ST9:0")]
|
||||
[InlineData("T4:0.ACC")]
|
||||
[InlineData("I:0/0")]
|
||||
[InlineData("S:1")]
|
||||
public void ToLibplctagName_roundtrips(string input)
|
||||
{
|
||||
var a = AbLegacyAddress.TryParse(input);
|
||||
a.ShouldNotBeNull();
|
||||
a.ToLibplctagName().ShouldBe(input);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbLegacyBitRmwTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Bit_set_reads_parent_word_ORs_bit_writes_back()
|
||||
{
|
||||
var factory = new FakeAbLegacyTagFactory
|
||||
{
|
||||
Customise = p => new FakeAbLegacyTag(p) { Value = (short)0b0001 },
|
||||
};
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags = [new AbLegacyTagDefinition("Flag3", "ab://10.0.0.5/1,0", "N7:0/3", AbLegacyDataType.Bit)],
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("Flag3", true)], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
factory.Tags.ShouldContainKey("N7:0"); // parent word runtime created
|
||||
Convert.ToInt32(factory.Tags["N7:0"].Value).ShouldBe(0b1001);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bit_clear_preserves_other_bits_in_N_file_word()
|
||||
{
|
||||
var factory = new FakeAbLegacyTagFactory
|
||||
{
|
||||
Customise = p => new FakeAbLegacyTag(p) { Value = unchecked((short)0xFFFF) },
|
||||
};
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags = [new AbLegacyTagDefinition("F", "ab://10.0.0.5/1,0", "N7:0/3", AbLegacyDataType.Bit)],
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.WriteAsync([new WriteRequest("F", false)], CancellationToken.None);
|
||||
|
||||
Convert.ToInt32(factory.Tags["N7:0"].Value).ShouldBe(unchecked((short)0xFFF7));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Concurrent_bit_writes_to_same_word_compose_correctly()
|
||||
{
|
||||
var factory = new FakeAbLegacyTagFactory
|
||||
{
|
||||
Customise = p => new FakeAbLegacyTag(p) { Value = (short)0 },
|
||||
};
|
||||
var tags = Enumerable.Range(0, 8)
|
||||
.Select(b => new AbLegacyTagDefinition($"Bit{b}", "ab://10.0.0.5/1,0", $"N7:0/{b}", AbLegacyDataType.Bit))
|
||||
.ToArray();
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags = tags,
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await Task.WhenAll(Enumerable.Range(0, 8).Select(b =>
|
||||
drv.WriteAsync([new WriteRequest($"Bit{b}", true)], CancellationToken.None)));
|
||||
|
||||
Convert.ToInt32(factory.Tags["N7:0"].Value).ShouldBe(0xFF);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Repeat_bit_writes_reuse_parent_runtime()
|
||||
{
|
||||
var factory = new FakeAbLegacyTagFactory
|
||||
{
|
||||
Customise = p => new FakeAbLegacyTag(p) { Value = (short)0 },
|
||||
};
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags =
|
||||
[
|
||||
new AbLegacyTagDefinition("Bit0", "ab://10.0.0.5/1,0", "N7:0/0", AbLegacyDataType.Bit),
|
||||
new AbLegacyTagDefinition("Bit5", "ab://10.0.0.5/1,0", "N7:0/5", AbLegacyDataType.Bit),
|
||||
],
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.WriteAsync([new WriteRequest("Bit0", true)], CancellationToken.None);
|
||||
await drv.WriteAsync([new WriteRequest("Bit5", true)], CancellationToken.None);
|
||||
|
||||
factory.Tags["N7:0"].InitializeCount.ShouldBe(1);
|
||||
factory.Tags["N7:0"].WriteCount.ShouldBe(2);
|
||||
Convert.ToInt32(factory.Tags["N7:0"].Value).ShouldBe(0x21); // bits 0 + 5
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbLegacyCapabilityTests
|
||||
{
|
||||
// ---- ITagDiscovery ----
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_emits_pre_declared_tags_under_device_folder()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0", DeviceName: "Press-SLC-1")],
|
||||
Tags =
|
||||
[
|
||||
new AbLegacyTagDefinition("Speed", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int),
|
||||
new AbLegacyTagDefinition("Temperature", "ab://10.0.0.5/1,0", "F8:0", AbLegacyDataType.Float, Writable: false),
|
||||
],
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "AbLegacy");
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "ab://10.0.0.5/1,0" && f.DisplayName == "Press-SLC-1");
|
||||
builder.Variables.Count.ShouldBe(2);
|
||||
builder.Variables.Single(v => v.BrowseName == "Speed").Info.SecurityClass.ShouldBe(SecurityClassification.Operate);
|
||||
builder.Variables.Single(v => v.BrowseName == "Temperature").Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
|
||||
}
|
||||
|
||||
// ---- ISubscribable ----
|
||||
|
||||
[Fact]
|
||||
public async Task Subscribe_initial_poll_raises_OnDataChange()
|
||||
{
|
||||
var factory = new FakeAbLegacyTagFactory
|
||||
{
|
||||
Customise = p => new FakeAbLegacyTag(p) { Value = 42 },
|
||||
};
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags = [new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int)],
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
||||
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
||||
|
||||
var handle = await drv.SubscribeAsync(["X"], TimeSpan.FromMilliseconds(200), CancellationToken.None);
|
||||
await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(2));
|
||||
|
||||
events.First().Snapshot.Value.ShouldBe(42);
|
||||
await drv.UnsubscribeAsync(handle, CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unsubscribe_halts_polling()
|
||||
{
|
||||
var tagRef = new FakeAbLegacyTag(
|
||||
new AbLegacyTagCreateParams("10.0.0.5", 44818, "1,0", "slc500", "N7:0", TimeSpan.FromSeconds(2))) { Value = 1 };
|
||||
var factory = new FakeAbLegacyTagFactory { Customise = _ => tagRef };
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags = [new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int)],
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
||||
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
||||
|
||||
var handle = await drv.SubscribeAsync(["X"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
|
||||
await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(1));
|
||||
await drv.UnsubscribeAsync(handle, CancellationToken.None);
|
||||
|
||||
var afterUnsub = events.Count;
|
||||
tagRef.Value = 999;
|
||||
await Task.Delay(300);
|
||||
events.Count.ShouldBe(afterUnsub);
|
||||
}
|
||||
|
||||
// ---- IHostConnectivityProbe ----
|
||||
|
||||
[Fact]
|
||||
public async Task GetHostStatuses_returns_one_per_device()
|
||||
{
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices =
|
||||
[
|
||||
new AbLegacyDeviceOptions("ab://10.0.0.5/1,0"),
|
||||
new AbLegacyDeviceOptions("ab://10.0.0.6/1,0"),
|
||||
],
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.GetHostStatuses().Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_transitions_to_Running_on_successful_read()
|
||||
{
|
||||
var factory = new FakeAbLegacyTagFactory { Customise = p => new FakeAbLegacyTag(p) };
|
||||
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Probe = new AbLegacyProbeOptions
|
||||
{
|
||||
Enabled = true, Interval = TimeSpan.FromMilliseconds(100),
|
||||
Timeout = TimeSpan.FromMilliseconds(50), ProbeAddress = "S:0",
|
||||
},
|
||||
}, "drv-1", factory);
|
||||
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await WaitForAsync(() => transitions.Any(t => t.NewState == HostState.Running), TimeSpan.FromSeconds(2));
|
||||
|
||||
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Running);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_transitions_to_Stopped_on_read_failure()
|
||||
{
|
||||
var factory = new FakeAbLegacyTagFactory { Customise = p => new FakeAbLegacyTag(p) { ThrowOnRead = true } };
|
||||
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Probe = new AbLegacyProbeOptions
|
||||
{
|
||||
Enabled = true, Interval = TimeSpan.FromMilliseconds(100),
|
||||
Timeout = TimeSpan.FromMilliseconds(50), ProbeAddress = "S:0",
|
||||
},
|
||||
}, "drv-1", factory);
|
||||
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await WaitForAsync(() => transitions.Any(t => t.NewState == HostState.Stopped), TimeSpan.FromSeconds(2));
|
||||
|
||||
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Stopped);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_disabled_when_ProbeAddress_is_null()
|
||||
{
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Probe = new AbLegacyProbeOptions { Enabled = true, ProbeAddress = null },
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await Task.Delay(200);
|
||||
|
||||
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Unknown);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
// ---- IPerCallHostResolver ----
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveHost_returns_declared_device_for_known_tag()
|
||||
{
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices =
|
||||
[
|
||||
new AbLegacyDeviceOptions("ab://10.0.0.5/1,0"),
|
||||
new AbLegacyDeviceOptions("ab://10.0.0.6/1,0"),
|
||||
],
|
||||
Tags =
|
||||
[
|
||||
new AbLegacyTagDefinition("A", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int),
|
||||
new AbLegacyTagDefinition("B", "ab://10.0.0.6/1,0", "N7:0", AbLegacyDataType.Int),
|
||||
],
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.ResolveHost("A").ShouldBe("ab://10.0.0.5/1,0");
|
||||
drv.ResolveHost("B").ShouldBe("ab://10.0.0.6/1,0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveHost_falls_back_to_first_device_for_unknown()
|
||||
{
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.ResolveHost("missing").ShouldBe("ab://10.0.0.5/1,0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveHost_falls_back_to_DriverInstanceId_when_no_devices()
|
||||
{
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions(), "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.ResolveHost("anything").ShouldBe("drv-1");
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private static async Task WaitForAsync(Func<bool> condition, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (!condition() && DateTime.UtcNow < deadline)
|
||||
await Task.Delay(20);
|
||||
}
|
||||
|
||||
private sealed class RecordingBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
|
||||
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
|
||||
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||
{ Folders.Add((browseName, displayName)); return this; }
|
||||
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
|
||||
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
|
||||
|
||||
public void AddProperty(string _, DriverDataType __, object? ___) { }
|
||||
|
||||
private sealed class Handle(string fullRef) : IVariableHandle
|
||||
{
|
||||
public string FullReference => fullRef;
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
|
||||
}
|
||||
private sealed class NullSink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbLegacyDriverTests
|
||||
{
|
||||
[Fact]
|
||||
public void DriverType_is_AbLegacy()
|
||||
{
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions(), "drv-1");
|
||||
drv.DriverType.ShouldBe("AbLegacy");
|
||||
drv.DriverInstanceId.ShouldBe("drv-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InitializeAsync_with_devices_assigns_family_profiles()
|
||||
{
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices =
|
||||
[
|
||||
new AbLegacyDeviceOptions("ab://10.0.0.5/1,0", AbLegacyPlcFamily.Slc500),
|
||||
new AbLegacyDeviceOptions("ab://10.0.0.6/", AbLegacyPlcFamily.MicroLogix),
|
||||
new AbLegacyDeviceOptions("ab://10.0.0.7/1,0", AbLegacyPlcFamily.Plc5),
|
||||
],
|
||||
}, "drv-1");
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.DeviceCount.ShouldBe(3);
|
||||
drv.GetDeviceState("ab://10.0.0.5/1,0")!.Profile.ShouldBe(AbLegacyPlcFamilyProfile.Slc500);
|
||||
drv.GetDeviceState("ab://10.0.0.6/")!.Profile.ShouldBe(AbLegacyPlcFamilyProfile.MicroLogix);
|
||||
drv.GetDeviceState("ab://10.0.0.7/1,0")!.Profile.ShouldBe(AbLegacyPlcFamilyProfile.Plc5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InitializeAsync_with_malformed_host_address_faults()
|
||||
{
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("not-a-valid-address")],
|
||||
}, "drv-1");
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(
|
||||
() => drv.InitializeAsync("{}", CancellationToken.None));
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShutdownAsync_clears_devices()
|
||||
{
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
drv.DeviceCount.ShouldBe(0);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Family_profiles_expose_expected_defaults()
|
||||
{
|
||||
AbLegacyPlcFamilyProfile.Slc500.LibplctagPlcAttribute.ShouldBe("slc500");
|
||||
AbLegacyPlcFamilyProfile.Slc500.SupportsLongFile.ShouldBeTrue();
|
||||
AbLegacyPlcFamilyProfile.Slc500.DefaultCipPath.ShouldBe("1,0");
|
||||
|
||||
AbLegacyPlcFamilyProfile.MicroLogix.DefaultCipPath.ShouldBe("");
|
||||
AbLegacyPlcFamilyProfile.MicroLogix.SupportsLongFile.ShouldBeFalse();
|
||||
|
||||
AbLegacyPlcFamilyProfile.Plc5.LibplctagPlcAttribute.ShouldBe("plc5");
|
||||
AbLegacyPlcFamilyProfile.Plc5.SupportsLongFile.ShouldBeFalse();
|
||||
|
||||
AbLegacyPlcFamilyProfile.LogixPccc.LibplctagPlcAttribute.ShouldBe("logixpccc");
|
||||
AbLegacyPlcFamilyProfile.LogixPccc.SupportsLongFile.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AbLegacyPlcFamily.Slc500, "slc500")]
|
||||
[InlineData(AbLegacyPlcFamily.MicroLogix, "micrologix")]
|
||||
[InlineData(AbLegacyPlcFamily.Plc5, "plc5")]
|
||||
[InlineData(AbLegacyPlcFamily.LogixPccc, "logixpccc")]
|
||||
public void ForFamily_dispatches_correctly(AbLegacyPlcFamily family, string expectedAttribute)
|
||||
{
|
||||
AbLegacyPlcFamilyProfile.ForFamily(family).LibplctagPlcAttribute.ShouldBe(expectedAttribute);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DataType_mapping_covers_atomic_pccc_types()
|
||||
{
|
||||
AbLegacyDataType.Bit.ToDriverDataType().ShouldBe(DriverDataType.Boolean);
|
||||
AbLegacyDataType.Int.ToDriverDataType().ShouldBe(DriverDataType.Int32);
|
||||
AbLegacyDataType.Long.ToDriverDataType().ShouldBe(DriverDataType.Int32);
|
||||
AbLegacyDataType.Float.ToDriverDataType().ShouldBe(DriverDataType.Float32);
|
||||
AbLegacyDataType.String.ToDriverDataType().ShouldBe(DriverDataType.String);
|
||||
AbLegacyDataType.TimerElement.ToDriverDataType().ShouldBe(DriverDataType.Int32);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbLegacyHostAndStatusTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("ab://10.0.0.5/1,0", "10.0.0.5", 44818, "1,0")]
|
||||
[InlineData("ab://10.0.0.5/", "10.0.0.5", 44818, "")]
|
||||
[InlineData("ab://10.0.0.5:2222/1,0", "10.0.0.5", 2222, "1,0")]
|
||||
[InlineData("ab://plc-slc.factory/1,2", "plc-slc.factory", 44818, "1,2")]
|
||||
public void HostAddress_parses_valid(string input, string gateway, int port, string path)
|
||||
{
|
||||
var parsed = AbLegacyHostAddress.TryParse(input);
|
||||
parsed.ShouldNotBeNull();
|
||||
parsed.Gateway.ShouldBe(gateway);
|
||||
parsed.Port.ShouldBe(port);
|
||||
parsed.CipPath.ShouldBe(path);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("http://10.0.0.5/1,0")]
|
||||
[InlineData("ab://10.0.0.5")]
|
||||
[InlineData("ab:///1,0")]
|
||||
[InlineData("ab://10.0.0.5:0/1,0")]
|
||||
public void HostAddress_rejects_invalid(string? input)
|
||||
{
|
||||
AbLegacyHostAddress.TryParse(input).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HostAddress_ToString_canonicalises()
|
||||
{
|
||||
new AbLegacyHostAddress("10.0.0.5", 44818, "1,0").ToString().ShouldBe("ab://10.0.0.5/1,0");
|
||||
new AbLegacyHostAddress("10.0.0.5", 2222, "1,0").ToString().ShouldBe("ab://10.0.0.5:2222/1,0");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData((byte)0x00, AbLegacyStatusMapper.Good)]
|
||||
[InlineData((byte)0x10, AbLegacyStatusMapper.BadNotSupported)]
|
||||
[InlineData((byte)0x20, AbLegacyStatusMapper.BadNodeIdUnknown)]
|
||||
[InlineData((byte)0x30, AbLegacyStatusMapper.BadNotWritable)]
|
||||
[InlineData((byte)0x40, AbLegacyStatusMapper.BadDeviceFailure)]
|
||||
[InlineData((byte)0x50, AbLegacyStatusMapper.BadDeviceFailure)]
|
||||
[InlineData((byte)0xF0, AbLegacyStatusMapper.BadInternalError)]
|
||||
[InlineData((byte)0xFF, AbLegacyStatusMapper.BadCommunicationError)]
|
||||
public void PcccStatus_maps_known_codes(byte sts, uint expected)
|
||||
{
|
||||
AbLegacyStatusMapper.MapPcccStatus(sts).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, AbLegacyStatusMapper.Good)]
|
||||
[InlineData(1, AbLegacyStatusMapper.GoodMoreData)]
|
||||
[InlineData(-5, AbLegacyStatusMapper.BadTimeout)]
|
||||
[InlineData(-7, AbLegacyStatusMapper.BadCommunicationError)]
|
||||
[InlineData(-14, AbLegacyStatusMapper.BadNodeIdUnknown)]
|
||||
[InlineData(-16, AbLegacyStatusMapper.BadNotWritable)]
|
||||
[InlineData(-17, AbLegacyStatusMapper.BadOutOfRange)]
|
||||
public void LibplctagStatus_maps_known_codes(int status, uint expected)
|
||||
{
|
||||
AbLegacyStatusMapper.MapLibplctagStatus(status).ShouldBe(expected);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbLegacyReadWriteTests
|
||||
{
|
||||
private static (AbLegacyDriver drv, FakeAbLegacyTagFactory factory) NewDriver(params AbLegacyTagDefinition[] tags)
|
||||
{
|
||||
var factory = new FakeAbLegacyTagFactory();
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags = tags,
|
||||
}, "drv-1", factory);
|
||||
return (drv, factory);
|
||||
}
|
||||
|
||||
// ---- Read ----
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_reference_maps_to_BadNodeIdUnknown()
|
||||
{
|
||||
var (drv, _) = NewDriver();
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var snapshots = await drv.ReadAsync(["missing"], CancellationToken.None);
|
||||
snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Successful_N_file_read_returns_Good_value()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbLegacyTagDefinition("Counter", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbLegacyTag(p) { Value = 42 };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Counter"], CancellationToken.None);
|
||||
|
||||
snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
snapshots.Single().Value.ShouldBe(42);
|
||||
factory.Tags["N7:0"].InitializeCount.ShouldBe(1);
|
||||
factory.Tags["N7:0"].ReadCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Repeat_read_reuses_runtime()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbLegacyTag(p) { Value = 1 };
|
||||
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
|
||||
factory.Tags["N7:0"].InitializeCount.ShouldBe(1);
|
||||
factory.Tags["N7:0"].ReadCount.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NonZero_libplctag_status_maps_via_AbLegacyStatusMapper()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbLegacyTag(p) { Status = -14 };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_exception_surfaces_BadCommunicationError()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbLegacyTag(p) { ThrowOnRead = true };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Batched_reads_preserve_order()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbLegacyTagDefinition("A", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int),
|
||||
new AbLegacyTagDefinition("B", "ab://10.0.0.5/1,0", "F8:0", AbLegacyDataType.Float),
|
||||
new AbLegacyTagDefinition("C", "ab://10.0.0.5/1,0", "ST9:0", AbLegacyDataType.String));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => p.TagName switch
|
||||
{
|
||||
"N7:0" => new FakeAbLegacyTag(p) { Value = 1 },
|
||||
"F8:0" => new FakeAbLegacyTag(p) { Value = 3.14f },
|
||||
_ => new FakeAbLegacyTag(p) { Value = "hello" },
|
||||
};
|
||||
|
||||
var snapshots = await drv.ReadAsync(["A", "B", "C"], CancellationToken.None);
|
||||
|
||||
snapshots.Count.ShouldBe(3);
|
||||
snapshots[0].Value.ShouldBe(1);
|
||||
snapshots[1].Value.ShouldBe(3.14f);
|
||||
snapshots[2].Value.ShouldBe("hello");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_TagCreateParams_composed_from_device_and_profile()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", "N7:5", AbLegacyDataType.Int));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
|
||||
var p = factory.Tags["N7:5"].CreationParams;
|
||||
p.Gateway.ShouldBe("10.0.0.5");
|
||||
p.Port.ShouldBe(44818);
|
||||
p.CipPath.ShouldBe("1,0");
|
||||
p.LibplctagPlcAttribute.ShouldBe("slc500");
|
||||
p.TagName.ShouldBe("N7:5");
|
||||
}
|
||||
|
||||
// ---- Write ----
|
||||
|
||||
[Fact]
|
||||
public async Task Non_writable_tag_rejects_with_BadNotWritable()
|
||||
{
|
||||
var (drv, _) = NewDriver(
|
||||
new AbLegacyTagDefinition("RO", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int, Writable: false));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("RO", 1)], CancellationToken.None);
|
||||
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotWritable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Successful_N_file_write_encodes_and_flushes()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("X", 123)], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
factory.Tags["N7:0"].Value.ShouldBe(123);
|
||||
factory.Tags["N7:0"].WriteCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bit_within_word_write_now_succeeds_via_RMW()
|
||||
{
|
||||
// Task #181 pass 2 lifted this gap — N-file bit writes now go through
|
||||
// WriteBitInWordAsync + a parallel parent-word runtime, so the status is Good rather
|
||||
// than BadNotSupported. Full RMW semantics covered by AbLegacyBitRmwTests.
|
||||
var factory = new FakeAbLegacyTagFactory();
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags = [new AbLegacyTagDefinition("Bit3", "ab://10.0.0.5/1,0", "N7:0/3", AbLegacyDataType.Bit)],
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("Bit3", true)], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Write_exception_surfaces_BadCommunicationError()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbLegacyTag(p) { ThrowOnWrite = true };
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("X", 1)], CancellationToken.None);
|
||||
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Batch_write_preserves_order_across_outcomes()
|
||||
{
|
||||
var factory = new FakeAbLegacyTagFactory();
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags =
|
||||
[
|
||||
new AbLegacyTagDefinition("A", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int),
|
||||
new AbLegacyTagDefinition("B", "ab://10.0.0.5/1,0", "N7:1", AbLegacyDataType.Int, Writable: false),
|
||||
],
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[
|
||||
new WriteRequest("A", 1),
|
||||
new WriteRequest("B", 2),
|
||||
new WriteRequest("Unknown", 3),
|
||||
], CancellationToken.None);
|
||||
|
||||
results.Count.ShouldBe(3);
|
||||
results[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
results[1].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotWritable);
|
||||
results[2].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cancellation_propagates()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbLegacyTag(p)
|
||||
{
|
||||
ThrowOnRead = true,
|
||||
Exception = new OperationCanceledException(),
|
||||
};
|
||||
|
||||
await Should.ThrowAsync<OperationCanceledException>(
|
||||
() => drv.ReadAsync(["X"], CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShutdownAsync_disposes_runtimes()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbLegacyTagDefinition("A", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbLegacyTag(p) { Value = 1 };
|
||||
|
||||
await drv.ReadAsync(["A"], CancellationToken.None);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
|
||||
factory.Tags["N7:0"].Disposed.ShouldBeTrue();
|
||||
}
|
||||
|
||||
private sealed class RmwThrowingFake(AbLegacyTagCreateParams p) : FakeAbLegacyTag(p)
|
||||
{
|
||||
public override void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value)
|
||||
{
|
||||
if (type == AbLegacyDataType.Bit && bitIndex is not null)
|
||||
throw new NotSupportedException("bit-within-word RMW deferred");
|
||||
Value = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
|
||||
internal class FakeAbLegacyTag : IAbLegacyTagRuntime
|
||||
{
|
||||
public AbLegacyTagCreateParams CreationParams { get; }
|
||||
public object? Value { get; set; }
|
||||
public int Status { get; set; }
|
||||
public bool ThrowOnInitialize { get; set; }
|
||||
public bool ThrowOnRead { get; set; }
|
||||
public bool ThrowOnWrite { get; set; }
|
||||
public Exception? Exception { get; set; }
|
||||
public int InitializeCount { get; private set; }
|
||||
public int ReadCount { get; private set; }
|
||||
public int WriteCount { get; private set; }
|
||||
public bool Disposed { get; private set; }
|
||||
|
||||
public FakeAbLegacyTag(AbLegacyTagCreateParams p) => CreationParams = p;
|
||||
|
||||
public virtual Task InitializeAsync(CancellationToken ct)
|
||||
{
|
||||
InitializeCount++;
|
||||
if (ThrowOnInitialize) throw Exception ?? new InvalidOperationException();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public virtual Task ReadAsync(CancellationToken ct)
|
||||
{
|
||||
ReadCount++;
|
||||
if (ThrowOnRead) throw Exception ?? new InvalidOperationException();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public virtual Task WriteAsync(CancellationToken ct)
|
||||
{
|
||||
WriteCount++;
|
||||
if (ThrowOnWrite) throw Exception ?? new InvalidOperationException();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public virtual int GetStatus() => Status;
|
||||
public virtual object? DecodeValue(AbLegacyDataType type, int? bitIndex) => Value;
|
||||
public virtual void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value) => Value = value;
|
||||
public virtual void Dispose() => Disposed = true;
|
||||
}
|
||||
|
||||
internal sealed class FakeAbLegacyTagFactory : IAbLegacyTagFactory
|
||||
{
|
||||
public Dictionary<string, FakeAbLegacyTag> Tags { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public Func<AbLegacyTagCreateParams, FakeAbLegacyTag>? Customise { get; set; }
|
||||
|
||||
public IAbLegacyTagRuntime Create(AbLegacyTagCreateParams p)
|
||||
{
|
||||
var fake = Customise?.Invoke(p) ?? new FakeAbLegacyTag(p);
|
||||
Tags[p.TagName] = fake;
|
||||
return fake;
|
||||
}
|
||||
}
|
||||
+31
@@ -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\Drivers\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,94 @@
|
||||
# FOCAS Docker simulator — focas-mock + shim DLL
|
||||
|
||||
Hardware-free FOCAS fixture for OtOpcUa's integration test matrix. Runs
|
||||
the vendored [`focas-mock`](focas-mock/VENDORED.md) Python server under
|
||||
Docker and pairs it with the [shim DLL](../Shim/VENDORED.md) that
|
||||
masquerades as `Fwlib64.dll` inside the .NET test process.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌────────────────────────────┐ cnc_allclibhndl3 / cnc_rdparam / ...
|
||||
│ xunit test process │ (P/Invoke, __stdcall)
|
||||
│ ├── Driver.FOCAS │
|
||||
│ │ └── FwlibNative.cs ─┼─┐
|
||||
│ └── FocasSimFixture │ │ resolves to...
|
||||
└────────────────────────────┘ │
|
||||
▼
|
||||
┌────────────────────────────┐
|
||||
│ Fwlib64.dll (shim) │ JSON over TCP
|
||||
│ tests/.../Shim/focas_ │──────────────────────┐
|
||||
│ shim.c compiled here │ │
|
||||
└────────────────────────────┘ │
|
||||
▼
|
||||
┌─────────────────────────────┐
|
||||
│ focas-mock (Docker) │
|
||||
│ python:3.11-slim │
|
||||
│ profile-aware responses │
|
||||
│ mock_load_profile / │
|
||||
│ mock_patch admin methods │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
The shim bridges the binary ABI (C `__stdcall` exports with FOCAS struct
|
||||
shapes) to the mock's newline-delimited JSON protocol. OtOpcUa's
|
||||
`FocasSimFixture` seeds per-test state by sending `mock_load_profile` +
|
||||
`mock_patch` admin calls on the same socket. Tests assert the managed
|
||||
driver sees the seeded values through its normal P/Invoke path.
|
||||
|
||||
## Running
|
||||
|
||||
Pick one compose profile (they all publish 8193 — only one at a time):
|
||||
|
||||
```powershell
|
||||
docker compose -f Docker/docker-compose.yml --profile thirtyone up -d
|
||||
dotnet test tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests
|
||||
docker compose -f Docker/docker-compose.yml --profile thirtyone down
|
||||
```
|
||||
|
||||
Available profiles + their focas-mock target:
|
||||
|
||||
| compose --profile | focas-mock profile | Covers |
|
||||
|---|---|---|
|
||||
| `thirtyone` / `thirty` / `thirtytwo` | `fwlib30i64` | 30i / 31i / 32i series |
|
||||
| `sixteen` | `FWLIB64` | 16i / 18i / 21i legacy family |
|
||||
| `zerod` / `zerof` / `zeromf` / `zerotf` | `fwlib0iD64` | 0i-D / 0i-F / 0i-MF / 0i-TF |
|
||||
| `powermotion` | `fwlib0DN64` | Power Motion i |
|
||||
| `ethernet` | `fwlibe64` | Ethernet-variant DLL |
|
||||
| `ncguide` | `fwlibNCG64` | NC Guide PC simulator |
|
||||
|
||||
## What this covers — and what it doesn't
|
||||
|
||||
**Covered:**
|
||||
|
||||
- All 10 FOCAS functions `FwlibNative.cs` P/Invokes
|
||||
- Read-after-write round-trip for parameters, macros, PMC ranges
|
||||
- PMC bit read-modify-write path (via the `pmc_wrpmcrng` seam)
|
||||
- `IAlarmSource` raise + clear transitions (via `mock_schedule_alarms`)
|
||||
- Per-series profile selection — tests can pin one and assert series-gated
|
||||
behaviour
|
||||
|
||||
**Not covered** (still hardware-gated):
|
||||
|
||||
- Real FOCAS2 TCP wire protocol (this is a JSON mock; the shim hides
|
||||
the real protocol entirely)
|
||||
- CNC-specific firmware quirks (position scaling across power cycles,
|
||||
edit-mode session locks, MTB custom screens)
|
||||
- Concurrent-read behaviour on the real `Fwlib64.dll` — the shim is
|
||||
single-threaded per connection
|
||||
|
||||
See [`docs/drivers/FOCAS-Test-Fixture.md`](../../../docs/drivers/FOCAS-Test-Fixture.md)
|
||||
for the full coverage map.
|
||||
|
||||
## Skip behaviour
|
||||
|
||||
`FocasSimFixture` probes the mock at collection init time:
|
||||
|
||||
- Mock unreachable → tests skip with the compose-up command to run
|
||||
- Mock reachable but shim DLL not loaded → tests skip with a pointer
|
||||
at `Shim/build.ps1`
|
||||
- Both available → tests run
|
||||
|
||||
This lets the same test assembly be green on a fresh CI box without
|
||||
docker, green on a dev box with just the docker compose up, and
|
||||
exercise the full wire path when the shim is built.
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
# FOCAS simulator — focas-mock JSON/TCP + native FOCAS2 Ethernet server.
|
||||
#
|
||||
# The image is built from the vendored focas-mock snapshot at ./focas-mock/
|
||||
# (see focas-mock/VENDORED.md for refresh procedure).
|
||||
#
|
||||
# Usage:
|
||||
# docker compose -f Docker/docker-compose.yml up -d --wait
|
||||
# docker compose -f Docker/docker-compose.yml down
|
||||
#
|
||||
# One service, one container — the mock's native FOCAS Ethernet responder
|
||||
# auto-detects the binary PDU prefix (`a0 a0 a0 a0`) on the same TCP port
|
||||
# that serves JSON admin commands. Tests that need per-series behaviour
|
||||
# call `mock_load_profile` via the fixture's admin API at test start.
|
||||
# The pre-wire-client era had one compose profile per CNC series; that
|
||||
# ceremony is gone because the managed wire client doesn't depend on a
|
||||
# per-series shim DLL.
|
||||
|
||||
services:
|
||||
focas-sim:
|
||||
image: otopcua-focas-sim:latest
|
||||
build:
|
||||
context: ./focas-mock
|
||||
dockerfile: Dockerfile
|
||||
container_name: otopcua-focas-sim
|
||||
ports:
|
||||
- "8193:8193"
|
||||
restart: "no"
|
||||
command: ["--profile", "FWLIB64"]
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "python -c \"import socket; s=socket.create_connection(('127.0.0.1',8193),timeout=2); s.close()\" || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY pyproject.toml README.md LICENSE ./
|
||||
COPY src ./src
|
||||
|
||||
RUN pip install --no-cache-dir .
|
||||
|
||||
EXPOSE 8193
|
||||
|
||||
ENTRYPOINT ["focas-mock", "serve", "--host", "0.0.0.0", "--port", "8193"]
|
||||
CMD ["--profile", "FWLIB64"]
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
# focas-mock
|
||||
|
||||
`focas-mock` is a Python TCP mock server for testing higher-level FOCAS clients without a real FANUC control.
|
||||
|
||||
The project is built from two inputs:
|
||||
|
||||
- The 64-bit FANUC-related DLLs downloaded from [Ladder99/fanuc-cnc-api](https://github.com/Ladder99/fanuc-cnc-api)
|
||||
- The vendor `fwlib.cs` interop file, used as the callable surface reference
|
||||
|
||||
The DLLs are not reimplemented at the binary ABI level. Instead, this project extracts their export tables, builds per-version capability profiles, exposes a JSON-over-TCP mock API, and implements the targeted native FOCAS Ethernet wire protocol used by OtOpcUa fixed-tree tests.
|
||||
|
||||
## What is included
|
||||
|
||||
- Vendored 64-bit DLLs under `vendor/fanuc-cnc-api/64bit/`
|
||||
- A profile extractor that inspects PE exports with `pefile`
|
||||
- A Windows P/Invoke shim source under `shim/` for clients that load `FWLIB64.dll` directly
|
||||
- Built-in profiles for:
|
||||
- `FWLIB64`
|
||||
- `fwlib0DN64`
|
||||
- `fwlib0iD64`
|
||||
- `fwlib30i64`
|
||||
- `fwlibe64`
|
||||
- `fwlibNCG64`
|
||||
- A stateful mock server with:
|
||||
- version/profile switching
|
||||
- forced error injection
|
||||
- runtime state patching
|
||||
- built-in default mock data
|
||||
- auto-detected native FOCAS Ethernet PDU handling for the targeted API subset
|
||||
|
||||
## Quick start
|
||||
|
||||
Install in editable mode:
|
||||
|
||||
```powershell
|
||||
python -m pip install -e .
|
||||
```
|
||||
|
||||
List the generated profiles:
|
||||
|
||||
```powershell
|
||||
focas-mock list-profiles
|
||||
```
|
||||
|
||||
Start the mock server with the 30i profile:
|
||||
|
||||
```powershell
|
||||
focas-mock serve --profile fwlib30i64 --host 127.0.0.1 --port 8193
|
||||
```
|
||||
|
||||
Start with a JSON patch file that overrides the default data:
|
||||
|
||||
```powershell
|
||||
focas-mock serve --profile fwlib30i64 --data examples/mock-30i.json
|
||||
```
|
||||
|
||||
## Protocol
|
||||
|
||||
The server accepts two protocols on the same port:
|
||||
|
||||
- newline-delimited JSON for fixture control and shim tests
|
||||
- native FOCAS Ethernet binary PDUs from the real `fwlibe64.dll`
|
||||
|
||||
JSON requests are one object per line:
|
||||
|
||||
```json
|
||||
{"id":1,"method":"cnc_allclibhndl3","params":{"ipaddr":"127.0.0.1","port":8193,"timeout":10}}
|
||||
```
|
||||
|
||||
Example response:
|
||||
|
||||
```json
|
||||
{"id":1,"method":"cnc_allclibhndl3","rc":0,"message":"EW_OK","result":{"FlibHndl":1,"profile":"fwlib30i64"}}
|
||||
```
|
||||
|
||||
Supported admin methods:
|
||||
|
||||
- `mock_get_state`
|
||||
- `mock_patch`
|
||||
- `mock_reset`
|
||||
- `mock_load_profile`
|
||||
- `mock_list_methods`
|
||||
- `mock_schedule_alarms`
|
||||
|
||||
Example patch request:
|
||||
|
||||
```json
|
||||
{"id":2,"method":"mock_patch","params":{"state":{"parameters":{"6711":{"type":"long","value":1234,"decimal":0}}}}}
|
||||
```
|
||||
|
||||
Native FOCAS Ethernet clients do not use the JSON request format. Seed profile
|
||||
and fixture state with JSON first, then point `cnc_allclibhndl3` at the same
|
||||
host and port. Wire-level details are documented in
|
||||
`docs/FOCAS_WIRE_PROTOCOL.md`.
|
||||
|
||||
For clients that should avoid FANUC DLL loading entirely, `dotnet/Focas.Wire`
|
||||
contains a native C# read-only TCP client for the verified wire subset. It does
|
||||
not expose write APIs; use the JSON control channel to preset fixture state.
|
||||
|
||||
Example test setup over TCP:
|
||||
|
||||
```json
|
||||
{"id":1,"method":"mock_load_profile","params":{"profile":"FWLIB64"}}
|
||||
{"id":2,"method":"mock_patch","params":{"state":{"pmc":{"R":{"100":{"type":"byte","value":1}}},"parameters":{"6711":{"type":"long","value":1234,"decimal":0}},"macros":{"500":{"value":42000,"decimal":3}},"statinfo":{"run":3,"aut":1,"emergency":0},"alarms":[{"alm_no":100,"type":1,"axis":0,"msg":"TEST ALARM"}]}}}
|
||||
{"id":3,"method":"cnc_allclibhndl3","params":{"ipaddr":"127.0.0.1","port":8193,"timeout":10}}
|
||||
{"id":4,"method":"pmc_rdpmcrng","params":{"FlibHndl":1,"area":"R","data_type":"byte","start":100,"end":100}}
|
||||
```
|
||||
|
||||
## Regenerating profiles
|
||||
|
||||
The built-in JSON profiles are generated from the vendored binaries:
|
||||
|
||||
```powershell
|
||||
python -m focas_mock.cli extract-profiles
|
||||
```
|
||||
|
||||
By default this reads:
|
||||
|
||||
- `vendor/fanuc-cnc-api/64bit/*.dll`
|
||||
- `upstream/fwlib.cs`
|
||||
|
||||
and writes:
|
||||
|
||||
- `src/focas_mock/builtin_profiles/*.json`
|
||||
|
||||
## Testing Direct P/Invoke Clients
|
||||
|
||||
If a client directly P/Invokes FANUC's 64-bit DLLs, point it at the shim DLLs built from `shim/` instead of the real vendor DLLs. The shim exports the small FOCAS surface used by the client and forwards calls to this Python server over JSON/TCP.
|
||||
|
||||
```powershell
|
||||
focas-mock serve --profile FWLIB64 --host 127.0.0.1 --port 8193
|
||||
.\shim\build.ps1
|
||||
$env:FOCAS_MOCK_HOST = "127.0.0.1"
|
||||
$env:FOCAS_MOCK_PORT = "8193"
|
||||
```
|
||||
|
||||
Before running the client, seed profile/state with `mock_load_profile` and `mock_patch` as shown above.
|
||||
|
||||
Detailed documentation for the supported FOCAS subset is in `docs/USED_FOCAS_API.md`.
|
||||
Native Ethernet wire notes are in `docs/FOCAS_WIRE_PROTOCOL.md`.
|
||||
OtOpcUa-specific setup notes are in `docs/OTOPCUA_DOTNET_INTEGRATION.md`.
|
||||
|
||||
## Implemented mock calls
|
||||
|
||||
The server currently implements a practical subset of the surface observed in the exported DLLs and the C# wrapper:
|
||||
|
||||
- `cnc_allclibhndl`
|
||||
- `cnc_allclibhndl2`
|
||||
- `cnc_allclibhndl3`
|
||||
- `cnc_freelibhndl`
|
||||
- `cnc_sysinfo`
|
||||
- `cnc_statinfo`
|
||||
- `cnc_rddynamic2`
|
||||
- `cnc_actf`
|
||||
- `cnc_acts`
|
||||
- `cnc_acts2`
|
||||
- `cnc_getpath`
|
||||
- `cnc_setpath`
|
||||
- `cnc_rdaxisname`
|
||||
- `cnc_rdspdlname`
|
||||
- `cnc_rdparam`
|
||||
- `cnc_wrparam`
|
||||
- `cnc_rdmacro`
|
||||
- `cnc_wrmacro`
|
||||
- `cnc_rdalmmsg2`
|
||||
- `pmc_rdpmcrng`
|
||||
- `pmc_wrpmcrng`
|
||||
- `cnc_rdopmsg`
|
||||
- `cnc_rdopmode`
|
||||
- `cnc_rdprgnum`
|
||||
- `cnc_exeprgname2`
|
||||
- `cnc_rdexecprog`
|
||||
- `cnc_rdseqnum`
|
||||
- `cnc_rdblkcount`
|
||||
- `cnc_rdproginfo`
|
||||
- `cnc_rdprogdir3`
|
||||
- `cnc_rdtimer`
|
||||
- `cnc_rdspmeter`
|
||||
- `cnc_rdsvmeter`
|
||||
- `cnc_rdspload`
|
||||
- `cnc_rdspgear`
|
||||
- `cnc_rdspmaxrpm`
|
||||
- `cnc_rddiagnum`
|
||||
- `cnc_rddiaginfo`
|
||||
- `cnc_diagnoss`
|
||||
|
||||
## Limitations
|
||||
|
||||
- This is not a binary-compatible replacement for FANUC's DLLs.
|
||||
- Native FOCAS Ethernet support is intentionally scoped to the targeted API subset documented in `docs/FOCAS_WIRE_PROTOCOL.md`.
|
||||
- The per-version profiles are grounded in exported symbol tables plus the published interop wrapper, while some defaults such as axis-count hints are inferred from filename families and documented as heuristics.
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
# focas-mock — vendored snapshot
|
||||
|
||||
Source: `C:\Users\dohertj2\Desktop\focas` (sibling project in this dev environment).
|
||||
|
||||
**Snapshot date:** 2026-04-24 (second refresh — pulled the native FOCAS2 Ethernet responder work in).
|
||||
|
||||
## Why vendored
|
||||
|
||||
OtOpcUa's FOCAS integration fixture runs against the Python mock server.
|
||||
The upstream lives in its own repo; this directory is a verbatim
|
||||
snapshot so CI can build the Docker image without network access to the
|
||||
source repo and so OtOpcUa's test matrix pins a known-good revision.
|
||||
|
||||
The managed `WireFocasClient` speaks the mock's native FOCAS2 Ethernet
|
||||
binary protocol directly — there's no longer a companion shim DLL.
|
||||
|
||||
## What's here
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `src/focas_mock/` | Python package — TCP JSON/line-delimited mock server with 6 Fanuc CNC profiles |
|
||||
| `pyproject.toml` | Package metadata; installs `focas-mock` CLI |
|
||||
| `Dockerfile` | `python:3.11-slim` image built by the parent `docker-compose.yml` |
|
||||
| `README.md` | Upstream README |
|
||||
| `LICENSE` | MIT — permissive, vendoring allowed |
|
||||
|
||||
|
||||
## Refreshing the snapshot
|
||||
|
||||
When upstream ships changes worth pulling:
|
||||
|
||||
```powershell
|
||||
$src = "C:\Users\dohertj2\Desktop\focas"
|
||||
$dest = "$PWD"
|
||||
Remove-Item -Recurse -Force "$dest\src" 2>$null
|
||||
Copy-Item -Recurse "$src\src" "$dest\src"
|
||||
Copy-Item "$src\pyproject.toml" "$dest\"
|
||||
Copy-Item "$src\README.md" "$dest\"
|
||||
Copy-Item "$src\LICENSE" "$dest\"
|
||||
Copy-Item "$src\Dockerfile" "$dest\"
|
||||
```
|
||||
|
||||
Update the snapshot date at the top of this file afterward. No other
|
||||
files belong here — the Docker build context is just the Python package
|
||||
and its metadata.
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=69", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "focas-mock"
|
||||
version = "0.1.0"
|
||||
description = "Mock FOCAS server with version-aware profiles derived from FANUC 64-bit DLL exports."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
license = "MIT"
|
||||
dependencies = ["pefile>=2024.8.26"]
|
||||
|
||||
[project.scripts]
|
||||
focas-mock = "focas_mock.cli:main"
|
||||
|
||||
[tool.setuptools]
|
||||
package-dir = {"" = "src"}
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
focas_mock = ["builtin_profiles/*.json"]
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
Metadata-Version: 2.4
|
||||
Name: focas-mock
|
||||
Version: 0.1.0
|
||||
Summary: Mock FOCAS server with version-aware profiles derived from FANUC 64-bit DLL exports.
|
||||
License-Expression: MIT
|
||||
Requires-Python: >=3.11
|
||||
Description-Content-Type: text/markdown
|
||||
License-File: LICENSE
|
||||
Requires-Dist: pefile>=2024.8.26
|
||||
Dynamic: license-file
|
||||
|
||||
# focas-mock
|
||||
|
||||
`focas-mock` is a Python TCP mock server for testing higher-level FOCAS clients without a real FANUC control.
|
||||
|
||||
The project is built from two inputs:
|
||||
|
||||
- The 64-bit FANUC-related DLLs downloaded from [Ladder99/fanuc-cnc-api](https://github.com/Ladder99/fanuc-cnc-api)
|
||||
- The vendor `fwlib.cs` interop file, used as the callable surface reference
|
||||
|
||||
The DLLs are not reimplemented at the binary ABI level. Instead, this project extracts their export tables, builds per-version capability profiles, and exposes a JSON-over-TCP mock API whose method names match common FOCAS entry points such as `cnc_allclibhndl3`, `cnc_sysinfo`, `cnc_statinfo`, and `cnc_rddynamic2`.
|
||||
|
||||
## What is included
|
||||
|
||||
- Vendored 64-bit DLLs under `vendor/fanuc-cnc-api/64bit/`
|
||||
- A profile extractor that inspects PE exports with `pefile`
|
||||
- A Windows P/Invoke shim source under `shim/` for clients that load `FWLIB64.dll` directly
|
||||
- Built-in profiles for:
|
||||
- `FWLIB64`
|
||||
- `fwlib0DN64`
|
||||
- `fwlib0iD64`
|
||||
- `fwlib30i64`
|
||||
- `fwlibe64`
|
||||
- `fwlibNCG64`
|
||||
- A stateful mock server with:
|
||||
- version/profile switching
|
||||
- forced error injection
|
||||
- runtime state patching
|
||||
- built-in default mock data
|
||||
|
||||
## Quick start
|
||||
|
||||
Install in editable mode:
|
||||
|
||||
```powershell
|
||||
python -m pip install -e .
|
||||
```
|
||||
|
||||
List the generated profiles:
|
||||
|
||||
```powershell
|
||||
focas-mock list-profiles
|
||||
```
|
||||
|
||||
Start the mock server with the 30i profile:
|
||||
|
||||
```powershell
|
||||
focas-mock serve --profile fwlib30i64 --host 127.0.0.1 --port 8193
|
||||
```
|
||||
|
||||
Start with a JSON patch file that overrides the default data:
|
||||
|
||||
```powershell
|
||||
focas-mock serve --profile fwlib30i64 --data examples/mock-30i.json
|
||||
```
|
||||
|
||||
## Protocol
|
||||
|
||||
The server speaks newline-delimited JSON. Each request is one JSON object per line:
|
||||
|
||||
```json
|
||||
{"id":1,"method":"cnc_allclibhndl3","params":{"ipaddr":"127.0.0.1","port":8193,"timeout":10}}
|
||||
```
|
||||
|
||||
Example response:
|
||||
|
||||
```json
|
||||
{"id":1,"method":"cnc_allclibhndl3","rc":0,"message":"EW_OK","result":{"FlibHndl":1,"profile":"fwlib30i64"}}
|
||||
```
|
||||
|
||||
Supported admin methods:
|
||||
|
||||
- `mock_get_state`
|
||||
- `mock_patch`
|
||||
- `mock_reset`
|
||||
- `mock_load_profile`
|
||||
- `mock_list_methods`
|
||||
|
||||
Example patch request:
|
||||
|
||||
```json
|
||||
{"id":2,"method":"mock_patch","params":{"state":{"parameters":{"6711":{"type":"long","value":1234,"decimal":0}}}}}
|
||||
```
|
||||
|
||||
Example test setup over TCP:
|
||||
|
||||
```json
|
||||
{"id":1,"method":"mock_load_profile","params":{"profile":"FWLIB64"}}
|
||||
{"id":2,"method":"mock_patch","params":{"state":{"pmc":{"R":{"100":{"type":"byte","value":1}}},"parameters":{"6711":{"type":"long","value":1234,"decimal":0}},"macros":{"500":{"value":42000,"decimal":3}},"statinfo":{"run":3,"aut":1,"emergency":0},"alarms":[{"alm_no":100,"type":1,"axis":0,"msg":"TEST ALARM"}]}}}
|
||||
{"id":3,"method":"cnc_allclibhndl3","params":{"ipaddr":"127.0.0.1","port":8193,"timeout":10}}
|
||||
{"id":4,"method":"pmc_rdpmcrng","params":{"FlibHndl":1,"area":"R","data_type":"byte","start":100,"end":100}}
|
||||
```
|
||||
|
||||
## Regenerating profiles
|
||||
|
||||
The built-in JSON profiles are generated from the vendored binaries:
|
||||
|
||||
```powershell
|
||||
python -m focas_mock.cli extract-profiles
|
||||
```
|
||||
|
||||
By default this reads:
|
||||
|
||||
- `vendor/fanuc-cnc-api/64bit/*.dll`
|
||||
- `upstream/fwlib.cs`
|
||||
|
||||
and writes:
|
||||
|
||||
- `src/focas_mock/builtin_profiles/*.json`
|
||||
|
||||
## Testing Direct P/Invoke Clients
|
||||
|
||||
If a client directly P/Invokes FANUC's 64-bit DLLs, point it at the shim DLLs built from `shim/` instead of the real vendor DLLs. The shim exports the small FOCAS surface used by the client and forwards calls to this Python server over JSON/TCP.
|
||||
|
||||
```powershell
|
||||
focas-mock serve --profile FWLIB64 --host 127.0.0.1 --port 8193
|
||||
.\shim\build.ps1
|
||||
$env:FOCAS_MOCK_HOST = "127.0.0.1"
|
||||
$env:FOCAS_MOCK_PORT = "8193"
|
||||
```
|
||||
|
||||
Before running the client, seed profile/state with `mock_load_profile` and `mock_patch` as shown above.
|
||||
|
||||
Detailed documentation for the supported FOCAS subset is in `docs/USED_FOCAS_API.md`.
|
||||
OtOpcUa-specific setup notes are in `docs/OTOPCUA_DOTNET_INTEGRATION.md`.
|
||||
|
||||
## Implemented mock calls
|
||||
|
||||
The server currently implements a practical subset of the surface observed in the exported DLLs and the C# wrapper:
|
||||
|
||||
- `cnc_allclibhndl`
|
||||
- `cnc_allclibhndl2`
|
||||
- `cnc_allclibhndl3`
|
||||
- `cnc_freelibhndl`
|
||||
- `cnc_sysinfo`
|
||||
- `cnc_statinfo`
|
||||
- `cnc_rddynamic2`
|
||||
- `cnc_actf`
|
||||
- `cnc_acts`
|
||||
- `cnc_acts2`
|
||||
- `cnc_getpath`
|
||||
- `cnc_setpath`
|
||||
- `cnc_rdaxisname`
|
||||
- `cnc_rdspdlname`
|
||||
- `cnc_rdparam`
|
||||
- `cnc_wrparam`
|
||||
- `cnc_rdmacro`
|
||||
- `cnc_wrmacro`
|
||||
- `cnc_rdalmmsg2`
|
||||
- `pmc_rdpmcrng`
|
||||
- `pmc_wrpmcrng`
|
||||
- `cnc_rdopmsg`
|
||||
- `cnc_rdopmode`
|
||||
- `cnc_rdprgnum`
|
||||
- `cnc_exeprgname2`
|
||||
- `cnc_rdexecprog`
|
||||
- `cnc_rdseqnum`
|
||||
- `cnc_rdblkcount`
|
||||
- `cnc_rdproginfo`
|
||||
- `cnc_rdprogdir3`
|
||||
- `cnc_rdtimer`
|
||||
- `cnc_rdspmeter`
|
||||
- `cnc_rdsvmeter`
|
||||
- `cnc_rdspload`
|
||||
- `cnc_rdspgear`
|
||||
- `cnc_rdspmaxrpm`
|
||||
- `cnc_rddiagnum`
|
||||
- `cnc_rddiaginfo`
|
||||
- `cnc_diagnoss`
|
||||
|
||||
## Limitations
|
||||
|
||||
- This is not a binary-compatible replacement for FANUC's DLLs.
|
||||
- This is not a reverse-engineered implementation of FANUC's wire protocol.
|
||||
- The per-version profiles are grounded in exported symbol tables plus the published interop wrapper, while some defaults such as axis-count hints are inferred from filename families and documented as heuristics.
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
LICENSE
|
||||
README.md
|
||||
pyproject.toml
|
||||
src/focas_mock/__init__.py
|
||||
src/focas_mock/cli.py
|
||||
src/focas_mock/constants.py
|
||||
src/focas_mock/data_store.py
|
||||
src/focas_mock/defaults.py
|
||||
src/focas_mock/export_introspection.py
|
||||
src/focas_mock/profiles.py
|
||||
src/focas_mock/server.py
|
||||
src/focas_mock.egg-info/PKG-INFO
|
||||
src/focas_mock.egg-info/SOURCES.txt
|
||||
src/focas_mock.egg-info/dependency_links.txt
|
||||
src/focas_mock.egg-info/entry_points.txt
|
||||
src/focas_mock.egg-info/requires.txt
|
||||
src/focas_mock.egg-info/top_level.txt
|
||||
src/focas_mock/builtin_profiles/FWLIB64.json
|
||||
src/focas_mock/builtin_profiles/fwlib0DN64.json
|
||||
src/focas_mock/builtin_profiles/fwlib0iD64.json
|
||||
src/focas_mock/builtin_profiles/fwlib30i64.json
|
||||
src/focas_mock/builtin_profiles/fwlibNCG64.json
|
||||
src/focas_mock/builtin_profiles/fwlibe64.json
|
||||
tests/test_profiles.py
|
||||
tests/test_server.py
|
||||
+1
@@ -0,0 +1 @@
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
[console_scripts]
|
||||
focas-mock = focas_mock.cli:main
|
||||
+1
@@ -0,0 +1 @@
|
||||
pefile>=2024.8.26
|
||||
+1
@@ -0,0 +1 @@
|
||||
focas_mock
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
from .profiles import list_profiles, load_profile
|
||||
from .server import FocasMockServer
|
||||
|
||||
__all__ = ["FocasMockServer", "list_profiles", "load_profile"]
|
||||
|
||||
+2677
File diff suppressed because it is too large
Load Diff
+1936
File diff suppressed because it is too large
Load Diff
+1936
File diff suppressed because it is too large
Load Diff
+2407
File diff suppressed because it is too large
Load Diff
+2407
File diff suppressed because it is too large
Load Diff
+1669
File diff suppressed because it is too large
Load Diff
+80
@@ -0,0 +1,80 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from .data_store import MockDataStore
|
||||
from .export_introspection import write_profiles
|
||||
from .profiles import list_profiles, load_profile
|
||||
from .server import FocasMockServer
|
||||
|
||||
|
||||
def _default_root() -> Path:
|
||||
return Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
root = _default_root()
|
||||
parser = argparse.ArgumentParser(prog="focas-mock")
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
serve = sub.add_parser("serve", help="Start the mock server.")
|
||||
serve.add_argument("--host", default="127.0.0.1")
|
||||
serve.add_argument("--port", type=int, default=8193)
|
||||
serve.add_argument("--profile", default="fwlib30i64")
|
||||
serve.add_argument("--data", help="Optional JSON patch file.")
|
||||
|
||||
sub.add_parser("list-profiles", help="List built-in profiles.")
|
||||
|
||||
dump_profile = sub.add_parser("dump-profile", help="Print one built-in profile as JSON.")
|
||||
dump_profile.add_argument("profile")
|
||||
|
||||
extract = sub.add_parser("extract-profiles", help="Regenerate JSON profiles from vendored DLLs.")
|
||||
extract.add_argument("--dll-dir", default=str(root / "vendor" / "fanuc-cnc-api" / "64bit"))
|
||||
extract.add_argument("--fwlib", default=str(root / "upstream" / "fwlib.cs"))
|
||||
extract.add_argument("--out-dir", default=str(root / "src" / "focas_mock" / "builtin_profiles"))
|
||||
return parser
|
||||
|
||||
|
||||
async def _run_server(args: argparse.Namespace) -> None:
|
||||
profile = load_profile(args.profile)
|
||||
store = MockDataStore(profile)
|
||||
if args.data:
|
||||
store.load_patch_file(args.data)
|
||||
server = FocasMockServer(args.host, args.port, profile, store)
|
||||
await server.start()
|
||||
print(f"focas-mock listening on {server.host}:{server.port} with profile {profile['profile_name']}")
|
||||
try:
|
||||
await server.serve_forever()
|
||||
finally:
|
||||
await server.close()
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> None:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.command == "list-profiles":
|
||||
for profile in list_profiles():
|
||||
print(profile)
|
||||
return
|
||||
|
||||
if args.command == "dump-profile":
|
||||
print(json.dumps(load_profile(args.profile), indent=2))
|
||||
return
|
||||
|
||||
if args.command == "extract-profiles":
|
||||
written = write_profiles(args.dll_dir, args.fwlib, args.out_dir)
|
||||
for path in written:
|
||||
print(path)
|
||||
return
|
||||
|
||||
if args.command == "serve":
|
||||
asyncio.run(_run_server(args))
|
||||
return
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
from __future__ import annotations
|
||||
|
||||
EW_PROTOCOL = -17
|
||||
EW_SOCKET = -16
|
||||
EW_NODLL = -15
|
||||
EW_BUS = -11
|
||||
EW_SYSTEM2 = -10
|
||||
EW_HSSB = -9
|
||||
EW_HANDLE = -8
|
||||
EW_VERSION = -7
|
||||
EW_UNEXP = -6
|
||||
EW_SYSTEM = -5
|
||||
EW_PARITY = -4
|
||||
EW_MMCSYS = -3
|
||||
EW_RESET = -2
|
||||
EW_BUSY = -1
|
||||
EW_OK = 0
|
||||
EW_FUNC = 1
|
||||
EW_LENGTH = 2
|
||||
EW_NUMBER = 3
|
||||
EW_ATTRIB = 4
|
||||
EW_DATA = 5
|
||||
EW_NOOPT = 6
|
||||
EW_PROT = 7
|
||||
EW_OVRFLOW = 8
|
||||
EW_PARAM = 9
|
||||
EW_BUFFER = 10
|
||||
EW_PATH = 11
|
||||
EW_MODE = 12
|
||||
EW_REJECT = 13
|
||||
EW_DTSRVR = 14
|
||||
EW_ALARM = 15
|
||||
EW_STOP = 16
|
||||
EW_PASSWD = 17
|
||||
|
||||
RC_LABELS = {
|
||||
EW_PROTOCOL: "EW_PROTOCOL",
|
||||
EW_SOCKET: "EW_SOCKET",
|
||||
EW_NODLL: "EW_NODLL",
|
||||
EW_BUS: "EW_BUS",
|
||||
EW_SYSTEM2: "EW_SYSTEM2",
|
||||
EW_HSSB: "EW_HSSB",
|
||||
EW_HANDLE: "EW_HANDLE",
|
||||
EW_VERSION: "EW_VERSION",
|
||||
EW_UNEXP: "EW_UNEXP",
|
||||
EW_SYSTEM: "EW_SYSTEM",
|
||||
EW_PARITY: "EW_PARITY",
|
||||
EW_MMCSYS: "EW_MMCSYS",
|
||||
EW_RESET: "EW_RESET",
|
||||
EW_BUSY: "EW_BUSY",
|
||||
EW_OK: "EW_OK",
|
||||
EW_FUNC: "EW_FUNC",
|
||||
EW_LENGTH: "EW_LENGTH",
|
||||
EW_NUMBER: "EW_NUMBER",
|
||||
EW_ATTRIB: "EW_ATTRIB",
|
||||
EW_DATA: "EW_DATA",
|
||||
EW_NOOPT: "EW_NOOPT",
|
||||
EW_PROT: "EW_PROT",
|
||||
EW_OVRFLOW: "EW_OVRFLOW",
|
||||
EW_PARAM: "EW_PARAM",
|
||||
EW_BUFFER: "EW_BUFFER",
|
||||
EW_PATH: "EW_PATH",
|
||||
EW_MODE: "EW_MODE",
|
||||
EW_REJECT: "EW_REJECT",
|
||||
EW_DTSRVR: "EW_DTSRVR",
|
||||
EW_ALARM: "EW_ALARM",
|
||||
EW_STOP: "EW_STOP",
|
||||
EW_PASSWD: "EW_PASSWD",
|
||||
}
|
||||
|
||||
IMPLEMENTED_FOCAS_METHODS = [
|
||||
"cnc_allclibhndl",
|
||||
"cnc_allclibhndl2",
|
||||
"cnc_allclibhndl3",
|
||||
"cnc_freelibhndl",
|
||||
"cnc_sysinfo",
|
||||
"cnc_statinfo",
|
||||
"cnc_rddynamic2",
|
||||
"cnc_actf",
|
||||
"cnc_acts",
|
||||
"cnc_acts2",
|
||||
"cnc_getpath",
|
||||
"cnc_setpath",
|
||||
"cnc_rdaxisname",
|
||||
"cnc_rdspdlname",
|
||||
"cnc_rdparam",
|
||||
"cnc_wrparam",
|
||||
"cnc_rdmacro",
|
||||
"cnc_wrmacro",
|
||||
"cnc_rdalmmsg2",
|
||||
"pmc_rdpmcrng",
|
||||
"pmc_wrpmcrng",
|
||||
"cnc_rdopmsg",
|
||||
"cnc_rdopmode",
|
||||
"cnc_rdprgnum",
|
||||
"cnc_exeprgname2",
|
||||
"cnc_rdexecprog",
|
||||
"cnc_rdseqnum",
|
||||
"cnc_rdblkcount",
|
||||
"cnc_rdproginfo",
|
||||
"cnc_rdprogdir3",
|
||||
"cnc_rdtimer",
|
||||
"cnc_rdspmeter",
|
||||
"cnc_rdsvmeter",
|
||||
"cnc_rdspload",
|
||||
"cnc_rdspgear",
|
||||
"cnc_rdspmaxrpm",
|
||||
"cnc_rddiagnum",
|
||||
"cnc_rddiaginfo",
|
||||
"cnc_diagnoss",
|
||||
]
|
||||
|
||||
ADMIN_METHODS = [
|
||||
"mock_get_state",
|
||||
"mock_patch",
|
||||
"mock_reset",
|
||||
"mock_load_profile",
|
||||
"mock_list_methods",
|
||||
"mock_schedule_alarms",
|
||||
]
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
from .defaults import make_default_state
|
||||
|
||||
|
||||
def _deep_merge(target: dict[str, Any], patch: Mapping[str, Any]) -> dict[str, Any]:
|
||||
for key, value in patch.items():
|
||||
if isinstance(value, Mapping) and isinstance(target.get(key), dict):
|
||||
_deep_merge(target[key], value)
|
||||
else:
|
||||
target[key] = deepcopy(value)
|
||||
return target
|
||||
|
||||
|
||||
class MockDataStore:
|
||||
def __init__(self, profile: Mapping[str, Any]) -> None:
|
||||
self.profile = dict(profile)
|
||||
self._defaults = make_default_state(profile)
|
||||
self._state = deepcopy(self._defaults)
|
||||
|
||||
@property
|
||||
def state(self) -> dict[str, Any]:
|
||||
return self._state
|
||||
|
||||
def snapshot(self) -> dict[str, Any]:
|
||||
return deepcopy(self._state)
|
||||
|
||||
def reset(self) -> dict[str, Any]:
|
||||
self._state = deepcopy(self._defaults)
|
||||
return self.snapshot()
|
||||
|
||||
def merge_patch(self, patch: Mapping[str, Any]) -> dict[str, Any]:
|
||||
_deep_merge(self._state, patch)
|
||||
return self.snapshot()
|
||||
|
||||
def load_patch_file(self, path: str | Path) -> dict[str, Any]:
|
||||
patch = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
return self.merge_patch(patch)
|
||||
|
||||
def consume_forced_error(self, method: str) -> tuple[int, str] | None:
|
||||
entry = self._state.get("forced_errors", {}).get(method)
|
||||
if not entry:
|
||||
return None
|
||||
if isinstance(entry, int):
|
||||
return entry, f"forced error for {method}"
|
||||
rc = int(entry.get("rc", 0))
|
||||
count = int(entry.get("count", 1))
|
||||
message = str(entry.get("message", f"forced error for {method}"))
|
||||
if count <= 1:
|
||||
self._state["forced_errors"].pop(method, None)
|
||||
else:
|
||||
entry["count"] = count - 1
|
||||
return rc, message
|
||||
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Mapping
|
||||
|
||||
|
||||
def _family_config(profile_name: str) -> tuple[str, int, int, int]:
|
||||
lowered = profile_name.lower()
|
||||
if "30i" in lowered or "ncg" in lowered:
|
||||
return ("30i", 32, 2, 4)
|
||||
if "0id" in lowered or "0dn" in lowered:
|
||||
return ("0i-D", 24, 1, 2)
|
||||
if "fwlibe64" in lowered:
|
||||
return ("e64", 8, 1, 2)
|
||||
return ("generic", 8, 1, 2)
|
||||
|
||||
|
||||
def make_default_state(profile: Mapping[str, Any]) -> dict[str, Any]:
|
||||
profile_name = str(profile.get("profile_name", "FWLIB64"))
|
||||
family, default_axes, max_path, max_spindles = _family_config(profile_name)
|
||||
max_axis = int(profile.get("max_axis_hint") or default_axes)
|
||||
axis_names = [
|
||||
"X",
|
||||
"Y",
|
||||
"Z",
|
||||
"A",
|
||||
"B",
|
||||
"C",
|
||||
"U",
|
||||
"V",
|
||||
"W",
|
||||
"P",
|
||||
"Q",
|
||||
"R",
|
||||
]
|
||||
while len(axis_names) < max_axis:
|
||||
axis_names.append(f"A{len(axis_names) + 1}")
|
||||
|
||||
spindle_names = [f"S{i}" for i in range(1, max_spindles + 1)]
|
||||
programs = [
|
||||
{"number": 1, "comment": "MAIN", "length": 128},
|
||||
{"number": 100, "comment": "TOOLCHANGE", "length": 84},
|
||||
]
|
||||
|
||||
return {
|
||||
"sysinfo": {
|
||||
"addinfo": 0,
|
||||
"max_axis": max_axis,
|
||||
"cnc_type": family[:2].ljust(2),
|
||||
"mt_type": "M ",
|
||||
"series": family[:4].ljust(4),
|
||||
"version": "A1.0",
|
||||
"axes": str(max_axis).rjust(2, "0"),
|
||||
},
|
||||
"statinfo": {
|
||||
"aut": 1,
|
||||
"run": 3,
|
||||
"motion": 1,
|
||||
"mstb": 0,
|
||||
"emergency": 0,
|
||||
"alarm": 0,
|
||||
"edit": 0,
|
||||
},
|
||||
"paths": {"current": 1, "max": max_path},
|
||||
"dynamic": {
|
||||
"alarm": 0,
|
||||
"prgnum": 1,
|
||||
"prgmnum": 1,
|
||||
"seqnum": 120,
|
||||
"actf": 1500,
|
||||
"acts": 3200,
|
||||
"axes": {
|
||||
axis_names[0]: {"absolute": 123456, "machine": 123450, "relative": 6, "distance": 0},
|
||||
axis_names[1]: {"absolute": -22000, "machine": -22010, "relative": 10, "distance": 0},
|
||||
axis_names[2]: {"absolute": 8000, "machine": 7990, "relative": 10, "distance": 0},
|
||||
},
|
||||
},
|
||||
"actf": 1500,
|
||||
"acts": 3200,
|
||||
"acts2": [3200] + [0] * (max_spindles - 1),
|
||||
"axis_names": axis_names[:max_axis],
|
||||
"spindle_names": spindle_names,
|
||||
"parameters": {
|
||||
"6711": {"type": "long", "value": 1, "decimal": 0, "description": "example parameter"},
|
||||
"6712": {"type": "long", "value": 500, "decimal": 0, "description": "example parameter"},
|
||||
},
|
||||
"pmc": {
|
||||
"R": {
|
||||
"100": {"type": "byte", "value": 0},
|
||||
"101": {"type": "byte", "value": 1},
|
||||
"102": {"type": "byte", "value": 0},
|
||||
},
|
||||
},
|
||||
"macros": {
|
||||
"100": {"value": 12345, "decimal": 3},
|
||||
"101": {"value": 98765, "decimal": 3},
|
||||
},
|
||||
"alarms": [
|
||||
{"alm_no": 0, "type": 0, "axis": 0, "msg": ""},
|
||||
],
|
||||
"operator_messages": [
|
||||
{"number": 200, "type": 0, "char_num": 12, "data": "READY"},
|
||||
],
|
||||
"program": {
|
||||
"current": 1,
|
||||
"main": 1,
|
||||
"sequence": 120,
|
||||
"block_count": 42,
|
||||
"executing": "%\nO0001\nG90 G54 G00 X0 Y0\nM30\n%",
|
||||
"executing_path": "//CNC_MEM/USER/PATH1/O0001",
|
||||
"directory": programs,
|
||||
},
|
||||
"spindle": {
|
||||
"meter": [
|
||||
{"name": spindle_names[0], "value": 56, "unit": "%"},
|
||||
{"name": spindle_names[1], "value": 0, "unit": "%"},
|
||||
],
|
||||
"servo_meter": [
|
||||
{"name": axis_names[0], "value": 14, "unit": "%"},
|
||||
{"name": axis_names[1], "value": 8, "unit": "%"},
|
||||
{"name": axis_names[2], "value": 5, "unit": "%"},
|
||||
],
|
||||
"load": [
|
||||
{"name": spindle_names[0], "load": 56, "speed": 3200},
|
||||
{"name": spindle_names[1], "load": 0, "speed": 0},
|
||||
],
|
||||
"gear": [1] * max_spindles,
|
||||
"max_rpm": [6000] * max_spindles,
|
||||
},
|
||||
"timers": {
|
||||
"power_on": 86400,
|
||||
"operating": 7200,
|
||||
"cutting": 3600,
|
||||
"cycle": 95,
|
||||
},
|
||||
"operation_mode": {"mode": 1, "name": "MEM"},
|
||||
"diagnostics": {
|
||||
"300": {"type": "long", "value": 14, "description": "servo load X"},
|
||||
"301": {"type": "long", "value": 8, "description": "servo load Y"},
|
||||
"302": {"type": "long", "value": 5, "description": "servo load Z"},
|
||||
},
|
||||
"forced_errors": {},
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .constants import IMPLEMENTED_FOCAS_METHODS
|
||||
|
||||
|
||||
def parse_fwlib_imports(fwlib_cs_path: str | Path) -> list[str]:
|
||||
text = Path(fwlib_cs_path).read_text(encoding="utf-8", errors="ignore")
|
||||
imports = re.findall(r"extern short\s+([A-Za-z0-9_]+)\s*\(", text)
|
||||
return sorted(set(imports))
|
||||
|
||||
|
||||
def extract_dll_exports(dll_path: str | Path) -> list[str]:
|
||||
import pefile
|
||||
|
||||
pe = pefile.PE(str(dll_path))
|
||||
exports = [entry.name.decode("ascii", "ignore") for entry in pe.DIRECTORY_ENTRY_EXPORT.symbols if entry.name]
|
||||
return sorted(set(exports))
|
||||
|
||||
|
||||
def _infer_metadata(dll_name: str) -> tuple[str, int, int]:
|
||||
lowered = dll_name.lower()
|
||||
if lowered == "fwlib64.dll":
|
||||
return ("generic-ethernet", 8, 1)
|
||||
if lowered == "fwlibe64.dll":
|
||||
return ("embedded-ethernet", 8, 1)
|
||||
if "30i" in lowered:
|
||||
return ("30i/31i/32i", 32, 2)
|
||||
if "ncg" in lowered:
|
||||
return ("ncguide-family", 32, 2)
|
||||
if "0id" in lowered:
|
||||
return ("0i-d-family", 24, 1)
|
||||
if "0dn" in lowered:
|
||||
return ("0-dn-family", 24, 1)
|
||||
return ("unknown", 8, 1)
|
||||
|
||||
|
||||
def build_profile(dll_path: str | Path, fwlib_cs_path: str | Path) -> dict[str, Any]:
|
||||
dll_path = Path(dll_path)
|
||||
exports = extract_dll_exports(dll_path)
|
||||
wrapper_imports = set(parse_fwlib_imports(fwlib_cs_path))
|
||||
series_hint, max_axis_hint, max_path_hint = _infer_metadata(dll_path.name)
|
||||
export_set = set(exports)
|
||||
connection_methods = sorted(symbol for symbol in exports if symbol.startswith("cnc_allclibhndl"))
|
||||
mock_methods = sorted(set(IMPLEMENTED_FOCAS_METHODS) & export_set)
|
||||
wrapper_supported = sorted(wrapper_imports & export_set)
|
||||
return {
|
||||
"profile_name": dll_path.stem,
|
||||
"dll_name": dll_path.name,
|
||||
"series_hint": series_hint,
|
||||
"max_axis_hint": max_axis_hint,
|
||||
"max_path_hint": max_path_hint,
|
||||
"export_count": len(exports),
|
||||
"connection_methods": connection_methods,
|
||||
"mock_methods": mock_methods,
|
||||
"wrapper_supported_count": len(wrapper_supported),
|
||||
"wrapper_supported_methods": wrapper_supported,
|
||||
"exports": exports,
|
||||
"notes": [
|
||||
"Exports extracted directly from the 64-bit DLL PE export directory.",
|
||||
"Wrapper-supported methods are intersected with upstream fwlib.cs extern declarations.",
|
||||
"Axis and path hints are filename-family heuristics, not protocol-level proofs.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def write_profiles(dll_dir: str | Path, fwlib_cs_path: str | Path, out_dir: str | Path) -> list[Path]:
|
||||
dll_dir = Path(dll_dir)
|
||||
out_dir = Path(out_dir)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
written: list[Path] = []
|
||||
for dll_path in sorted(dll_dir.glob("*.dll")):
|
||||
profile = build_profile(dll_path, fwlib_cs_path)
|
||||
out_path = out_dir / f"{dll_path.stem}.json"
|
||||
out_path.write_text(json.dumps(profile, indent=2), encoding="utf-8")
|
||||
written.append(out_path)
|
||||
return written
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
PROFILE_DIR = Path(__file__).resolve().parent / "builtin_profiles"
|
||||
|
||||
PROFILE_ALIASES = {
|
||||
"ZeroI_D": "fwlib0iD64",
|
||||
"ZeroI_F": "fwlib0iD64",
|
||||
"ZeroI_MF": "fwlib0iD64",
|
||||
"ZeroI_TF": "fwlib0iD64",
|
||||
"Sixteen_i": "FWLIB64",
|
||||
"Thirty_i": "fwlib30i64",
|
||||
"ThirtyOne_i": "fwlib30i64",
|
||||
"ThirtyTwo_i": "fwlib30i64",
|
||||
"PowerMotion_i": "fwlib0DN64",
|
||||
}
|
||||
|
||||
|
||||
def list_profiles() -> list[str]:
|
||||
return sorted(path.stem for path in PROFILE_DIR.glob("*.json"))
|
||||
|
||||
|
||||
def resolve_profile_name(profile_name: str) -> str:
|
||||
return PROFILE_ALIASES.get(profile_name, profile_name)
|
||||
|
||||
|
||||
def load_profile(profile_name: str) -> dict[str, Any]:
|
||||
profile_name = resolve_profile_name(profile_name)
|
||||
candidates = [
|
||||
PROFILE_DIR / f"{profile_name}.json",
|
||||
PROFILE_DIR / f"{Path(profile_name).stem}.json",
|
||||
]
|
||||
for candidate in candidates:
|
||||
if candidate.exists():
|
||||
return json.loads(candidate.read_text(encoding="utf-8"))
|
||||
available = ", ".join(list_profiles())
|
||||
raise FileNotFoundError(f"Unknown profile '{profile_name}'. Available profiles: {available}")
|
||||
+1018
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,192 @@
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Fixture for the focas-mock simulator. Probes the Docker mock at
|
||||
/// collection init; if reachable, exposes helpers that drive the mock's
|
||||
/// admin surface (<c>mock_load_profile</c>, <c>mock_patch</c>,
|
||||
/// <c>mock_reset</c>, <c>mock_schedule_alarms</c>) so tests can seed
|
||||
/// deterministic state before exercising the managed driver.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Single skip gate: <see cref="SkipReason"/> is non-null when the
|
||||
/// <c>localhost:8193</c> TCP probe fails. Tests call
|
||||
/// <c>Assert.Skip</c>.
|
||||
/// </remarks>
|
||||
public sealed class FocasSimFixture : IAsyncDisposable
|
||||
{
|
||||
private const string EndpointEnvVar = "OTOPCUA_FOCAS_SIM_ENDPOINT";
|
||||
private const string ProfileEnvVar = "OTOPCUA_FOCAS_SIM_PROFILE";
|
||||
private const string DefaultHost = "localhost";
|
||||
private const int DefaultPort = 8193;
|
||||
|
||||
public string Host { get; }
|
||||
public int Port { get; }
|
||||
|
||||
/// <summary>focas-mock profile stem the fixture should load (e.g. <c>fwlib30i64</c>,
|
||||
/// <c>ThirtyOne_i</c> — both resolve via the mock's alias table). Null when unset.</summary>
|
||||
public string? ExpectedProfile { get; }
|
||||
|
||||
/// <summary>When the <see cref="ExpectedProfile"/> maps to a concrete
|
||||
/// <see cref="FocasCncSeries"/>, this is it. Null otherwise.</summary>
|
||||
public FocasCncSeries? ExpectedSeries { get; }
|
||||
|
||||
/// <summary>Non-null when the mock probe failed — tests skip with this reason.</summary>
|
||||
public string? SkipReason { get; }
|
||||
|
||||
public FocasSimFixture()
|
||||
{
|
||||
var endpoint = Environment.GetEnvironmentVariable(EndpointEnvVar) ?? $"{DefaultHost}:{DefaultPort}";
|
||||
(Host, Port) = ParseEndpoint(endpoint);
|
||||
|
||||
ExpectedProfile = Environment.GetEnvironmentVariable(ProfileEnvVar);
|
||||
ExpectedSeries = ParseSeries(ExpectedProfile);
|
||||
|
||||
try
|
||||
{
|
||||
using var client = new TcpClient(AddressFamily.InterNetwork);
|
||||
var addresses = System.Net.Dns.GetHostAddresses(Host);
|
||||
var ip = addresses.FirstOrDefault(a => a.AddressFamily == AddressFamily.InterNetwork)
|
||||
?? System.Net.IPAddress.Loopback;
|
||||
var task = client.ConnectAsync(ip, Port);
|
||||
if (!task.Wait(TimeSpan.FromSeconds(2)) || !client.Connected)
|
||||
{
|
||||
SkipReason = $"focas-mock at {Host}:{Port} did not accept a TCP connection within 2s. " +
|
||||
$"Start it (`docker compose -f Docker/docker-compose.yml up -d`) " +
|
||||
$"or override {EndpointEnvVar}.";
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SkipReason = $"focas-mock at {Host}:{Port} unreachable: {ex.GetType().Name}: {ex.Message}. " +
|
||||
$"Start it or override {EndpointEnvVar}.";
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
// ---- Admin API helpers ----
|
||||
|
||||
/// <summary>
|
||||
/// Load a focas-mock profile. Accepts either the raw DLL-stem name
|
||||
/// (<c>fwlib30i64</c>) or the OtOpcUa-style alias (<c>ThirtyOne_i</c>);
|
||||
/// focas-mock's <c>PROFILE_ALIASES</c> resolves both.
|
||||
/// </summary>
|
||||
public Task<JsonElement> LoadProfileAsync(string profileName, CancellationToken ct = default) =>
|
||||
SendAdminAsync("mock_load_profile", new { profile = profileName }, ct);
|
||||
|
||||
/// <summary>Deep-merge <paramref name="state"/> into the mock's current state.</summary>
|
||||
public Task<JsonElement> PatchStateAsync(object state, CancellationToken ct = default) =>
|
||||
SendAdminAsync("mock_patch", new { state }, ct);
|
||||
|
||||
/// <summary>Reset the mock to the selected profile's default state.</summary>
|
||||
public Task<JsonElement> ResetAsync(CancellationToken ct = default) =>
|
||||
SendAdminAsync("mock_reset", new { }, ct);
|
||||
|
||||
/// <summary>Install a time-scheduled alarm raise / clear sequence.</summary>
|
||||
public Task<JsonElement> ScheduleAlarmsAsync(IEnumerable<object> sequence, CancellationToken ct = default) =>
|
||||
SendAdminAsync("mock_schedule_alarms", new { sequence }, ct);
|
||||
|
||||
/// <summary>Low-level JSON round-trip. One TCP connection per call — matches
|
||||
/// how the shim talks to the mock; simpler than pooling.</summary>
|
||||
public async Task<JsonElement> SendAdminAsync(string method, object @params, CancellationToken ct = default)
|
||||
{
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync(Host, Port, ct).ConfigureAwait(false);
|
||||
using var stream = client.GetStream();
|
||||
|
||||
var request = JsonSerializer.SerializeToUtf8Bytes(new
|
||||
{
|
||||
id = Interlocked.Increment(ref _nextId),
|
||||
method,
|
||||
@params,
|
||||
});
|
||||
await stream.WriteAsync(request, ct).ConfigureAwait(false);
|
||||
await stream.WriteAsync(new byte[] { (byte)'\n' }, ct).ConfigureAwait(false);
|
||||
|
||||
var buffer = new byte[65536];
|
||||
var len = 0;
|
||||
while (len < buffer.Length)
|
||||
{
|
||||
var read = await stream.ReadAsync(buffer.AsMemory(len), ct).ConfigureAwait(false);
|
||||
if (read == 0) break;
|
||||
len += read;
|
||||
// focas-mock replies with a single newline-terminated JSON object.
|
||||
if (Array.IndexOf(buffer, (byte)'\n', 0, len) >= 0) break;
|
||||
}
|
||||
var newline = Array.IndexOf(buffer, (byte)'\n', 0, len);
|
||||
var jsonLen = newline >= 0 ? newline : len;
|
||||
var text = Encoding.UTF8.GetString(buffer, 0, jsonLen);
|
||||
|
||||
using var doc = JsonDocument.Parse(text);
|
||||
var rc = doc.RootElement.GetProperty("rc").GetInt32();
|
||||
if (rc != 0)
|
||||
{
|
||||
var message = doc.RootElement.TryGetProperty("message", out var m) ? m.GetString() : "?";
|
||||
throw new InvalidOperationException($"focas-mock {method} returned rc={rc} ({message}).");
|
||||
}
|
||||
// Return the "result" subtree cloned — document is disposed on exit.
|
||||
return doc.RootElement.GetProperty("result").Clone();
|
||||
}
|
||||
|
||||
private static int _nextId;
|
||||
|
||||
// ---- Parsing ----
|
||||
|
||||
private static (string Host, int Port) ParseEndpoint(string endpoint)
|
||||
{
|
||||
const string focasScheme = "focas://";
|
||||
var body = endpoint.StartsWith(focasScheme, StringComparison.OrdinalIgnoreCase)
|
||||
? endpoint[focasScheme.Length..]
|
||||
: endpoint;
|
||||
var slash = body.IndexOf('/');
|
||||
if (slash >= 0) body = body[..slash];
|
||||
var colon = body.LastIndexOf(':');
|
||||
if (colon < 0) return (body, DefaultPort);
|
||||
var host = body[..colon];
|
||||
return int.TryParse(body[(colon + 1)..], out var p) ? (host, p) : (host, DefaultPort);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Map either a focas-mock DLL-stem profile (<c>fwlib30i64</c>) or a
|
||||
/// OtOpcUa-style alias (<c>ThirtyOne_i</c>) to the matching
|
||||
/// <see cref="FocasCncSeries"/>. Keeps tests able to assert
|
||||
/// series-gated behaviour regardless of how the profile was pinned.
|
||||
/// </summary>
|
||||
private static FocasCncSeries? ParseSeries(string? profile)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(profile)) return null;
|
||||
var trimmed = profile.Trim();
|
||||
|
||||
// Try the OtOpcUa alias set first — it's a superset of human-readable names.
|
||||
// The docker-compose profile names (thirtyone / zerod / ...) are accepted too so
|
||||
// run-focas.ps1's -Profile argument threads straight through.
|
||||
var aliasMapped = trimmed switch
|
||||
{
|
||||
"ThirtyOne_i" or "Thirty_i" or "ThirtyTwo_i"
|
||||
or "thirtyone_i" or "thirty_i" or "thirtytwo_i"
|
||||
or "thirtyone" or "thirty" or "thirtytwo"
|
||||
or "fwlib30i64" => "ThirtyOne_i",
|
||||
"Sixteen_i" or "sixteen_i" or "sixteen" or "FWLIB64" => "Sixteen_i",
|
||||
"Zero_i_D" or "Zero_i_F" or "Zero_i_MF" or "Zero_i_TF"
|
||||
or "zero_i_d" or "zero_i_f" or "zero_i_mf" or "zero_i_tf"
|
||||
or "zerod" or "zerof" or "zeromf" or "zerotf"
|
||||
or "fwlib0iD64" => "Zero_i_D",
|
||||
"PowerMotion_i" or "powermotion_i" or "powermotion"
|
||||
or "fwlib0DN64" => "PowerMotion_i",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
return aliasMapped is not null && Enum.TryParse<FocasCncSeries>(aliasMapped, out var parsed)
|
||||
? parsed : null;
|
||||
}
|
||||
}
|
||||
|
||||
[Xunit.CollectionDefinition(Name)]
|
||||
public sealed class FocasSimCollection : Xunit.ICollectionFixture<FocasSimFixture>
|
||||
{
|
||||
public const string Name = "FocasSim";
|
||||
}
|
||||
+263
@@ -0,0 +1,263 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests.Series;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end coverage for the driver capabilities that aren't part of
|
||||
/// the fixed-tree path: user-authored <c>PARAM:</c> / <c>MACRO:</c> / PMC
|
||||
/// reads, <c>DiscoverAsync</c> emission, <c>SubscribeAsync</c> +
|
||||
/// <c>OnDataChange</c>, <c>IAlarmSource</c> raise/clear, and
|
||||
/// <c>IHostConnectivityProbe</c> transitions. All via the managed
|
||||
/// <see cref="WireFocasClient"/> against the running focas-mock.
|
||||
/// </summary>
|
||||
[Collection(FocasSimCollection.Name)]
|
||||
public sealed class WireBackendCoverageTests
|
||||
{
|
||||
private readonly FocasSimFixture _fx;
|
||||
|
||||
public WireBackendCoverageTests(FocasSimFixture fx) => _fx = fx;
|
||||
|
||||
private const string DeviceHost = "focas://127.0.0.1:8193";
|
||||
|
||||
[Fact]
|
||||
public async Task User_tag_reads_route_via_wire_backend()
|
||||
{
|
||||
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
await _fx.LoadProfileAsync("FWLIB64", ct);
|
||||
await _fx.PatchStateAsync(new
|
||||
{
|
||||
parameters = new Dictionary<string, object>
|
||||
{
|
||||
["6711"] = new { type = "long", value = 1234, @decimal = 0 },
|
||||
},
|
||||
macros = new Dictionary<string, object>
|
||||
{
|
||||
["500"] = new { value = 42000, @decimal = 3 },
|
||||
},
|
||||
pmc = new { R = new Dictionary<string, object>
|
||||
{
|
||||
["100"] = new { type = "byte", value = 7 },
|
||||
}},
|
||||
}, ct);
|
||||
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(DeviceHost)],
|
||||
Tags =
|
||||
[
|
||||
new FocasTagDefinition("Param6711", DeviceHost, "PARAM:6711", FocasDataType.Int32, Writable: false),
|
||||
new FocasTagDefinition("Macro500", DeviceHost, "MACRO:500", FocasDataType.Float64, Writable: false),
|
||||
new FocasTagDefinition("R100", DeviceHost, "R100", FocasDataType.Byte, Writable: false),
|
||||
],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, driverInstanceId: "wire-usertags", clientFactory: new WireFocasClientFactory());
|
||||
|
||||
await using (drv)
|
||||
{
|
||||
await drv.InitializeAsync("{}", ct);
|
||||
var snaps = await drv.ReadAsync(["Param6711", "Macro500", "R100"], ct);
|
||||
snaps.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.Good);
|
||||
Convert.ToInt32(snaps[0].Value).ShouldBe(1234);
|
||||
Convert.ToDouble(snaps[1].Value).ShouldBe(42.0, tolerance: 0.001);
|
||||
Convert.ToInt32(snaps[2].Value).ShouldBe(7);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Discover_emits_device_folder_and_tag_variables()
|
||||
{
|
||||
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
await _fx.LoadProfileAsync("FWLIB64", ct);
|
||||
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(DeviceHost, DeviceName: "Lathe-1")],
|
||||
Tags =
|
||||
[
|
||||
new FocasTagDefinition("Run", DeviceHost, "R100", FocasDataType.Byte, Writable: false),
|
||||
new FocasTagDefinition("Speed", DeviceHost, "MACRO:500", FocasDataType.Float64, Writable: false),
|
||||
],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, driverInstanceId: "wire-discover", clientFactory: new WireFocasClientFactory());
|
||||
|
||||
await using (drv)
|
||||
{
|
||||
await drv.InitializeAsync("{}", ct);
|
||||
|
||||
var builder = new RecordingBuilder();
|
||||
await drv.DiscoverAsync(builder, ct);
|
||||
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "FOCAS");
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == DeviceHost && f.DisplayName == "Lathe-1");
|
||||
builder.Variables.ShouldContain(v => v.BrowseName == "Run");
|
||||
builder.Variables.ShouldContain(v => v.BrowseName == "Speed");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Subscribe_fires_OnDataChange_via_wire_backend()
|
||||
{
|
||||
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
await _fx.LoadProfileAsync("FWLIB64", ct);
|
||||
await _fx.PatchStateAsync(new
|
||||
{
|
||||
pmc = new { R = new Dictionary<string, object>
|
||||
{
|
||||
["100"] = new { type = "byte", value = 1 },
|
||||
}},
|
||||
}, ct);
|
||||
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(DeviceHost)],
|
||||
Tags = [new FocasTagDefinition("Run", DeviceHost, "R100", FocasDataType.Byte, Writable: false)],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, driverInstanceId: "wire-subscribe", clientFactory: new WireFocasClientFactory());
|
||||
|
||||
await using (drv)
|
||||
{
|
||||
await drv.InitializeAsync("{}", ct);
|
||||
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
||||
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
||||
|
||||
var handle = await drv.SubscribeAsync(["Run"], TimeSpan.FromMilliseconds(150), ct);
|
||||
await WaitFor(() => events.Count >= 1, TimeSpan.FromSeconds(3));
|
||||
Convert.ToInt32(events.First().Snapshot.Value).ShouldBe(1);
|
||||
|
||||
// Flip the PMC byte — next poll tick should emit a fresh OnDataChange.
|
||||
var before = events.Count;
|
||||
await _fx.PatchStateAsync(new
|
||||
{
|
||||
pmc = new { R = new Dictionary<string, object>
|
||||
{
|
||||
["100"] = new { type = "byte", value = 99 },
|
||||
}},
|
||||
}, ct);
|
||||
await WaitFor(() => events.Any(e => Convert.ToInt32(e.Snapshot.Value) == 99),
|
||||
TimeSpan.FromSeconds(3));
|
||||
|
||||
await drv.UnsubscribeAsync(handle, ct);
|
||||
events.Count.ShouldBeGreaterThan(before);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Alarm_raise_then_clear_emits_both_events_via_wire_backend()
|
||||
{
|
||||
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
await _fx.LoadProfileAsync("FWLIB64", ct);
|
||||
// Start with no active alarms.
|
||||
await _fx.PatchStateAsync(new { alarms = Array.Empty<object>() }, ct);
|
||||
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(DeviceHost)],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
AlarmProjection = new FocasAlarmProjectionOptions
|
||||
{
|
||||
Enabled = true,
|
||||
PollInterval = TimeSpan.FromMilliseconds(200),
|
||||
},
|
||||
}, driverInstanceId: "wire-alarms", clientFactory: new WireFocasClientFactory());
|
||||
|
||||
await using (drv)
|
||||
{
|
||||
await drv.InitializeAsync("{}", ct);
|
||||
var events = new List<AlarmEventArgs>();
|
||||
drv.OnAlarmEvent += (_, e) => { lock (events) events.Add(e); };
|
||||
|
||||
var sub = await drv.SubscribeAlarmsAsync([], ct);
|
||||
|
||||
// Raise one alarm.
|
||||
await _fx.PatchStateAsync(new
|
||||
{
|
||||
alarms = new[]
|
||||
{
|
||||
new { alm_no = 500, type = 2, axis = 1, msg = "TEST OVERTRAVEL" },
|
||||
},
|
||||
}, ct);
|
||||
await WaitFor(() => events.Any(e => e.Message.Contains("OVERTRAVEL")), TimeSpan.FromSeconds(5));
|
||||
|
||||
// Clear.
|
||||
await _fx.PatchStateAsync(new { alarms = Array.Empty<object>() }, ct);
|
||||
await WaitFor(() => events.Any(e => e.Message.Contains("cleared")), TimeSpan.FromSeconds(5));
|
||||
|
||||
await drv.UnsubscribeAlarmsAsync(sub, ct);
|
||||
|
||||
events.ShouldContain(e => e.AlarmType == "Overtravel" && e.Severity == AlarmSeverity.Critical);
|
||||
events.ShouldContain(e => e.Message.Contains("cleared"));
|
||||
events[0].SourceNodeId.ShouldBe(DeviceHost);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_transitions_to_Running_against_live_mock()
|
||||
{
|
||||
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(DeviceHost)],
|
||||
Probe = new FocasProbeOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Interval = TimeSpan.FromMilliseconds(150),
|
||||
Timeout = TimeSpan.FromSeconds(1),
|
||||
},
|
||||
}, driverInstanceId: "wire-probe", clientFactory: new WireFocasClientFactory());
|
||||
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
|
||||
|
||||
await using (drv)
|
||||
{
|
||||
await drv.InitializeAsync("{}", ct);
|
||||
await WaitFor(() => transitions.Any(t => t.NewState == HostState.Running), TimeSpan.FromSeconds(5));
|
||||
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Running);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WaitFor(Func<bool> pred, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (pred()) return;
|
||||
await Task.Delay(50);
|
||||
}
|
||||
}
|
||||
|
||||
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) { } }
|
||||
}
|
||||
}
|
||||
+280
@@ -0,0 +1,280 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests.Series;
|
||||
|
||||
/// <summary>
|
||||
/// Dual-run companion to <see cref="FixedTreePopulatesTests"/> — exercises the same
|
||||
/// fixed-tree scenarios through the pure-managed <see cref="WireFocasClient"/>
|
||||
/// instead of the shim/P-Invoke path. Proves both backends observe identical
|
||||
/// state against the same focas-mock instance.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Scheduled for removal in Wire migration phase 3 (task #104) once the shim is
|
||||
/// deleted — at that point only this class survives and becomes the canonical
|
||||
/// fixed-tree integration test.
|
||||
/// </remarks>
|
||||
[Collection(FocasSimCollection.Name)]
|
||||
public sealed class WireBackendTests
|
||||
{
|
||||
private readonly FocasSimFixture _fx;
|
||||
|
||||
public WireBackendTests(FocasSimFixture fx) => _fx = fx;
|
||||
|
||||
private const string DeviceHost = "focas://127.0.0.1:8193";
|
||||
|
||||
[Fact]
|
||||
public async Task Identity_axes_and_dynamic_populate_via_wire_backend()
|
||||
{
|
||||
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
await _fx.LoadProfileAsync("FWLIB64", ct);
|
||||
await _fx.PatchStateAsync(new
|
||||
{
|
||||
sysinfo = new
|
||||
{
|
||||
addinfo = 0, max_axis = 8, cnc_type = "M ", mt_type = "M ",
|
||||
series = "30i ", version = "A1.0", axes = "3 ",
|
||||
},
|
||||
axis_names = new[] { "X", "Y", "Z" },
|
||||
rddynamic2 = new
|
||||
{
|
||||
axis = 1, alarm = 0, prgnum = 1, prgmnum = 1, seqnum = 42,
|
||||
actf = 1500, acts = 3200,
|
||||
pos = new { absolute = 123456, machine = 123450, relative = 6, distance = 0 },
|
||||
},
|
||||
}, ct);
|
||||
|
||||
var driver = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(DeviceHost)],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
FixedTree = new FocasFixedTreeOptions
|
||||
{
|
||||
Enabled = true,
|
||||
PollInterval = TimeSpan.FromMilliseconds(100),
|
||||
},
|
||||
}, driverInstanceId: "focas-wire-identity", clientFactory: new WireFocasClientFactory());
|
||||
|
||||
await using (driver)
|
||||
{
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
|
||||
await WaitFor(() =>
|
||||
driver.GetDeviceState(DeviceHost) is { FixedTreeCache: not null }, TimeSpan.FromSeconds(5));
|
||||
|
||||
var state = driver.GetDeviceState(DeviceHost);
|
||||
state.ShouldNotBeNull();
|
||||
state.FixedTreeCache.ShouldNotBeNull();
|
||||
state.FixedTreeCache.SysInfo.Series.ShouldStartWith("30i");
|
||||
state.FixedTreeCache.Axes.Count.ShouldBe(3);
|
||||
state.FixedTreeCache.Axes[0].Display.ShouldBe("X");
|
||||
|
||||
await WaitFor(() =>
|
||||
state.LastFixedSnapshots.ContainsKey($"{DeviceHost}/Axes/X/AbsolutePosition"),
|
||||
TimeSpan.FromSeconds(3));
|
||||
state.LastFixedSnapshots[$"{DeviceHost}/Axes/X/AbsolutePosition"].ShouldBe(123456);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Program_and_operation_mode_populate_via_wire_backend()
|
||||
{
|
||||
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
await _fx.LoadProfileAsync("FWLIB64", ct);
|
||||
await _fx.PatchStateAsync(new
|
||||
{
|
||||
sysinfo = new
|
||||
{
|
||||
addinfo = 0, max_axis = 8, cnc_type = "M ", mt_type = "M ",
|
||||
series = "30i ", version = "A1.0", axes = "1 ",
|
||||
},
|
||||
axis_names = new[] { "X" },
|
||||
rddynamic2 = new
|
||||
{
|
||||
axis = 1, alarm = 0, prgnum = 42, prgmnum = 42, seqnum = 100,
|
||||
actf = 0, acts = 0,
|
||||
pos = new { absolute = 0, machine = 0, relative = 0, distance = 0 },
|
||||
},
|
||||
program = new
|
||||
{
|
||||
current = 42, main = 42, sequence = 100, block_count = 17,
|
||||
executing_path = "O0042.NC",
|
||||
},
|
||||
operation_mode = new { mode = 3 },
|
||||
}, ct);
|
||||
|
||||
var driver = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(DeviceHost)],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
FixedTree = new FocasFixedTreeOptions
|
||||
{
|
||||
Enabled = true,
|
||||
PollInterval = TimeSpan.FromMilliseconds(100),
|
||||
ProgramPollInterval = TimeSpan.FromMilliseconds(200),
|
||||
},
|
||||
}, driverInstanceId: "focas-wire-program", clientFactory: new WireFocasClientFactory());
|
||||
|
||||
await using (driver)
|
||||
{
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
|
||||
await WaitFor(() =>
|
||||
driver.GetDeviceState(DeviceHost) is { LastProgramInfo: not null },
|
||||
TimeSpan.FromSeconds(5));
|
||||
|
||||
var snapshots = await driver.ReadAsync(
|
||||
[$"{DeviceHost}/Program/Name",
|
||||
$"{DeviceHost}/Program/ONumber",
|
||||
$"{DeviceHost}/Program/BlockCount",
|
||||
$"{DeviceHost}/OperationMode/Mode"], ct);
|
||||
|
||||
snapshots.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.Good);
|
||||
snapshots[0].Value!.ToString().ShouldStartWith("O0042");
|
||||
Convert.ToInt32(snapshots[1].Value).ShouldBe(42);
|
||||
Convert.ToInt32(snapshots[2].Value).ShouldBe(17);
|
||||
Convert.ToInt32(snapshots[3].Value).ShouldBe(3);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Timers_populate_via_wire_backend()
|
||||
{
|
||||
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
await _fx.LoadProfileAsync("FWLIB64", ct);
|
||||
await _fx.PatchStateAsync(new
|
||||
{
|
||||
axis_names = new[] { "X" },
|
||||
timers = new
|
||||
{
|
||||
power_on = 3600,
|
||||
operating = 7200,
|
||||
cutting = 1800,
|
||||
cycle = 120,
|
||||
},
|
||||
}, ct);
|
||||
|
||||
var driver = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(DeviceHost)],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
FixedTree = new FocasFixedTreeOptions
|
||||
{
|
||||
Enabled = true,
|
||||
PollInterval = TimeSpan.FromMilliseconds(100),
|
||||
TimerPollInterval = TimeSpan.FromMilliseconds(200),
|
||||
ProgramPollInterval = TimeSpan.Zero,
|
||||
},
|
||||
}, driverInstanceId: "focas-wire-timers", clientFactory: new WireFocasClientFactory());
|
||||
|
||||
await using (driver)
|
||||
{
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
|
||||
await WaitFor(() =>
|
||||
{
|
||||
var state = driver.GetDeviceState(DeviceHost);
|
||||
return state is not null && state.LastTimers.Count == 4;
|
||||
}, TimeSpan.FromSeconds(5));
|
||||
|
||||
var snapshots = await driver.ReadAsync(
|
||||
[$"{DeviceHost}/Timers/PowerOnSeconds",
|
||||
$"{DeviceHost}/Timers/OperatingSeconds",
|
||||
$"{DeviceHost}/Timers/CuttingSeconds",
|
||||
$"{DeviceHost}/Timers/CycleSeconds"], ct);
|
||||
|
||||
snapshots.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.Good);
|
||||
Convert.ToDouble(snapshots[0].Value).ShouldBe(3600.0, tolerance: 1);
|
||||
Convert.ToDouble(snapshots[1].Value).ShouldBe(7200.0, tolerance: 1);
|
||||
Convert.ToDouble(snapshots[2].Value).ShouldBe(1800.0, tolerance: 1);
|
||||
Convert.ToDouble(snapshots[3].Value).ShouldBe(120.0, tolerance: 1);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Spindle_load_and_max_rpm_populate_via_wire_backend()
|
||||
{
|
||||
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
await _fx.LoadProfileAsync("FWLIB64", ct);
|
||||
await _fx.PatchStateAsync(new
|
||||
{
|
||||
axis_names = new[] { "X" },
|
||||
spindle_names = new[] { "S1", "S2" },
|
||||
spindle = new
|
||||
{
|
||||
load = new object[]
|
||||
{
|
||||
new { name = "S1", load = 56, speed = 3200 },
|
||||
new { name = "S2", load = 12, speed = 1800 },
|
||||
},
|
||||
max_rpm = new[] { 6000, 4500 },
|
||||
},
|
||||
rddynamic2 = new
|
||||
{
|
||||
axis = 1, alarm = 0, prgnum = 1, prgmnum = 1, seqnum = 1,
|
||||
actf = 0, acts = 0,
|
||||
pos = new { absolute = 0, machine = 0, relative = 0, distance = 0 },
|
||||
},
|
||||
}, ct);
|
||||
|
||||
var driver = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(DeviceHost)],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
FixedTree = new FocasFixedTreeOptions
|
||||
{
|
||||
Enabled = true,
|
||||
PollInterval = TimeSpan.FromMilliseconds(100),
|
||||
ProgramPollInterval = TimeSpan.Zero,
|
||||
TimerPollInterval = TimeSpan.Zero,
|
||||
},
|
||||
}, driverInstanceId: "focas-wire-spindle", clientFactory: new WireFocasClientFactory());
|
||||
|
||||
await using (driver)
|
||||
{
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
|
||||
await WaitFor(() =>
|
||||
{
|
||||
var state = driver.GetDeviceState(DeviceHost);
|
||||
return state?.FixedTreeCache is { Capabilities.SpindleLoad: true, Capabilities.SpindleMaxRpm: true }
|
||||
&& state.LastSpindleLoads.Count >= 2;
|
||||
}, TimeSpan.FromSeconds(5));
|
||||
|
||||
var snapshots = await driver.ReadAsync(
|
||||
[$"{DeviceHost}/Spindle/S1/Load",
|
||||
$"{DeviceHost}/Spindle/S1/MaxRpm",
|
||||
$"{DeviceHost}/Spindle/S2/Load",
|
||||
$"{DeviceHost}/Spindle/S2/MaxRpm"], ct);
|
||||
|
||||
snapshots.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.Good);
|
||||
Convert.ToInt32(snapshots[0].Value).ShouldBe(56);
|
||||
Convert.ToInt32(snapshots[1].Value).ShouldBe(6000);
|
||||
Convert.ToInt32(snapshots[2].Value).ShouldBe(12);
|
||||
Convert.ToInt32(snapshots[3].Value).ShouldBe(4500);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WaitFor(Func<bool> pred, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (pred()) return;
|
||||
await Task.Delay(50);
|
||||
}
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.FOCAS\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Docker/ (the Python simulator + profiles) is part of the project so the
|
||||
file tree stays discoverable, but it doesn't need to be copied to bin/;
|
||||
tests run it via docker compose, not via the test-output dir. -->
|
||||
<None Include="Docker\**\*" Pack="false" CopyToOutputDirectory="Never"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,115 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
internal class FakeFocasClient : IFocasClient
|
||||
{
|
||||
public bool IsConnected { get; private set; }
|
||||
public int ConnectCount { get; private set; }
|
||||
public int DisposeCount { get; private set; }
|
||||
public bool ThrowOnConnect { get; set; }
|
||||
public bool ThrowOnRead { get; set; }
|
||||
public bool ThrowOnWrite { get; set; }
|
||||
public bool ProbeResult { get; set; } = true;
|
||||
public Exception? Exception { get; set; }
|
||||
|
||||
public Dictionary<string, object?> Values { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public Dictionary<string, uint> ReadStatuses { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public Dictionary<string, uint> WriteStatuses { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public List<(FocasAddress addr, FocasDataType type, object? value)> WriteLog { get; } = new();
|
||||
|
||||
public virtual Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken ct)
|
||||
{
|
||||
ConnectCount++;
|
||||
if (ThrowOnConnect) throw Exception ?? new InvalidOperationException();
|
||||
IsConnected = true;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public virtual Task<(object? value, uint status)> ReadAsync(
|
||||
FocasAddress address, FocasDataType type, CancellationToken ct)
|
||||
{
|
||||
if (ThrowOnRead) throw Exception ?? new InvalidOperationException();
|
||||
var key = address.Canonical;
|
||||
var status = ReadStatuses.TryGetValue(key, out var s) ? s : FocasStatusMapper.Good;
|
||||
var value = Values.TryGetValue(key, out var v) ? v : null;
|
||||
return Task.FromResult((value, status));
|
||||
}
|
||||
|
||||
public virtual Task<uint> WriteAsync(
|
||||
FocasAddress address, FocasDataType type, object? value, CancellationToken ct)
|
||||
{
|
||||
if (ThrowOnWrite) throw Exception ?? new InvalidOperationException();
|
||||
WriteLog.Add((address, type, value));
|
||||
Values[address.Canonical] = value;
|
||||
var status = WriteStatuses.TryGetValue(address.Canonical, out var s) ? s : FocasStatusMapper.Good;
|
||||
return Task.FromResult(status);
|
||||
}
|
||||
|
||||
public virtual Task<bool> ProbeAsync(CancellationToken ct) => Task.FromResult(ProbeResult);
|
||||
|
||||
public List<FocasActiveAlarm> Alarms { get; } = [];
|
||||
|
||||
public virtual Task<IReadOnlyList<FocasActiveAlarm>> ReadAlarmsAsync(CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<FocasActiveAlarm>>([.. Alarms]);
|
||||
|
||||
// ---- Fixed-tree T1 ----
|
||||
public FocasSysInfo SysInfo { get; set; } = new(0, 3, "M", "M", "30i", "A1.0", 3);
|
||||
public List<FocasAxisName> AxisNames { get; } = [new("X", ""), new("Y", ""), new("Z", "")];
|
||||
public List<FocasSpindleName> SpindleNames { get; } = [new("S", "1", "", "")];
|
||||
public Dictionary<int, FocasDynamicSnapshot> DynamicByAxis { get; } = [];
|
||||
|
||||
public virtual Task<FocasSysInfo> GetSysInfoAsync(CancellationToken ct) => Task.FromResult(SysInfo);
|
||||
public virtual Task<IReadOnlyList<FocasAxisName>> GetAxisNamesAsync(CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<FocasAxisName>>([.. AxisNames]);
|
||||
public virtual Task<IReadOnlyList<FocasSpindleName>> GetSpindleNamesAsync(CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<FocasSpindleName>>([.. SpindleNames]);
|
||||
public virtual Task<FocasDynamicSnapshot> ReadDynamicAsync(int axisIndex, CancellationToken ct)
|
||||
{
|
||||
if (!DynamicByAxis.TryGetValue(axisIndex, out var snap))
|
||||
snap = new FocasDynamicSnapshot(axisIndex, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0);
|
||||
return Task.FromResult(snap);
|
||||
}
|
||||
|
||||
public FocasProgramInfo ProgramInfo { get; set; } = new("O0001", 1, 0, 1);
|
||||
public virtual Task<FocasProgramInfo> GetProgramInfoAsync(CancellationToken ct) =>
|
||||
Task.FromResult(ProgramInfo);
|
||||
|
||||
public Dictionary<FocasTimerKind, FocasTimer> Timers { get; } = [];
|
||||
public virtual Task<FocasTimer> GetTimerAsync(FocasTimerKind kind, CancellationToken ct)
|
||||
{
|
||||
if (!Timers.TryGetValue(kind, out var t))
|
||||
t = new FocasTimer(kind, 0, 0);
|
||||
return Task.FromResult(t);
|
||||
}
|
||||
|
||||
public List<FocasServoLoad> ServoLoads { get; } = [];
|
||||
public virtual Task<IReadOnlyList<FocasServoLoad>> GetServoLoadsAsync(CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<FocasServoLoad>>([.. ServoLoads]);
|
||||
|
||||
public List<int> SpindleLoads { get; } = [];
|
||||
public List<int> SpindleMaxRpms { get; } = [];
|
||||
public virtual Task<IReadOnlyList<int>> GetSpindleLoadsAsync(CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<int>>([.. SpindleLoads]);
|
||||
public virtual Task<IReadOnlyList<int>> GetSpindleMaxRpmsAsync(CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<int>>([.. SpindleMaxRpms]);
|
||||
|
||||
public virtual void Dispose()
|
||||
{
|
||||
DisposeCount++;
|
||||
IsConnected = false;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FakeFocasClientFactory : IFocasClientFactory
|
||||
{
|
||||
public List<FakeFocasClient> Clients { get; } = new();
|
||||
public Func<FakeFocasClient>? Customise { get; set; }
|
||||
|
||||
public IFocasClient Create()
|
||||
{
|
||||
var c = Customise?.Invoke() ?? new FakeFocasClient();
|
||||
Clients.Add(c);
|
||||
return c;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasAlarmProjectionTests
|
||||
{
|
||||
private const string Host = "focas://10.0.0.5:8193";
|
||||
|
||||
private static (FocasDriver drv, FakeFocasClientFactory factory) NewDriver(bool alarmsEnabled)
|
||||
{
|
||||
var factory = new FakeFocasClientFactory();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host)],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
AlarmProjection = new FocasAlarmProjectionOptions
|
||||
{
|
||||
Enabled = alarmsEnabled,
|
||||
PollInterval = TimeSpan.FromMilliseconds(30),
|
||||
},
|
||||
}, "drv-1", factory);
|
||||
return (drv, factory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Subscribe_without_Enable_throws_NotSupported()
|
||||
{
|
||||
var (drv, _) = NewDriver(alarmsEnabled: false);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await Should.ThrowAsync<NotSupportedException>(() =>
|
||||
drv.SubscribeAlarmsAsync([], CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Raise_then_clear_emits_both_events()
|
||||
{
|
||||
var (drv, factory) = NewDriver(alarmsEnabled: true);
|
||||
factory.Customise = () => new FakeFocasClient();
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var events = new List<AlarmEventArgs>();
|
||||
drv.OnAlarmEvent += (_, e) => { lock (events) events.Add(e); };
|
||||
|
||||
var sub = await drv.SubscribeAlarmsAsync([], CancellationToken.None);
|
||||
|
||||
// First tick creates the client via EnsureConnectedAsync — wait for it before we
|
||||
// poke the alarm list so we don't race the poll loop.
|
||||
await WaitFor(() => factory.Clients.Count > 0, TimeSpan.FromSeconds(3));
|
||||
var client = factory.Clients[0];
|
||||
client.Alarms.Add(new FocasActiveAlarm(500, FocasAlarmType.Overtravel, 1, "Axis 1 overtravel"));
|
||||
await WaitFor(() => events.Any(e => e.Message.Contains("overtravel")), TimeSpan.FromSeconds(3));
|
||||
|
||||
// Clear — the clear event wraps the original message with "(cleared)".
|
||||
client.Alarms.Clear();
|
||||
await WaitFor(() => events.Any(e => e.Message.Contains("cleared")), TimeSpan.FromSeconds(3));
|
||||
|
||||
await drv.UnsubscribeAlarmsAsync(sub, CancellationToken.None);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
|
||||
events.ShouldContain(e => e.AlarmType == "Overtravel" && e.Severity == AlarmSeverity.Critical);
|
||||
events.ShouldContain(e => e.Message.Contains("cleared"));
|
||||
events[0].SourceNodeId.ShouldBe(Host);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tick_diffs_raises_and_clears_without_polling_loop()
|
||||
{
|
||||
// Drive Tick directly so the test isn't timing-dependent. The projection's
|
||||
// Tick() is internal so we reach it through the driver using a handcrafted
|
||||
// subscription — simpler than standing up the full loop.
|
||||
var (drv, factory) = NewDriver(alarmsEnabled: true);
|
||||
factory.Customise = () => new FakeFocasClient();
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var projection = new FocasAlarmProjection(drv, TimeSpan.FromMinutes(1));
|
||||
var sub = new FocasAlarmProjection.Subscription(
|
||||
new FocasAlarmSubscriptionHandle(1), deviceFilter: null,
|
||||
new CancellationTokenSource());
|
||||
|
||||
var events = new List<AlarmEventArgs>();
|
||||
drv.OnAlarmEvent += (_, e) => events.Add(e);
|
||||
|
||||
// Tick 1 — raise two alarms.
|
||||
projection.Tick(sub, Host, [
|
||||
new FocasActiveAlarm(100, FocasAlarmType.Parameter, 0, "Param 100"),
|
||||
new FocasActiveAlarm(200, FocasAlarmType.Servo, 1, "Servo 200"),
|
||||
]);
|
||||
events.Count.ShouldBe(2);
|
||||
events[0].Severity.ShouldBe(AlarmSeverity.Medium);
|
||||
events[1].Severity.ShouldBe(AlarmSeverity.Critical);
|
||||
|
||||
// Tick 2 — same alarms stay active → no new events.
|
||||
events.Clear();
|
||||
projection.Tick(sub, Host, [
|
||||
new FocasActiveAlarm(100, FocasAlarmType.Parameter, 0, "Param 100"),
|
||||
new FocasActiveAlarm(200, FocasAlarmType.Servo, 1, "Servo 200"),
|
||||
]);
|
||||
events.ShouldBeEmpty();
|
||||
|
||||
// Tick 3 — one clears, one stays → one "cleared" event only.
|
||||
projection.Tick(sub, Host, [
|
||||
new FocasActiveAlarm(200, FocasAlarmType.Servo, 1, "Servo 200"),
|
||||
]);
|
||||
events.Count.ShouldBe(1);
|
||||
events[0].Message.ShouldEndWith("(cleared)");
|
||||
events[0].AlarmType.ShouldBe("Parameter");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Severity_mapping_matches_docs()
|
||||
{
|
||||
FocasAlarmProjection.MapSeverity(FocasAlarmType.Overtravel).ShouldBe(AlarmSeverity.Critical);
|
||||
FocasAlarmProjection.MapSeverity(FocasAlarmType.Servo).ShouldBe(AlarmSeverity.Critical);
|
||||
FocasAlarmProjection.MapSeverity(FocasAlarmType.PulseCode).ShouldBe(AlarmSeverity.Critical);
|
||||
FocasAlarmProjection.MapSeverity(FocasAlarmType.Parameter).ShouldBe(AlarmSeverity.Medium);
|
||||
FocasAlarmProjection.MapSeverity(FocasAlarmType.MacroAlarm).ShouldBe(AlarmSeverity.Medium);
|
||||
FocasAlarmProjection.MapSeverity(FocasAlarmType.Overheat).ShouldBe(AlarmSeverity.High);
|
||||
}
|
||||
|
||||
private static async Task WaitFor(Func<bool> pred, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (pred()) return;
|
||||
await Task.Delay(30);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Version-matrix coverage for <see cref="FocasCapabilityMatrix"/>. Encodes the
|
||||
/// documented Fanuc FOCAS Developer Kit support boundaries per CNC series so a
|
||||
/// config-time change that widens or narrows a range without updating
|
||||
/// <c>docs/v2/focas-version-matrix.md</c> fails a test. Every assertion cites the
|
||||
/// specific matrix row it reflects.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasCapabilityMatrixTests
|
||||
{
|
||||
// ---- Macro ranges ----
|
||||
|
||||
[Theory]
|
||||
[InlineData(FocasCncSeries.Sixteen_i, 999, true)]
|
||||
[InlineData(FocasCncSeries.Sixteen_i, 1000, false)] // above legacy ceiling
|
||||
[InlineData(FocasCncSeries.Zero_i_D, 999, true)]
|
||||
[InlineData(FocasCncSeries.Zero_i_D, 9999, false)] // 0i-D is still legacy-ceiling
|
||||
[InlineData(FocasCncSeries.Zero_i_F, 9999, true)] // widened on 0i-F
|
||||
[InlineData(FocasCncSeries.Zero_i_F, 10000, false)]
|
||||
[InlineData(FocasCncSeries.Thirty_i, 99999, true)] // highest-end
|
||||
[InlineData(FocasCncSeries.Thirty_i, 100000, false)]
|
||||
[InlineData(FocasCncSeries.PowerMotion_i, 999, true)]
|
||||
[InlineData(FocasCncSeries.PowerMotion_i, 1000, false)] // atypical coverage
|
||||
public void Macro_range_matches_series(FocasCncSeries series, int number, bool accepted)
|
||||
{
|
||||
var address = new FocasAddress(FocasAreaKind.Macro, null, number, null);
|
||||
var result = FocasCapabilityMatrix.Validate(series, address);
|
||||
(result is null).ShouldBe(accepted,
|
||||
$"Macro #{number} on {series}: expected {(accepted ? "accept" : "reject")}, got {(result ?? "accept")}");
|
||||
}
|
||||
|
||||
// ---- Parameter ranges ----
|
||||
|
||||
[Theory]
|
||||
[InlineData(FocasCncSeries.Sixteen_i, 9999, true)]
|
||||
[InlineData(FocasCncSeries.Sixteen_i, 10000, false)] // 16i capped at 9999
|
||||
[InlineData(FocasCncSeries.Zero_i_F, 14999, true)]
|
||||
[InlineData(FocasCncSeries.Zero_i_F, 15000, false)]
|
||||
[InlineData(FocasCncSeries.Thirty_i, 29999, true)]
|
||||
[InlineData(FocasCncSeries.Thirty_i, 30000, false)]
|
||||
public void Parameter_range_matches_series(FocasCncSeries series, int number, bool accepted)
|
||||
{
|
||||
var address = new FocasAddress(FocasAreaKind.Parameter, null, number, null);
|
||||
var result = FocasCapabilityMatrix.Validate(series, address);
|
||||
(result is null).ShouldBe(accepted);
|
||||
}
|
||||
|
||||
// ---- PMC letters ----
|
||||
|
||||
[Theory]
|
||||
[InlineData(FocasCncSeries.Sixteen_i, "X", true)]
|
||||
[InlineData(FocasCncSeries.Sixteen_i, "Y", true)]
|
||||
[InlineData(FocasCncSeries.Sixteen_i, "R", true)]
|
||||
[InlineData(FocasCncSeries.Sixteen_i, "F", false)] // 16i has no F/G signal groups
|
||||
[InlineData(FocasCncSeries.Sixteen_i, "G", false)]
|
||||
[InlineData(FocasCncSeries.Sixteen_i, "K", false)]
|
||||
[InlineData(FocasCncSeries.Zero_i_D, "E", true)] // widened since 0i-D
|
||||
[InlineData(FocasCncSeries.Zero_i_D, "F", false)] // still no F on 0i-D
|
||||
[InlineData(FocasCncSeries.Zero_i_F, "F", true)] // F/G added on 0i-F
|
||||
[InlineData(FocasCncSeries.Zero_i_F, "K", false)] // K/T still 30i-only
|
||||
[InlineData(FocasCncSeries.Thirty_i, "K", true)]
|
||||
[InlineData(FocasCncSeries.Thirty_i, "T", true)]
|
||||
[InlineData(FocasCncSeries.Thirty_i, "Q", false)] // unsupported even on 30i
|
||||
public void Pmc_letter_matches_series(FocasCncSeries series, string letter, bool accepted)
|
||||
{
|
||||
var address = new FocasAddress(FocasAreaKind.Pmc, letter, 0, null);
|
||||
var result = FocasCapabilityMatrix.Validate(series, address);
|
||||
(result is null).ShouldBe(accepted,
|
||||
$"PMC letter '{letter}' on {series}: expected {(accepted ? "accept" : "reject")}, got {(result ?? "accept")}");
|
||||
}
|
||||
|
||||
// ---- PMC number ceiling ----
|
||||
|
||||
[Theory]
|
||||
[InlineData(FocasCncSeries.Sixteen_i, "R", 999, true)]
|
||||
[InlineData(FocasCncSeries.Sixteen_i, "R", 1000, false)]
|
||||
[InlineData(FocasCncSeries.Zero_i_D, "R", 1999, true)]
|
||||
[InlineData(FocasCncSeries.Zero_i_D, "R", 2000, false)]
|
||||
[InlineData(FocasCncSeries.Zero_i_F, "R", 9999, true)]
|
||||
[InlineData(FocasCncSeries.Zero_i_F, "R", 10000, false)]
|
||||
[InlineData(FocasCncSeries.Thirty_i, "R", 59999, true)]
|
||||
[InlineData(FocasCncSeries.Thirty_i, "R", 60000, false)]
|
||||
public void Pmc_number_ceiling_matches_series(FocasCncSeries series, string letter, int number, bool accepted)
|
||||
{
|
||||
var address = new FocasAddress(FocasAreaKind.Pmc, letter, number, null);
|
||||
var result = FocasCapabilityMatrix.Validate(series, address);
|
||||
(result is null).ShouldBe(accepted);
|
||||
}
|
||||
|
||||
// ---- Unknown series is permissive ----
|
||||
|
||||
[Theory]
|
||||
[InlineData("Z", 999_999)] // absurd PMC address
|
||||
[InlineData("Q", 0)] // non-existent letter
|
||||
public void Unknown_series_accepts_any_PMC(string letter, int number)
|
||||
{
|
||||
var address = new FocasAddress(FocasAreaKind.Pmc, letter, number, null);
|
||||
FocasCapabilityMatrix.Validate(FocasCncSeries.Unknown, address).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_series_accepts_any_macro_number()
|
||||
{
|
||||
var address = new FocasAddress(FocasAreaKind.Macro, null, 999_999, null);
|
||||
FocasCapabilityMatrix.Validate(FocasCncSeries.Unknown, address).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_series_accepts_any_parameter_number()
|
||||
{
|
||||
var address = new FocasAddress(FocasAreaKind.Parameter, null, 999_999, null);
|
||||
FocasCapabilityMatrix.Validate(FocasCncSeries.Unknown, address).ShouldBeNull();
|
||||
}
|
||||
|
||||
// ---- Reason messages include enough context to diagnose ----
|
||||
|
||||
[Fact]
|
||||
public void Rejection_message_names_series_and_limit()
|
||||
{
|
||||
var address = new FocasAddress(FocasAreaKind.Macro, null, 100_000, null);
|
||||
var reason = FocasCapabilityMatrix.Validate(FocasCncSeries.Zero_i_F, address);
|
||||
reason.ShouldNotBeNull();
|
||||
reason.ShouldContain("100000");
|
||||
reason.ShouldContain("Zero_i_F");
|
||||
reason.ShouldContain("9999");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pmc_rejection_lists_accepted_letters()
|
||||
{
|
||||
var address = new FocasAddress(FocasAreaKind.Pmc, "Q", 0, null);
|
||||
var reason = FocasCapabilityMatrix.Validate(FocasCncSeries.Thirty_i, address);
|
||||
reason.ShouldNotBeNull();
|
||||
reason.ShouldContain("'Q'");
|
||||
reason.ShouldContain("X"); // some accepted letter should appear
|
||||
reason.ShouldContain("Y");
|
||||
}
|
||||
|
||||
// ---- PMC address letter is case-insensitive ----
|
||||
|
||||
[Theory]
|
||||
[InlineData("x")]
|
||||
[InlineData("X")]
|
||||
[InlineData("f")]
|
||||
public void Pmc_letter_match_is_case_insensitive_on_30i(string letter)
|
||||
{
|
||||
var address = new FocasAddress(FocasAreaKind.Pmc, letter, 0, null);
|
||||
FocasCapabilityMatrix.Validate(FocasCncSeries.Thirty_i, address).ShouldBeNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasCapabilityTests
|
||||
{
|
||||
// ---- ITagDiscovery ----
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_emits_pre_declared_tags()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193", DeviceName: "Lathe-1")],
|
||||
Tags =
|
||||
[
|
||||
new FocasTagDefinition("Run", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte),
|
||||
new FocasTagDefinition("Alarm", "focas://10.0.0.5:8193", "R200", FocasDataType.Byte, Writable: false),
|
||||
],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-1", new FakeFocasClientFactory());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "FOCAS");
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "focas://10.0.0.5:8193" && f.DisplayName == "Lathe-1");
|
||||
builder.Variables.Single(v => v.BrowseName == "Run").Info.SecurityClass.ShouldBe(SecurityClassification.Operate);
|
||||
builder.Variables.Single(v => v.BrowseName == "Alarm").Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
|
||||
}
|
||||
|
||||
// ---- ISubscribable ----
|
||||
|
||||
[Fact]
|
||||
public async Task Subscribe_initial_poll_raises_OnDataChange()
|
||||
{
|
||||
var factory = new FakeFocasClientFactory
|
||||
{
|
||||
Customise = () => new FakeFocasClient { Values = { ["R100"] = (sbyte)42 } },
|
||||
};
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
||||
Tags = [new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte)],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
||||
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
||||
|
||||
var handle = await drv.SubscribeAsync(["X"], TimeSpan.FromMilliseconds(200), CancellationToken.None);
|
||||
await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(2));
|
||||
|
||||
events.First().Snapshot.Value.ShouldBe((sbyte)42);
|
||||
await drv.UnsubscribeAsync(handle, CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShutdownAsync_cancels_active_subscriptions()
|
||||
{
|
||||
var factory = new FakeFocasClientFactory
|
||||
{
|
||||
Customise = () => new FakeFocasClient { Values = { ["R100"] = (sbyte)1 } },
|
||||
};
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
||||
Tags = [new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte)],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
||||
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
||||
|
||||
_ = await drv.SubscribeAsync(["X"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
|
||||
await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(1));
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
|
||||
var afterShutdown = events.Count;
|
||||
await Task.Delay(200);
|
||||
events.Count.ShouldBe(afterShutdown);
|
||||
}
|
||||
|
||||
// ---- IHostConnectivityProbe ----
|
||||
|
||||
[Fact]
|
||||
public async Task GetHostStatuses_returns_entry_per_device()
|
||||
{
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices =
|
||||
[
|
||||
new FocasDeviceOptions("focas://10.0.0.5:8193"),
|
||||
new FocasDeviceOptions("focas://10.0.0.6:8193"),
|
||||
],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-1", new FakeFocasClientFactory());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.GetHostStatuses().Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_transitions_to_Running_on_success()
|
||||
{
|
||||
var factory = new FakeFocasClientFactory
|
||||
{
|
||||
Customise = () => new FakeFocasClient { ProbeResult = true },
|
||||
};
|
||||
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
||||
Probe = new FocasProbeOptions
|
||||
{
|
||||
Enabled = true, Interval = TimeSpan.FromMilliseconds(100),
|
||||
Timeout = TimeSpan.FromMilliseconds(50),
|
||||
},
|
||||
}, "drv-1", factory);
|
||||
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await WaitForAsync(() => transitions.Any(t => t.NewState == HostState.Running), TimeSpan.FromSeconds(2));
|
||||
|
||||
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Running);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_transitions_to_Stopped_on_failure()
|
||||
{
|
||||
var factory = new FakeFocasClientFactory
|
||||
{
|
||||
Customise = () => new FakeFocasClient { ProbeResult = false },
|
||||
};
|
||||
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
||||
Probe = new FocasProbeOptions
|
||||
{
|
||||
Enabled = true, Interval = TimeSpan.FromMilliseconds(100),
|
||||
Timeout = TimeSpan.FromMilliseconds(50),
|
||||
},
|
||||
}, "drv-1", factory);
|
||||
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await WaitForAsync(() => transitions.Any(t => t.NewState == HostState.Stopped), TimeSpan.FromSeconds(2));
|
||||
|
||||
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Stopped);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
// ---- IPerCallHostResolver ----
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveHost_returns_declared_device_for_known_tag()
|
||||
{
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices =
|
||||
[
|
||||
new FocasDeviceOptions("focas://10.0.0.5:8193"),
|
||||
new FocasDeviceOptions("focas://10.0.0.6:8193"),
|
||||
],
|
||||
Tags =
|
||||
[
|
||||
new FocasTagDefinition("A", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte),
|
||||
new FocasTagDefinition("B", "focas://10.0.0.6:8193", "R100", FocasDataType.Byte),
|
||||
],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-1", new FakeFocasClientFactory());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.ResolveHost("A").ShouldBe("focas://10.0.0.5:8193");
|
||||
drv.ResolveHost("B").ShouldBe("focas://10.0.0.6:8193");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveHost_falls_back_to_first_device_for_unknown()
|
||||
{
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-1", new FakeFocasClientFactory());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.ResolveHost("missing").ShouldBe("focas://10.0.0.5:8193");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveHost_falls_back_to_DriverInstanceId_when_no_devices()
|
||||
{
|
||||
var drv = new FocasDriver(new FocasDriverOptions(), "drv-1", new FakeFocasClientFactory());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.ResolveHost("anything").ShouldBe("drv-1");
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private static async Task WaitForAsync(Func<bool> condition, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (!condition() && DateTime.UtcNow < deadline)
|
||||
await Task.Delay(20);
|
||||
}
|
||||
|
||||
private sealed class RecordingBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
|
||||
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
|
||||
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||
{ Folders.Add((browseName, displayName)); return this; }
|
||||
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
|
||||
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
|
||||
|
||||
public void AddProperty(string _, DriverDataType __, object? ___) { }
|
||||
|
||||
private sealed class Handle(string fullRef) : IVariableHandle
|
||||
{
|
||||
public string FullReference => fullRef;
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
|
||||
}
|
||||
private sealed class NullSink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasHandleRecycleTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Recycle_loop_disposes_client_on_interval_reads_reopen_fresh_one()
|
||||
{
|
||||
var factory = new FakeFocasClientFactory();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
||||
Tags = [new FocasTagDefinition("R", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte)],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
HandleRecycle = new FocasHandleRecycleOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Interval = TimeSpan.FromMilliseconds(80),
|
||||
},
|
||||
}, "drv-1", factory);
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// First read forces the initial connect.
|
||||
await drv.ReadAsync(["R"], CancellationToken.None);
|
||||
var initialClients = factory.Clients.Count;
|
||||
initialClients.ShouldBe(1);
|
||||
|
||||
// Wait for a recycle tick, then read again — a new client must have been created.
|
||||
await WaitFor(() => factory.Clients[0].DisposeCount > 0, TimeSpan.FromSeconds(3));
|
||||
await drv.ReadAsync(["R"], CancellationToken.None);
|
||||
|
||||
factory.Clients.Count.ShouldBeGreaterThan(initialClients);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Recycle_loop_stays_off_when_not_enabled()
|
||||
{
|
||||
var factory = new FakeFocasClientFactory();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
||||
Tags = [new FocasTagDefinition("R", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte)],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await drv.ReadAsync(["R"], CancellationToken.None);
|
||||
await Task.Delay(150);
|
||||
|
||||
// With recycle off the same client stays live — no Dispose during the window.
|
||||
factory.Clients.Count.ShouldBe(1);
|
||||
factory.Clients[0].DisposeCount.ShouldBe(0);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
private static async Task WaitFor(Func<bool> pred, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (pred()) return;
|
||||
await Task.Delay(20);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasPmcBitRmwTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Fake client simulating PMC byte storage + exposing it as a sbyte so RMW callers can
|
||||
/// observe the read-modify-write round-trip. ReadAsync for a Bit with bitIndex surfaces
|
||||
/// the current bit; WriteAsync stores the full byte the driver issues.
|
||||
/// </summary>
|
||||
private sealed class PmcRmwFake : FakeFocasClient
|
||||
{
|
||||
public byte[] PmcBytes { get; } = new byte[1024];
|
||||
|
||||
public override Task<(object? value, uint status)> ReadAsync(
|
||||
FocasAddress address, FocasDataType type, CancellationToken ct)
|
||||
{
|
||||
if (address.Kind == FocasAreaKind.Pmc && type == FocasDataType.Byte)
|
||||
return Task.FromResult(((object?)(sbyte)PmcBytes[address.Number], FocasStatusMapper.Good));
|
||||
if (address.Kind == FocasAreaKind.Pmc && type == FocasDataType.Bit && address.BitIndex is int bit)
|
||||
return Task.FromResult(((object?)((PmcBytes[address.Number] & (1 << bit)) != 0), FocasStatusMapper.Good));
|
||||
return base.ReadAsync(address, type, ct);
|
||||
}
|
||||
|
||||
public override Task<uint> WriteAsync(
|
||||
FocasAddress address, FocasDataType type, object? value, CancellationToken ct)
|
||||
{
|
||||
// Driver writes the full byte after RMW (type==Byte with full byte value), OR a raw
|
||||
// bit write (type==Bit, bitIndex non-null) — depending on how the driver routes it.
|
||||
if (address.Kind == FocasAreaKind.Pmc && type == FocasDataType.Byte)
|
||||
{
|
||||
PmcBytes[address.Number] = (byte)Convert.ToSByte(value);
|
||||
return Task.FromResult(FocasStatusMapper.Good);
|
||||
}
|
||||
if (address.Kind == FocasAreaKind.Pmc && type == FocasDataType.Bit && address.BitIndex is int bit)
|
||||
{
|
||||
var current = PmcBytes[address.Number];
|
||||
PmcBytes[address.Number] = Convert.ToBoolean(value)
|
||||
? (byte)(current | (1 << bit))
|
||||
: (byte)(current & ~(1 << bit));
|
||||
return Task.FromResult(FocasStatusMapper.Good);
|
||||
}
|
||||
return base.WriteAsync(address, type, value, ct);
|
||||
}
|
||||
}
|
||||
|
||||
private static (FocasDriver drv, PmcRmwFake fake) NewDriver(params FocasTagDefinition[] tags)
|
||||
{
|
||||
var fake = new PmcRmwFake();
|
||||
var factory = new FakeFocasClientFactory { Customise = () => fake };
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
||||
Tags = tags,
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
return (drv, fake);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bit_set_surfaces_as_Good_status_and_flips_bit()
|
||||
{
|
||||
var (drv, fake) = NewDriver(
|
||||
new FocasTagDefinition("Run", "focas://10.0.0.5:8193", "R100.3", FocasDataType.Bit));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
fake.PmcBytes[100] = 0b0000_0001;
|
||||
|
||||
var results = await drv.WriteAsync([new WriteRequest("Run", true)], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
fake.PmcBytes[100].ShouldBe((byte)0b0000_1001);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bit_clear_preserves_other_bits()
|
||||
{
|
||||
var (drv, fake) = NewDriver(
|
||||
new FocasTagDefinition("Flag", "focas://10.0.0.5:8193", "R100.3", FocasDataType.Bit));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
fake.PmcBytes[100] = 0xFF;
|
||||
|
||||
await drv.WriteAsync([new WriteRequest("Flag", false)], CancellationToken.None);
|
||||
|
||||
fake.PmcBytes[100].ShouldBe((byte)0b1111_0111);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Subsequent_bit_sets_in_same_byte_compose_correctly()
|
||||
{
|
||||
var tags = Enumerable.Range(0, 8)
|
||||
.Select(b => new FocasTagDefinition($"Bit{b}", "focas://10.0.0.5:8193", $"R100.{b}", FocasDataType.Bit))
|
||||
.ToArray();
|
||||
var (drv, fake) = NewDriver(tags);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
fake.PmcBytes[100] = 0;
|
||||
|
||||
for (var b = 0; b < 8; b++)
|
||||
await drv.WriteAsync([new WriteRequest($"Bit{b}", true)], CancellationToken.None);
|
||||
|
||||
fake.PmcBytes[100].ShouldBe((byte)0xFF);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bit_write_to_different_bytes_does_not_contend()
|
||||
{
|
||||
var tags = Enumerable.Range(0, 4)
|
||||
.Select(i => new FocasTagDefinition($"Bit{i}", "focas://10.0.0.5:8193", $"R{50 + i}.0", FocasDataType.Bit))
|
||||
.ToArray();
|
||||
var (drv, fake) = NewDriver(tags);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await Task.WhenAll(Enumerable.Range(0, 4).Select(i =>
|
||||
drv.WriteAsync([new WriteRequest($"Bit{i}", true)], CancellationToken.None)));
|
||||
|
||||
for (var i = 0; i < 4; i++)
|
||||
fake.PmcBytes[50 + i].ShouldBe((byte)0x01);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasReadWriteTests
|
||||
{
|
||||
private static (FocasDriver drv, FakeFocasClientFactory factory) NewDriver(params FocasTagDefinition[] tags)
|
||||
{
|
||||
var factory = new FakeFocasClientFactory();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
||||
Tags = tags,
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
return (drv, factory);
|
||||
}
|
||||
|
||||
// ---- Read ----
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_reference_maps_to_BadNodeIdUnknown()
|
||||
{
|
||||
var (drv, _) = NewDriver();
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var snapshots = await drv.ReadAsync(["missing"], CancellationToken.None);
|
||||
snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Successful_PMC_read_returns_Good_value()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new FocasTagDefinition("Run", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () => new FakeFocasClient { Values = { ["R100"] = (sbyte)5 } };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Run"], CancellationToken.None);
|
||||
snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
snapshots.Single().Value.ShouldBe((sbyte)5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Parameter_read_routes_through_FocasAddress_Parameter_kind()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new FocasTagDefinition("Accel", "focas://10.0.0.5:8193", "PARAM:1820", FocasDataType.Int32));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () => new FakeFocasClient { Values = { ["PARAM:1820"] = 1500 } };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Accel"], CancellationToken.None);
|
||||
snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
snapshots.Single().Value.ShouldBe(1500);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Macro_read_routes_through_FocasAddress_Macro_kind()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new FocasTagDefinition("CustomVar", "focas://10.0.0.5:8193", "MACRO:500", FocasDataType.Float64));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () => new FakeFocasClient { Values = { ["MACRO:500"] = 3.14159 } };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["CustomVar"], CancellationToken.None);
|
||||
snapshots.Single().Value.ShouldBe(3.14159);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Repeat_read_reuses_connection()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () => new FakeFocasClient { Values = { ["R100"] = (sbyte)1 } };
|
||||
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
|
||||
factory.Clients.Count.ShouldBe(1);
|
||||
factory.Clients[0].ConnectCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FOCAS_error_status_maps_via_status_mapper()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new FocasTagDefinition("Ghost", "focas://10.0.0.5:8193", "R999", FocasDataType.Byte));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () =>
|
||||
{
|
||||
var c = new FakeFocasClient();
|
||||
c.ReadStatuses["R999"] = FocasStatusMapper.BadNodeIdUnknown;
|
||||
return c;
|
||||
};
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Ghost"], CancellationToken.None);
|
||||
snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_exception_surfaces_BadCommunicationError()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () => new FakeFocasClient { ThrowOnRead = true };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Connect_failure_disposes_client_and_surfaces_BadCommunicationError()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () => new FakeFocasClient { ThrowOnConnect = true };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
|
||||
factory.Clients[0].DisposeCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Batched_reads_preserve_order_across_areas()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new FocasTagDefinition("A", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte),
|
||||
new FocasTagDefinition("B", "focas://10.0.0.5:8193", "PARAM:1820", FocasDataType.Int32),
|
||||
new FocasTagDefinition("C", "focas://10.0.0.5:8193", "MACRO:500", FocasDataType.Float64));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () => new FakeFocasClient
|
||||
{
|
||||
Values =
|
||||
{
|
||||
["R100"] = (sbyte)5,
|
||||
["PARAM:1820"] = 1500,
|
||||
["MACRO:500"] = 2.718,
|
||||
},
|
||||
};
|
||||
|
||||
var snapshots = await drv.ReadAsync(["A", "B", "C"], CancellationToken.None);
|
||||
snapshots[0].Value.ShouldBe((sbyte)5);
|
||||
snapshots[1].Value.ShouldBe(1500);
|
||||
snapshots[2].Value.ShouldBe(2.718);
|
||||
}
|
||||
|
||||
// ---- Write ----
|
||||
|
||||
[Fact]
|
||||
public async Task Non_writable_tag_rejected_with_BadNotWritable()
|
||||
{
|
||||
var (drv, _) = NewDriver(
|
||||
new FocasTagDefinition("RO", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte, Writable: false));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("RO", 1)], CancellationToken.None);
|
||||
results.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNotWritable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Successful_write_logs_address_type_value()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new FocasTagDefinition("Speed", "focas://10.0.0.5:8193", "R100", FocasDataType.Int16));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("Speed", (short)1800)], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
var write = factory.Clients[0].WriteLog.Single();
|
||||
write.addr.Canonical.ShouldBe("R100");
|
||||
write.type.ShouldBe(FocasDataType.Int16);
|
||||
write.value.ShouldBe((short)1800);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Write_status_code_maps_via_FocasStatusMapper()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new FocasTagDefinition("Protected", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () =>
|
||||
{
|
||||
var c = new FakeFocasClient();
|
||||
c.WriteStatuses["R100"] = FocasStatusMapper.BadNotWritable;
|
||||
return c;
|
||||
};
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("Protected", (sbyte)1)], CancellationToken.None);
|
||||
results.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNotWritable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Batch_write_preserves_order_across_outcomes()
|
||||
{
|
||||
var factory = new FakeFocasClientFactory();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
||||
Tags =
|
||||
[
|
||||
new FocasTagDefinition("A", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte),
|
||||
new FocasTagDefinition("B", "focas://10.0.0.5:8193", "R101", FocasDataType.Byte, Writable: false),
|
||||
],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[
|
||||
new WriteRequest("A", (sbyte)1),
|
||||
new WriteRequest("B", (sbyte)2),
|
||||
new WriteRequest("Unknown", (sbyte)3),
|
||||
], CancellationToken.None);
|
||||
|
||||
results[0].StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
results[1].StatusCode.ShouldBe(FocasStatusMapper.BadNotWritable);
|
||||
results[2].StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cancellation_propagates()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () => new FakeFocasClient
|
||||
{
|
||||
ThrowOnRead = true,
|
||||
Exception = new OperationCanceledException(),
|
||||
};
|
||||
|
||||
await Should.ThrowAsync<OperationCanceledException>(
|
||||
() => drv.ReadAsync(["X"], CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShutdownAsync_disposes_client()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () => new FakeFocasClient { Values = { ["R100"] = (sbyte)1 } };
|
||||
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
|
||||
factory.Clients[0].DisposeCount.ShouldBe(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasScaffoldingTests
|
||||
{
|
||||
// ---- FocasHostAddress ----
|
||||
|
||||
[Theory]
|
||||
[InlineData("focas://10.0.0.5:8193", "10.0.0.5", 8193)]
|
||||
[InlineData("focas://10.0.0.5", "10.0.0.5", 8193)] // default port
|
||||
[InlineData("focas://cnc-01.factory.internal:8193", "cnc-01.factory.internal", 8193)]
|
||||
[InlineData("focas://10.0.0.5:12345", "10.0.0.5", 12345)]
|
||||
[InlineData("FOCAS://10.0.0.5:8193", "10.0.0.5", 8193)] // case-insensitive scheme
|
||||
public void HostAddress_parses_valid(string input, string host, int port)
|
||||
{
|
||||
var parsed = FocasHostAddress.TryParse(input);
|
||||
parsed.ShouldNotBeNull();
|
||||
parsed.Host.ShouldBe(host);
|
||||
parsed.Port.ShouldBe(port);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("http://10.0.0.5/")]
|
||||
[InlineData("focas:10.0.0.5:8193")] // missing //
|
||||
[InlineData("focas://")] // empty body
|
||||
[InlineData("focas://10.0.0.5:0")] // port 0
|
||||
[InlineData("focas://10.0.0.5:65536")] // port out of range
|
||||
[InlineData("focas://10.0.0.5:abc")] // non-numeric port
|
||||
public void HostAddress_rejects_invalid(string? input)
|
||||
{
|
||||
FocasHostAddress.TryParse(input).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HostAddress_ToString_strips_default_port()
|
||||
{
|
||||
new FocasHostAddress("10.0.0.5", 8193).ToString().ShouldBe("focas://10.0.0.5");
|
||||
new FocasHostAddress("10.0.0.5", 12345).ToString().ShouldBe("focas://10.0.0.5:12345");
|
||||
}
|
||||
|
||||
// ---- FocasAddress ----
|
||||
|
||||
[Theory]
|
||||
[InlineData("X0.0", FocasAreaKind.Pmc, "X", 0, 0)]
|
||||
[InlineData("X0", FocasAreaKind.Pmc, "X", 0, null)]
|
||||
[InlineData("Y10", FocasAreaKind.Pmc, "Y", 10, null)]
|
||||
[InlineData("F20.3", FocasAreaKind.Pmc, "F", 20, 3)]
|
||||
[InlineData("G54", FocasAreaKind.Pmc, "G", 54, null)]
|
||||
[InlineData("R100", FocasAreaKind.Pmc, "R", 100, null)]
|
||||
[InlineData("D200", FocasAreaKind.Pmc, "D", 200, null)]
|
||||
[InlineData("C300", FocasAreaKind.Pmc, "C", 300, null)]
|
||||
[InlineData("K400", FocasAreaKind.Pmc, "K", 400, null)]
|
||||
[InlineData("A500", FocasAreaKind.Pmc, "A", 500, null)]
|
||||
[InlineData("E600", FocasAreaKind.Pmc, "E", 600, null)]
|
||||
[InlineData("T50.4", FocasAreaKind.Pmc, "T", 50, 4)]
|
||||
public void Address_parses_PMC_forms(string input, FocasAreaKind kind, string letter, int num, int? bit)
|
||||
{
|
||||
var a = FocasAddress.TryParse(input);
|
||||
a.ShouldNotBeNull();
|
||||
a.Kind.ShouldBe(kind);
|
||||
a.PmcLetter.ShouldBe(letter);
|
||||
a.Number.ShouldBe(num);
|
||||
a.BitIndex.ShouldBe(bit);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("PARAM:1020", FocasAreaKind.Parameter, 1020, null)]
|
||||
[InlineData("PARAM:1815/0", FocasAreaKind.Parameter, 1815, 0)]
|
||||
[InlineData("PARAM:1815/31", FocasAreaKind.Parameter, 1815, 31)]
|
||||
public void Address_parses_parameter_forms(string input, FocasAreaKind kind, int num, int? bit)
|
||||
{
|
||||
var a = FocasAddress.TryParse(input);
|
||||
a.ShouldNotBeNull();
|
||||
a.Kind.ShouldBe(kind);
|
||||
a.PmcLetter.ShouldBeNull();
|
||||
a.Number.ShouldBe(num);
|
||||
a.BitIndex.ShouldBe(bit);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("MACRO:100", FocasAreaKind.Macro, 100)]
|
||||
[InlineData("MACRO:500", FocasAreaKind.Macro, 500)]
|
||||
public void Address_parses_macro_forms(string input, FocasAreaKind kind, int num)
|
||||
{
|
||||
var a = FocasAddress.TryParse(input);
|
||||
a.ShouldNotBeNull();
|
||||
a.Kind.ShouldBe(kind);
|
||||
a.Number.ShouldBe(num);
|
||||
a.BitIndex.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("Z0")] // unknown PMC letter
|
||||
[InlineData("X")] // missing number
|
||||
[InlineData("X-1")] // negative number
|
||||
[InlineData("Xabc")] // non-numeric
|
||||
[InlineData("X0.8")] // bit out of range (0-7)
|
||||
[InlineData("X0.-1")] // negative bit
|
||||
[InlineData("PARAM:")] // missing number
|
||||
[InlineData("PARAM:1815/32")] // bit out of range (0-31)
|
||||
[InlineData("MACRO:abc")] // non-numeric
|
||||
public void Address_rejects_invalid_forms(string? input)
|
||||
{
|
||||
FocasAddress.TryParse(input).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("X0.0")]
|
||||
[InlineData("R100")]
|
||||
[InlineData("F20.3")]
|
||||
[InlineData("PARAM:1020")]
|
||||
[InlineData("PARAM:1815/0")]
|
||||
[InlineData("MACRO:100")]
|
||||
public void Address_Canonical_roundtrips(string input)
|
||||
{
|
||||
var parsed = FocasAddress.TryParse(input);
|
||||
parsed.ShouldNotBeNull();
|
||||
parsed.Canonical.ShouldBe(input);
|
||||
}
|
||||
|
||||
// ---- FocasDataType ----
|
||||
|
||||
[Fact]
|
||||
public void DataType_mapping_covers_atomic_focas_types()
|
||||
{
|
||||
FocasDataType.Bit.ToDriverDataType().ShouldBe(DriverDataType.Boolean);
|
||||
FocasDataType.Int16.ToDriverDataType().ShouldBe(DriverDataType.Int32);
|
||||
FocasDataType.Int32.ToDriverDataType().ShouldBe(DriverDataType.Int32);
|
||||
FocasDataType.Float32.ToDriverDataType().ShouldBe(DriverDataType.Float32);
|
||||
FocasDataType.Float64.ToDriverDataType().ShouldBe(DriverDataType.Float64);
|
||||
FocasDataType.String.ToDriverDataType().ShouldBe(DriverDataType.String);
|
||||
}
|
||||
|
||||
// ---- FocasStatusMapper ----
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, FocasStatusMapper.Good)]
|
||||
[InlineData(3, FocasStatusMapper.BadOutOfRange)] // EW_NUMBER
|
||||
[InlineData(4, FocasStatusMapper.BadOutOfRange)] // EW_LENGTH
|
||||
[InlineData(5, FocasStatusMapper.BadNotWritable)] // EW_PROT
|
||||
[InlineData(6, FocasStatusMapper.BadNotSupported)] // EW_NOOPT
|
||||
[InlineData(8, FocasStatusMapper.BadNodeIdUnknown)] // EW_DATA
|
||||
[InlineData(-1, FocasStatusMapper.BadDeviceFailure)] // EW_BUSY
|
||||
[InlineData(-8, FocasStatusMapper.BadInternalError)] // EW_HANDLE
|
||||
[InlineData(-16, FocasStatusMapper.BadCommunicationError)] // EW_SOCKET
|
||||
[InlineData(999, FocasStatusMapper.BadCommunicationError)] // unknown → generic
|
||||
public void StatusMapper_covers_known_focas_returns(int ret, uint expected)
|
||||
{
|
||||
FocasStatusMapper.MapFocasReturn(ret).ShouldBe(expected);
|
||||
}
|
||||
|
||||
// ---- FocasDriver ----
|
||||
|
||||
[Fact]
|
||||
public void DriverType_is_FOCAS()
|
||||
{
|
||||
var drv = new FocasDriver(new FocasDriverOptions(), "drv-1");
|
||||
drv.DriverType.ShouldBe("FOCAS");
|
||||
drv.DriverInstanceId.ShouldBe("drv-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InitializeAsync_parses_device_addresses()
|
||||
{
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices =
|
||||
[
|
||||
new FocasDeviceOptions("focas://10.0.0.5:8193"),
|
||||
new FocasDeviceOptions("focas://10.0.0.6:12345", DeviceName: "CNC-2"),
|
||||
],
|
||||
}, "drv-1");
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.DeviceCount.ShouldBe(2);
|
||||
drv.GetDeviceState("focas://10.0.0.5:8193")!.ParsedAddress.Port.ShouldBe(8193);
|
||||
drv.GetDeviceState("focas://10.0.0.6:12345")!.Options.DeviceName.ShouldBe("CNC-2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InitializeAsync_malformed_address_faults()
|
||||
{
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions("not-an-address")],
|
||||
}, "drv-1");
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(
|
||||
() => drv.InitializeAsync("{}", CancellationToken.None));
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShutdownAsync_clears_devices()
|
||||
{
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
drv.DeviceCount.ShouldBe(0);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
|
||||
}
|
||||
|
||||
// ---- UnimplementedFocasClientFactory ----
|
||||
|
||||
[Fact]
|
||||
public void Unimplemented_factory_throws_on_Create_with_config_pointer()
|
||||
{
|
||||
var factory = new UnimplementedFocasClientFactory();
|
||||
var ex = Should.Throw<NotSupportedException>(() => factory.Create());
|
||||
ex.Message.ShouldContain("wire");
|
||||
ex.Message.ShouldContain("docs/drivers/FOCAS.md");
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.FOCAS\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,281 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading.Channels;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using MxGateway.Contracts.Proto.Galaxy;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Browse;
|
||||
|
||||
/// <summary>
|
||||
/// Tests <see cref="DeployWatcher"/>'s consumption of <see cref="IGalaxyDeployWatchSource"/>:
|
||||
/// bootstrap suppression, change detection, presence-flip handling, clean shutdown,
|
||||
/// and reconnect-on-error backoff.
|
||||
/// </summary>
|
||||
public sealed class DeployWatcherTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Test helper exposing a <see cref="Channel{T}"/> as the event source plus an
|
||||
/// optional fault hook so reconnect / retry paths can be exercised deterministically.
|
||||
/// </summary>
|
||||
private sealed class FakeDeployWatchSource : IGalaxyDeployWatchSource
|
||||
{
|
||||
private readonly Func<int, Channel<DeployEvent>> _channelFactory;
|
||||
public List<DateTimeOffset?> LastSeenTimes { get; } = [];
|
||||
public int CallCount { get; private set; }
|
||||
public Func<int, Exception?>? ThrowOnIteration { get; init; }
|
||||
|
||||
public FakeDeployWatchSource(Channel<DeployEvent> channel)
|
||||
{
|
||||
_channelFactory = _ => channel;
|
||||
}
|
||||
|
||||
public FakeDeployWatchSource(Func<int, Channel<DeployEvent>> channelFactory)
|
||||
{
|
||||
_channelFactory = channelFactory;
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<DeployEvent> WatchAsync(
|
||||
DateTimeOffset? lastSeenDeployTime,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
int iteration = ++CallCount;
|
||||
LastSeenTimes.Add(lastSeenDeployTime);
|
||||
|
||||
if (ThrowOnIteration?.Invoke(iteration) is { } ex)
|
||||
{
|
||||
throw ex;
|
||||
}
|
||||
|
||||
var channel = _channelFactory(iteration);
|
||||
await foreach (var ev in channel.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
yield return ev;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static DeployEvent Event(ulong sequence, DateTimeOffset? deployTime)
|
||||
{
|
||||
var ev = new DeployEvent
|
||||
{
|
||||
Sequence = sequence,
|
||||
ObservedAt = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow),
|
||||
TimeOfLastDeployPresent = deployTime is not null,
|
||||
};
|
||||
if (deployTime is { } t)
|
||||
{
|
||||
ev.TimeOfLastDeploy = Timestamp.FromDateTimeOffset(t);
|
||||
}
|
||||
return ev;
|
||||
}
|
||||
|
||||
private static List<RediscoveryEventArgs> CaptureRediscoverEvents(DeployWatcher watcher)
|
||||
{
|
||||
var captured = new List<RediscoveryEventArgs>();
|
||||
watcher.OnRediscoveryNeeded += (_, args) =>
|
||||
{
|
||||
lock (captured) captured.Add(args);
|
||||
};
|
||||
return captured;
|
||||
}
|
||||
|
||||
private static async Task WaitUntilAsync(Func<bool> condition, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTimeOffset.UtcNow + timeout;
|
||||
while (DateTimeOffset.UtcNow < deadline)
|
||||
{
|
||||
if (condition()) return;
|
||||
await Task.Delay(10).ConfigureAwait(false);
|
||||
}
|
||||
throw new TimeoutException("Condition was not met within timeout.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BootstrapEventIsSuppressed()
|
||||
{
|
||||
var channel = Channel.CreateUnbounded<DeployEvent>();
|
||||
var source = new FakeDeployWatchSource(channel);
|
||||
using var watcher = new DeployWatcher(source);
|
||||
var captured = CaptureRediscoverEvents(watcher);
|
||||
|
||||
await watcher.StartAsync(CancellationToken.None);
|
||||
|
||||
// Push only the bootstrap event.
|
||||
await channel.Writer.WriteAsync(Event(0, DateTimeOffset.Parse("2026-01-01T00:00:00Z")));
|
||||
|
||||
// Give the loop a moment to consume + ack.
|
||||
await WaitUntilAsync(() => source.CallCount > 0 && channel.Reader.Count == 0, TimeSpan.FromSeconds(2));
|
||||
await Task.Delay(50);
|
||||
|
||||
captured.ShouldBeEmpty();
|
||||
|
||||
await watcher.StopAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeployTimeChangeFiresRediscover()
|
||||
{
|
||||
var t0 = DateTimeOffset.Parse("2026-01-01T00:00:00Z");
|
||||
var t1 = DateTimeOffset.Parse("2026-01-02T12:00:00Z");
|
||||
|
||||
var channel = Channel.CreateUnbounded<DeployEvent>();
|
||||
var source = new FakeDeployWatchSource(channel);
|
||||
using var watcher = new DeployWatcher(source);
|
||||
var captured = CaptureRediscoverEvents(watcher);
|
||||
|
||||
await watcher.StartAsync(CancellationToken.None);
|
||||
|
||||
await channel.Writer.WriteAsync(Event(0, t0)); // bootstrap
|
||||
await channel.Writer.WriteAsync(Event(1, t1)); // real change
|
||||
|
||||
await WaitUntilAsync(() => captured.Count >= 1, TimeSpan.FromSeconds(2));
|
||||
|
||||
captured.Count.ShouldBe(1);
|
||||
captured[0].Reason.ShouldBe("deploy-time-changed");
|
||||
captured[0].ScopeHint.ShouldNotBeNull();
|
||||
DateTimeOffset.Parse(captured[0].ScopeHint!).ToUniversalTime()
|
||||
.ShouldBe(t1.ToUniversalTime());
|
||||
|
||||
await watcher.StopAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SameDeployTimeDoesNotFire()
|
||||
{
|
||||
var t0 = DateTimeOffset.Parse("2026-01-01T00:00:00Z");
|
||||
|
||||
var channel = Channel.CreateUnbounded<DeployEvent>();
|
||||
var source = new FakeDeployWatchSource(channel);
|
||||
using var watcher = new DeployWatcher(source);
|
||||
var captured = CaptureRediscoverEvents(watcher);
|
||||
|
||||
await watcher.StartAsync(CancellationToken.None);
|
||||
|
||||
await channel.Writer.WriteAsync(Event(0, t0)); // bootstrap
|
||||
await channel.Writer.WriteAsync(Event(2, t0)); // duplicate state — gateway re-sent
|
||||
|
||||
await WaitUntilAsync(() => channel.Reader.Count == 0, TimeSpan.FromSeconds(2));
|
||||
await Task.Delay(50);
|
||||
|
||||
captured.ShouldBeEmpty();
|
||||
|
||||
await watcher.StopAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TimeOfLastDeployPresentFlipFiresRediscover()
|
||||
{
|
||||
var t1 = DateTimeOffset.Parse("2026-03-01T08:00:00Z");
|
||||
|
||||
var channel = Channel.CreateUnbounded<DeployEvent>();
|
||||
var source = new FakeDeployWatchSource(channel);
|
||||
using var watcher = new DeployWatcher(source);
|
||||
var captured = CaptureRediscoverEvents(watcher);
|
||||
|
||||
await watcher.StartAsync(CancellationToken.None);
|
||||
|
||||
// Bootstrap with absent deploy time (Galaxy never deployed).
|
||||
await channel.Writer.WriteAsync(Event(0, deployTime: null));
|
||||
// Now a deploy lands and the present flag flips.
|
||||
await channel.Writer.WriteAsync(Event(1, t1));
|
||||
|
||||
await WaitUntilAsync(() => captured.Count >= 1, TimeSpan.FromSeconds(2));
|
||||
|
||||
captured.Count.ShouldBe(1);
|
||||
captured[0].Reason.ShouldBe("deploy-time-changed");
|
||||
captured[0].ScopeHint.ShouldNotBeNull();
|
||||
|
||||
await watcher.StopAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StopCancelsLoopCleanly()
|
||||
{
|
||||
var channel = Channel.CreateUnbounded<DeployEvent>();
|
||||
var source = new FakeDeployWatchSource(channel);
|
||||
using var watcher = new DeployWatcher(source);
|
||||
|
||||
await watcher.StartAsync(CancellationToken.None);
|
||||
|
||||
// Push bootstrap so the loop enters its enumeration body before stop.
|
||||
await channel.Writer.WriteAsync(Event(0, DateTimeOffset.UtcNow));
|
||||
await WaitUntilAsync(() => source.CallCount > 0, TimeSpan.FromSeconds(2));
|
||||
|
||||
// StopAsync should complete without throwing and within a reasonable window.
|
||||
var stopTask = watcher.StopAsync();
|
||||
var completed = await Task.WhenAny(stopTask, Task.Delay(TimeSpan.FromSeconds(5)));
|
||||
completed.ShouldBe(stopTask);
|
||||
await stopTask; // observe (no) exception
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisposeStopsRunningWatcher()
|
||||
{
|
||||
var channel = Channel.CreateUnbounded<DeployEvent>();
|
||||
var source = new FakeDeployWatchSource(channel);
|
||||
var watcher = new DeployWatcher(source);
|
||||
|
||||
await watcher.StartAsync(CancellationToken.None);
|
||||
await channel.Writer.WriteAsync(Event(0, DateTimeOffset.UtcNow));
|
||||
await WaitUntilAsync(() => source.CallCount > 0, TimeSpan.FromSeconds(2));
|
||||
|
||||
// Should not throw, should not hang.
|
||||
var disposeTask = Task.Run(watcher.Dispose);
|
||||
var completed = await Task.WhenAny(disposeTask, Task.Delay(TimeSpan.FromSeconds(5)));
|
||||
completed.ShouldBe(disposeTask);
|
||||
await disposeTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SourceExceptionTriggersRetryWithBackoff()
|
||||
{
|
||||
var t0 = DateTimeOffset.Parse("2026-04-01T00:00:00Z");
|
||||
var t1 = DateTimeOffset.Parse("2026-04-02T00:00:00Z");
|
||||
|
||||
var firstChannel = Channel.CreateUnbounded<DeployEvent>();
|
||||
var secondChannel = Channel.CreateUnbounded<DeployEvent>();
|
||||
|
||||
var source = new FakeDeployWatchSource(iteration => iteration switch
|
||||
{
|
||||
1 => firstChannel,
|
||||
_ => secondChannel,
|
||||
})
|
||||
{
|
||||
ThrowOnIteration = i => i == 1 ? new InvalidOperationException("transport drop") : null,
|
||||
};
|
||||
|
||||
// Tiny backoff so the test doesn't sit in Task.Delay.
|
||||
using var watcher = new DeployWatcher(
|
||||
source,
|
||||
logger: null,
|
||||
initialBackoff: TimeSpan.FromMilliseconds(10),
|
||||
maxBackoff: TimeSpan.FromMilliseconds(50),
|
||||
jitter: _ => TimeSpan.Zero);
|
||||
var captured = CaptureRediscoverEvents(watcher);
|
||||
|
||||
await watcher.StartAsync(CancellationToken.None);
|
||||
|
||||
// Wait for the second iteration (post-retry) to start.
|
||||
await WaitUntilAsync(() => source.CallCount >= 2, TimeSpan.FromSeconds(2));
|
||||
|
||||
// Now feed bootstrap + real event into the second channel.
|
||||
await secondChannel.Writer.WriteAsync(Event(0, t0));
|
||||
await secondChannel.Writer.WriteAsync(Event(1, t1));
|
||||
|
||||
await WaitUntilAsync(() => captured.Count >= 1, TimeSpan.FromSeconds(2));
|
||||
|
||||
captured.Count.ShouldBe(1);
|
||||
captured[0].Reason.ShouldBe("deploy-time-changed");
|
||||
|
||||
// The retry call passed null lastSeenDeployTime because no events were seen
|
||||
// before the throw — confirms baseline tracking is per-instance, not per-stream.
|
||||
source.LastSeenTimes.Count.ShouldBeGreaterThanOrEqualTo(2);
|
||||
source.LastSeenTimes[0].ShouldBeNull();
|
||||
source.LastSeenTimes[1].ShouldBeNull();
|
||||
|
||||
await watcher.StopAsync();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user