fix(heidenhain): Tool/Number, Tool/Index, Tool/AxisCode publish their declared integer types (#867)

HeidenhainSession.Leaf's `case Lsv2Tool t` switch expression mixed long, int and
double? arms, so C# unified the natural type to double? and Tool/Number (Int64),
Tool/Index and Tool/AxisCode (Int32) were published as Double. Every arm in that
switch (and in the alarm-list, axes and feature-acquire switches in the same file)
is now boxed to object explicitly so no numeric arm can widen another.

New HeidenhainLeafRuntimeTypeTests asserts value.GetType() for every catalog value
leaf against its declared DataType (scalar + array), plus PLC/DATA binding coercion
for Int16/Int32/Int64/Boolean. Verified red on the old code (exactly the three Tool
leaves) and green with the fix.

Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
This commit is contained in:
Joseph Doherty
2026-11-11 12:20:33 -05:00
parent e857135e8b
commit 8e97fc94fd
2 changed files with 151 additions and 14 deletions
@@ -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<Lsv2Alarm> 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;