diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainSession.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainSession.cs index 0c2b831ed..e354caa35 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainSession.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainSession.cs @@ -256,14 +256,15 @@ internal sealed class HeidenhainSession : IAsyncDisposable await PrepareTelemetryAsync(wire, ct).ConfigureAwait(false); value = feature switch { - "Execution" => Lsv2Data.U16(await _client.ReadRuntimeAsync(23, ct).ConfigureAwait(false)), - "ProgramState" => Lsv2Data.U16(await _client.ReadRuntimeAsync(26, ct).ConfigureAwait(false)), + // Each arm boxed explicitly so no numeric arm can widen another (lmxopcua#867). + "Execution" => (object)Lsv2Data.U16(await _client.ReadRuntimeAsync(23, ct).ConfigureAwait(false)), + "ProgramState" => (object)Lsv2Data.U16(await _client.ReadRuntimeAsync(26, ct).ConfigureAwait(false)), "Program" => Lsv2Data.Program(await _client.ReadRuntimeAsync(24, ct).ConfigureAwait(false)), "Overrides" => Lsv2Data.Overrides(await _client.ReadRuntimeAsync(25, ct).ConfigureAwait(false)), "Axes" => Lsv2Data.Axes(await _client.ReadRuntimeAsync(22, ct).ConfigureAwait(false)), "Tool" => Lsv2Data.Tool(await _client.ReadRuntimeAsync(51, ct).ConfigureAwait(false)), HeidenhainCatalog.AlarmsFeature => await AlarmsAsync(ct).ConfigureAwait(false), - _ => await TimerAsync(feature, ct).ConfigureAwait(false), + _ => (object)await TimerAsync(feature, ct).ConfigureAwait(false), }; } @@ -444,24 +445,27 @@ internal sealed class HeidenhainSession : IAsyncDisposable case double[] o when address.StartsWith("Overrides/", StringComparison.Ordinal): return o[name switch { "Feed" => 0, "Spindle" => 1, _ => 2 }]; case Lsv2Axes a: - return name == "Names" ? a.Names : a.Positions; + return name == "Names" ? (object)a.Names : (object)a.Positions; case Lsv2Tool t: + // lmxopcua#867: every arm is cast to object. Without it C# unifies long/int/double? to double?, so + // Tool/Number (Int64) and Tool/Index + Tool/AxisCode (Int32) were published as Double. A null + // geometry still boxes to null (BadNotSupported upstream). return name switch { - "Number" => t.Number, - "Index" => t.Index, - "AxisCode" => t.Axis, - "Length" => t.Length, - _ => t.Radius, + "Number" => (object)t.Number, + "Index" => (object)t.Index, + "AxisCode" => (object)t.Axis, + "Length" => (object?)t.Length, + _ => (object?)t.Radius, }; case List list: return name switch { - "Count" => list.Count, - "Messages" => list.Select(x => x.Message).ToArray(), - "Classes" => list.Select(x => x.ErrorClass).ToArray(), - "Groups" => list.Select(x => x.Group).ToArray(), - _ => list.Select(x => x.Number).ToArray(), + "Count" => (object)list.Count, + "Messages" => (object)list.Select(x => x.Message).ToArray(), + "Classes" => (object)list.Select(x => x.ErrorClass).ToArray(), + "Groups" => (object)list.Select(x => x.Group).ToArray(), + _ => (object)list.Select(x => x.Number).ToArray(), }; case int i: return i; diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/HeidenhainLeafRuntimeTypeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/HeidenhainLeafRuntimeTypeTests.cs new file mode 100644 index 000000000..7c10bd2cb --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/HeidenhainLeafRuntimeTypeTests.cs @@ -0,0 +1,133 @@ +using System.Buffers.Binary; +using System.Text; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests.Simulator; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests; + +/// +/// lmxopcua#867: the published value's RUNTIME type must be the CLR type of the leaf's declared data type. +/// Shouldly equality cannot catch this (7.0 equals 7L), so every assertion here compares GetType(). +/// +[Trait("Category", "Unit")] +public sealed class HeidenhainLeafRuntimeTypeTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private static Type ClrType(DriverDataType type, bool isArray) + { + var scalar = type switch + { + DriverDataType.Boolean => typeof(bool), + DriverDataType.Int16 => typeof(short), + DriverDataType.Int32 => typeof(int), + DriverDataType.Int64 => typeof(long), + DriverDataType.Float64 => typeof(double), + DriverDataType.String => typeof(string), + _ => throw new ArgumentOutOfRangeException(nameof(type), type, null), + }; + return isArray ? scalar.MakeArrayType() : scalar; + } + + /// Every fixed value leaf (capability strings excluded), with its declared type. + public static TheoryData CatalogValueLeaves() + { + var data = new TheoryData(); + foreach (var e in HeidenhainCatalog.Entries.Where(e => !e.IsCapability)) data.Add(e.Address); + return data; + } + + private static SimulatedControl FullyPopulatedControl() + { + var c = new SimulatedControl(); + c.Runtime[22] = () => SimReply.Ok("S_RI", [0, 1, .. Encoding.ASCII.GetBytes("1.5\0X\0")]); + c.Runtime[23] = () => SimReply.Ok("S_RI", [0, 4]); + c.Runtime[26] = () => SimReply.Ok("S_RI", [0, 7]); + c.Runtime[24] = () => SimReply.Ok("S_RI", [0, 0, 0, 5, .. "A\0B\0"u8.ToArray()]); + c.Runtime[25] = () => SimReply.Ok("S_RI", new byte[12]); + var alarm = new byte[10]; + BinaryPrimitives.WriteInt16BigEndian(alarm, 3); + BinaryPrimitives.WriteInt16BigEndian(alarm.AsSpan(2), 4); + BinaryPrimitives.WriteInt32BigEndian(alarm.AsSpan(4), 12); + alarm[8] = 65; + c.Runtime[27] = () => SimReply.Ok("S_RI", alarm); + c.Runtime[28] = () => SimReply.Refuse(57); + // 24-byte tool reply: number 49, index 1, axis 2, length 100.5, radius 3.25 (float64le). + var tool = new byte[24]; + BinaryPrimitives.WriteUInt32BigEndian(tool, 49); + BinaryPrimitives.WriteUInt16BigEndian(tool.AsSpan(4), 1); + BinaryPrimitives.WriteUInt16BigEndian(tool.AsSpan(6), 2); + BinaryPrimitives.WriteDoubleLittleEndian(tool.AsSpan(8), 100.5); + BinaryPrimitives.WriteDoubleLittleEndian(tool.AsSpan(16), 3.25); + c.Runtime[51] = () => SimReply.Ok("S_RI", tool); + for (var s = 30; s <= 34; s++) c.Runtime[s] = () => SimReply.Ok("S_RI", [0, 0, 1, 0]); + return c; + } + + [Theory] + [MemberData(nameof(CatalogValueLeaves))] + public async Task Catalog_leaf_publishes_the_clr_type_of_its_declared_data_type(string address) + { + HeidenhainCatalog.TryGet(address, out var entry).ShouldBeTrue(); + var control = FullyPopulatedControl(); + await using var sim = Lsv2Simulator.WithControl(control); + var client = new Lsv2.Lsv2Client("127.0.0.1", sim.Port, TimeSpan.FromSeconds(1)); + var tag = TestTags.Def(address); + var settings = new HeidenhainSessionSettings(true, true, false, 64, true, false, false); + await using var session = new HeidenhainSession(client, settings, (_, _) => ValueTask.FromResult("")); + await client.OpenAsync(Ct); + await session.InitializeAsync(Ct); + + var sample = (await session.ReadBatchAsync([tag], Ct))[0]; + + sample.Status.ShouldBe(HeidenhainStatus.Good, address); + sample.Value.ShouldNotBeNull(address); + sample.Value.GetType().ShouldBe(ClrType(entry!.DataType, entry.IsArray), address); + control.Violations.ShouldBeEmpty(); + } + + [Fact] + public void Every_integer_catalog_leaf_is_covered() + { + // Pins the theory's reach: the integer leaves #867 broke are in the data set. + var leaves = CatalogValueLeaves().Select(r => r.Data).ToList(); + leaves.ShouldContain("Tool/Number"); + leaves.ShouldContain("Tool/Index"); + leaves.ShouldContain("Tool/AxisCode"); + leaves.ShouldContain("Alarms/Count"); + leaves.ShouldContain("Program/Line"); + leaves.ShouldContain("Status/ExecutionState"); + leaves.Count(l => l.StartsWith("Timers/", StringComparison.Ordinal)).ShouldBe(HeidenhainCatalog.TimerNames.Count); + } + + [Theory] + [InlineData(DriverDataType.Int16, 42)] + [InlineData(DriverDataType.Int32, 42)] + [InlineData(DriverDataType.Int64, 42)] + [InlineData(DriverDataType.Int16, 42L)] + [InlineData(DriverDataType.Int32, 42L)] + [InlineData(DriverDataType.Int64, 42L)] + [InlineData(DriverDataType.Int16, 42.0)] + [InlineData(DriverDataType.Int32, 42.0)] + [InlineData(DriverDataType.Int64, 42.0)] + [InlineData(DriverDataType.Boolean, true)] + [InlineData(DriverDataType.Boolean, false)] + public void Binding_coercion_publishes_the_clr_type_of_the_declared_data_type(DriverDataType type, object native) + { + HeidenhainBindingCodec.Coerce(native, type).GetType().ShouldBe(ClrType(type, false)); + } + + [Theory] + [InlineData(HeidenhainPlcArea.Byte, new byte[] { 7 }, DriverDataType.Int16)] + [InlineData(HeidenhainPlcArea.Byte, new byte[] { 7 }, DriverDataType.Int32)] + [InlineData(HeidenhainPlcArea.Word, new byte[] { 7, 0 }, DriverDataType.Int64)] + [InlineData(HeidenhainPlcArea.Dword, new byte[] { 7, 0, 0, 0 }, DriverDataType.Int32)] + [InlineData(HeidenhainPlcArea.InputWord, new byte[] { 7, 0 }, DriverDataType.Int32)] + [InlineData(HeidenhainPlcArea.Marker, new byte[] { 1 }, DriverDataType.Boolean)] + public void Plc_binding_decode_publishes_the_clr_type_of_the_declared_data_type(HeidenhainPlcArea area, byte[] bytes, DriverDataType type) + { + HeidenhainBindingCodec.DecodePlc(area, bytes, type).GetType().ShouldBe(ClrType(type, false)); + } +}