Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasDuplicateSiblingNameTests.cs
T
Joseph Doherty 1dba46bd7b fix(focas): decode servo/spindle meters by their base/exponent envelope; MaxRpm -> MotorSpeedPct
Servo (0x0056) and spindle (0x0040) meter records are 8 bytes:
[i32 value][u16 base][u16 exponent], the same self-describing envelope as the
position blocks.

Servo: offset 4 was read as a decimal count. It is the BASE (a steady 10), so
every load was divided by 10^10 and published ~0 -- the sister ignitionoee panel
read servo 0% on every axis of a Makino cutting at 20,000 rpm. Loads are now
value / base^exponent, a direct percentage.

Spindle: the raw meter is a 1/16384 fraction of full scale. Settled on the real
Z-34184 by regressing the type-1 meter against cnc_rddynamic2 spindle speed over
596..20,000 rpm (rpm = 1.220749 x meter; 16384 x 1.220749 = 20000.75). So:
- Spindle/{name}/Load publishes % of rated load (Float64; was the raw Int32
  count -- a reading of 9155 meant 55.9%).
- Spindle/{name}/MaxRpm is RENAMED Spindle/{name}/MotorSpeedPct (Float64, % of
  maximum speed) and is polled with Load. The old node was actively wrong: the
  type-1 meter is the live motor speed, and it was read once at bootstrap and
  served forever as if it were a max-RPM constant. Client-visible: FIXED: tags
  on .../MaxRpm must be re-pointed.
- The spindle read no longer stops at the first zero after slot 1 (which dropped
  an idle second spindle and kept non-zero residue); every slot is returned.

A record whose envelope is undescribable (base not 2/10, exponent > 18 -- e.g.
residue past the configured axis/spindle count) publishes BadNoData, never a Good 0.

Ported from the sister ignitionoee FOCAS driver v0.17.2/v0.17.3 (Focas.Wire-019);
live captures live-spmeter-{load,maxrpm}-0x0040.bin added as fixtures.
2026-10-07 14:07:22 -04:00

204 lines
9.9 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/// <summary>
/// Gitea #548 — a CNC reporting duplicate sibling axis names (the wonder Makino returns two
/// axis folders both called <c>AA</c>) made the <c>Axes/</c> subtree untrustworthy: the
/// browser offered two identical folders commit-time collision detection had to refuse, and
/// the poll loop's name-keyed snapshot cache let the second axis silently overwrite the
/// first. Duplicated sibling names are now occurrence-suffixed (<c>AA_1</c>, <c>AA_2</c>)
/// everywhere — emitter, snapshot keys, servo loads, spindles — so both axes are
/// authorable and each reads its own axis's data.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasDuplicateSiblingNameTests
{
private const string Host = "focas://10.0.0.5:8193";
/// <summary>A fake modelled on the Makino: axes AA, Y, AA (slots 1–3) with distinct data.</summary>
private static FakeFocasClientFactory MakinoFactory() => new()
{
Customise = () =>
{
var c = new FakeFocasClient();
c.AxisNames.Clear();
c.AxisNames.AddRange([new("A", "A"), new("Y", ""), new("A", "A")]);
c.DynamicByAxis[1] = new FocasDynamicSnapshot(1, 0, 0, 0, 0, 0, 0, 111, 0, 0, 0);
c.DynamicByAxis[2] = new FocasDynamicSnapshot(2, 0, 0, 0, 0, 0, 0, 222, 0, 0, 0);
c.DynamicByAxis[3] = new FocasDynamicSnapshot(3, 0, 0, 0, 0, 0, 0, 333, 0, 0, 0);
c.ServoLoads.AddRange([new("AA", 10), new("Y", 20), new("AA", 30)]);
// Two spindles whose displays also collide, with distinct data per slot.
c.SpindleNames.Clear();
c.SpindleNames.AddRange([new("S", "1", "", ""), new("S", "1", "", "")]);
c.SpindleLoads.AddRange([5.0, 7.0]);
c.SpindleMotorSpeeds.AddRange([40.0, 60.0]);
return c;
},
};
private static FocasDriver NewDriver(params FocasTagDefinition[] tags) =>
new(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
Probe = new FocasProbeOptions { Enabled = false },
FixedTree = new FocasFixedTreeOptions { Enabled = true },
RawTags = FocasRawTags.Entries(tags),
}, "drv-1", MakinoFactory());
private static async Task<FocasDriver> StartedDriverAsync(params FocasTagDefinition[] tags)
{
var drv = NewDriver(tags);
await drv.InitializeAsync("{}", CancellationToken.None);
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
while (DateTime.UtcNow < deadline && drv.GetDeviceState(Host)?.LastFixedPollUtc is null)
await Task.Delay(25, TestContext.Current.CancellationToken);
drv.GetDeviceState(Host)!.LastFixedPollUtc.ShouldNotBeNull("the fixed-tree loop never completed a poll");
return drv;
}
[Fact]
public async Task Duplicate_axes_browse_as_distinct_suffixed_folders()
{
var drv = await StartedDriverAsync();
var builder = new RecordingBuilder();
await drv.DiscoverAsync(builder, CancellationToken.None);
var folders = builder.Folders.Select(f => f.BrowseName).ToList();
folders.ShouldContain("AA_1");
folders.ShouldContain("AA_2");
folders.ShouldContain("Y");
folders.ShouldNotContain("AA", customMessage:
"a bare AA folder would silently mean one of the two duplicate axes");
// Unique names stay bare — the disambiguation must not rename the rest of the machine.
folders.ShouldNotContain("Y_1");
// The emitted leaves are distinct, parseable addresses (the #540 invariant, held here
// for the duplicate set specifically).
var axisLeaves = builder.Variables
.Select(v => v.Info.FullName)
.Where(n => n.StartsWith("FIXED:Axes/AA", StringComparison.Ordinal))
.ToList();
axisLeaves.ShouldNotBeEmpty();
axisLeaves.ShouldAllBe(n => FocasAddress.TryParse(n) != null);
axisLeaves.Distinct(StringComparer.OrdinalIgnoreCase).Count().ShouldBe(axisLeaves.Count);
axisLeaves.ShouldContain("FIXED:Axes/AA_1/AbsolutePosition");
axisLeaves.ShouldContain("FIXED:Axes/AA_2/AbsolutePosition");
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Each_duplicate_axis_reads_its_own_position()
{
// The runtime half of #548: before disambiguation the second AA overwrote the first's
// snapshot slot, so even a hand-authored tag could only ever see one axis.
var drv = await StartedDriverAsync(
new FocasTagDefinition("raw/aa1", Host, "FIXED:Axes/AA_1/AbsolutePosition", FocasDataType.Float64, Writable: false),
new FocasTagDefinition("raw/aa2", Host, "FIXED:Axes/AA_2/AbsolutePosition", FocasDataType.Float64, Writable: false),
new FocasTagDefinition("raw/y", Host, "FIXED:Axes/Y/AbsolutePosition", FocasDataType.Float64, Writable: false));
var read = await drv.ReadAsync(["raw/aa1", "raw/aa2", "raw/y"], CancellationToken.None);
read[0].StatusCode.ShouldBe(FocasStatusMapper.Good);
read[0].Value.ShouldBe(111.0); // slot 1's AA
read[1].StatusCode.ShouldBe(FocasStatusMapper.Good);
read[1].Value.ShouldBe(333.0); // slot 3's AA — NOT slot 1's overwritten value
read[2].StatusCode.ShouldBe(FocasStatusMapper.Good);
read[2].Value.ShouldBe(222.0); // the unique axis, untouched by disambiguation
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task The_bare_name_of_a_duplicate_set_reads_Bad_rather_than_one_arbitrary_axis()
{
// Whole-set suffixing: when two axes are both AA, bare AA ceases to exist. Resolving it
// to either axis would be the same silent-wrongness class #534/#540 were about.
var drv = await StartedDriverAsync(
new FocasTagDefinition("raw/bare", Host, "FIXED:Axes/AA/AbsolutePosition", FocasDataType.Float64, Writable: false));
var read = await drv.ReadAsync(["raw/bare"], CancellationToken.None);
read[0].StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Servo_loads_resolve_per_duplicate_axis()
{
// cnc_rdsvmeter answers per-axis in slot order, so the k-th AA load belongs to the k-th
// AA axis. Before #548 both landed on one name-keyed dictionary entry, last write wins.
var drv = await StartedDriverAsync(
new FocasTagDefinition("raw/load1", Host, "FIXED:Axes/AA_1/ServoLoad", FocasDataType.Float64, Writable: false),
new FocasTagDefinition("raw/load2", Host, "FIXED:Axes/AA_2/ServoLoad", FocasDataType.Float64, Writable: false));
// The servo-load pass runs after the axis pass inside the same tick; poll until both
// distinct keys are populated rather than racing it.
var state = drv.GetDeviceState(Host)!;
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
while (DateTime.UtcNow < deadline && state.LastServoLoads.Count < 3)
await Task.Delay(25, TestContext.Current.CancellationToken);
var read = await drv.ReadAsync(["raw/load1", "raw/load2"], CancellationToken.None);
read[0].StatusCode.ShouldBe(FocasStatusMapper.Good);
read[0].Value.ShouldBe(10.0);
read[1].StatusCode.ShouldBe(FocasStatusMapper.Good);
read[1].Value.ShouldBe(30.0);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Duplicate_spindles_resolve_per_slot()
{
// The spindle read path matched by display name and took the FIRST index that matched,
// so a duplicate spindle name made the second spindle's data unreachable.
var drv = await StartedDriverAsync(
new FocasTagDefinition("raw/s1load", Host, "FIXED:Spindle/S1_1/Load", FocasDataType.Float64, Writable: false),
new FocasTagDefinition("raw/s2load", Host, "FIXED:Spindle/S1_2/Load", FocasDataType.Float64, Writable: false),
new FocasTagDefinition("raw/s1speed", Host, "FIXED:Spindle/S1_1/MotorSpeedPct", FocasDataType.Float64, Writable: false),
new FocasTagDefinition("raw/s2speed", Host, "FIXED:Spindle/S1_2/MotorSpeedPct", FocasDataType.Float64, Writable: false));
var read = await drv.ReadAsync(["raw/s1load", "raw/s2load", "raw/s1speed", "raw/s2speed"], CancellationToken.None);
read[0].Value.ShouldBe(5.0);
read[1].Value.ShouldBe(7.0);
read[2].Value.ShouldBe(40.0);
read[3].Value.ShouldBe(60.0);
read.ShouldAllBe(r => r.StatusCode == FocasStatusMapper.Good);
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>Records what <c>DiscoverAsync</c> emits, flattening the folder tree.</summary>
private sealed class RecordingBuilder : IAddressSpaceBuilder
{
public List<(string BrowseName, string DisplayName)> Folders { get; } = [];
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = [];
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 name, DriverDataType type, object? value) { }
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) { }
}
}
}