From 1dba46bd7b655f9e802b9178fdd06b48ff4bed53 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 7 Oct 2026 14:07:22 -0400 Subject: [PATCH] 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. --- .../FocasDriver.cs | 85 +++++++---- .../IFocasClient.cs | 49 ++++--- .../SynchronizedFocasClient.cs | 6 +- .../Wire/FocasWireClient.cs | 73 ++++++---- .../Wire/FocasWireModels.cs | 96 ++++++++++++- .../Wire/WireFocasClient.cs | 26 ++-- .../FakeFocasClient.cs | 18 +-- .../Fixtures/v3/live-spmeter-load-0x0040.bin | Bin 0 -> 92 bytes .../v3/live-spmeter-maxrpm-0x0040.bin | Bin 0 -> 92 bytes .../FocasDuplicateSiblingNameTests.cs | 22 +-- .../FocasMeterDecodeTests.cs | 127 +++++++++++++++++ .../FocasMeterPublishTests.cs | 134 ++++++++++++++++++ .../FocasWireV3Tests.cs | 5 +- 13 files changed, 518 insertions(+), 123 deletions(-) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/live-spmeter-load-0x0040.bin create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/live-spmeter-maxrpm-0x0040.bin create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasMeterDecodeTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasMeterPublishTests.cs diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs index 164c8070..a233f294 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs @@ -643,7 +643,8 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, // Spindle subtree — one folder per discovered spindle, suppressed // entirely on series that don't export cnc_rdspdlname. Per-spindle - // Load + MaxRpm each gated on their own capability probe. + // Load + MotorSpeedPct (both cnc_rdspmeter percentages of full scale) + // each gated on their own capability probe. if (cache.Capabilities.Spindles) { var spindleRoot = deviceFolder.Folder("Spindle", "Spindle"); @@ -652,9 +653,9 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, var name = cache.SpindleSegments[i]; var spindleFolder = spindleRoot.Folder(name, name); if (cache.Capabilities.SpindleLoad) - EmitFixedVariable(spindleFolder, $"Spindle/{name}", "Load", DriverDataType.Int32); - if (cache.Capabilities.SpindleMaxRpm && i < cache.SpindleMaxRpms.Count) - EmitFixedVariable(spindleFolder, $"Spindle/{name}", "MaxRpm", DriverDataType.Int32); + EmitFixedVariable(spindleFolder, $"Spindle/{name}", "Load", DriverDataType.Float64); + if (cache.Capabilities.SpindleMotorSpeed) + EmitFixedVariable(spindleFolder, $"Spindle/{name}", "MotorSpeedPct", DriverDataType.Float64); } } @@ -927,7 +928,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, // Optional-API probes — each returns empty / throws when unsupported. var spindles = await SafeProbe(() => client.GetSpindleNamesAsync(ct), []); - var spindleMaxRpms = await SafeProbe(() => client.GetSpindleMaxRpmsAsync(ct), []); + var spindleMotorSpeeds = await SafeProbe(() => client.GetSpindleMotorSpeedsAsync(ct), []); var servoLoads = await SafeProbe(() => client.GetServoLoadsAsync(ct), []); var programInfo = await SafeTryProbe(() => client.GetProgramInfoAsync(ct)); var timer = await SafeTryProbe(() => client.GetTimerAsync(FocasTimerKind.PowerOn, ct)); @@ -936,13 +937,12 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, var caps = new FocasFixedTreeCapabilities( Spindles: spindles.Count > 0, SpindleLoad: spindleLoad.Count > 0, - SpindleMaxRpm: spindleMaxRpms.Count > 0, + SpindleMotorSpeed: spindleMotorSpeeds.Count > 0, ServoLoad: servoLoads.Count > 0, ProgramInfo: programInfo is not null, Timers: timer is not null); - state.FixedTreeCache = new FocasFixedTreeCache( - sys, [.. axes], [.. spindles], [.. spindleMaxRpms], caps); + state.FixedTreeCache = new FocasFixedTreeCache(sys, [.. axes], [.. spindles], caps); // The bootstrap IS an answer from the device, and Identity comes entirely from it — // so stamp liveness here as well as per tick, or a CNC whose axis poll never // succeeds would serve a perfectly valid sysinfo as stale. @@ -958,15 +958,14 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, } } - // Prime the spindle-loads cache from bootstrap if supported — avoids a + // Prime the spindle-meter caches from bootstrap if supported — avoids a // "tree is there but reads say BadNodeIdUnknown" window on startup. - if (state.FixedTreeCache?.Capabilities is { SpindleLoad: true }) + if (state.FixedTreeCache?.Capabilities is { SpindleLoad: true } or { SpindleMotorSpeed: true }) { try { var client2 = await EnsureConnectedAsync(state, ct).ConfigureAwait(false); - var loads = await client2.GetSpindleLoadsAsync(ct).ConfigureAwait(false); - for (var i = 0; i < loads.Count; i++) state.LastSpindleLoads[i] = loads[i]; + await PollSpindleMetersAsync(state, client2, ct).ConfigureAwait(false); } catch (Exception ex) { @@ -1019,17 +1018,16 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, state.Options.HostAddress); } } - if (cache.Capabilities.SpindleLoad) + if (cache.Capabilities.SpindleLoad || cache.Capabilities.SpindleMotorSpeed) { try { - var loads = await client.GetSpindleLoadsAsync(ct).ConfigureAwait(false); - for (var i = 0; i < loads.Count; i++) state.LastSpindleLoads[i] = loads[i]; + await PollSpindleMetersAsync(state, client, ct).ConfigureAwait(false); } catch (Exception ex) { /* transient */ - _logger.LogDebug(ex, "FOCAS spindle-loads poll failed for {Host}", + _logger.LogDebug(ex, "FOCAS spindle-meter poll failed for {Host}", state.Options.HostAddress); } } @@ -1148,7 +1146,27 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, { var segments = FixedTreeNames.Disambiguate(loads.Select(l => l.AxisName)); for (var i = 0; i < loads.Count; i++) - state.LastServoLoads[segments[i]] = loads[i].LoadPercent; + state.LastServoLoads[segments[i]] = loads[i].LoadPercent ?? NoValue; + } + + /// + /// Read and cache both spindle meters (load, motor speed) — each a cnc_rdspmeter + /// percentage of full scale (16384 = 100%) — for every spindle slot, as capability allows. + /// A slot whose record describes no value is cached as (BadNoData). + /// + private static async Task PollSpindleMetersAsync(DeviceState state, IFocasClient client, CancellationToken ct) + { + var caps = state.FixedTreeCache?.Capabilities; + if (caps is { SpindleLoad: true }) + { + var loads = await client.GetSpindleLoadsAsync(ct).ConfigureAwait(false); + for (var i = 0; i < loads.Count; i++) state.LastSpindleLoads[i] = loads[i] ?? NoValue; + } + if (caps is { SpindleMotorSpeed: true }) + { + var speeds = await client.GetSpindleMotorSpeedsAsync(ct).ConfigureAwait(false); + for (var i = 0; i < speeds.Count; i++) state.LastSpindleMotorSpeeds[i] = speeds[i] ?? NoValue; + } } private static object? TimerValue(DeviceState state, FocasTimerKind kind) => @@ -1240,7 +1258,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, return CachedDouble(load, status, sourceUtc, now); } - // Spindle matches: "Spindle/{name}/Load" + "Spindle/{name}/MaxRpm" + // Spindle matches: "Spindle/{name}/Load" + "Spindle/{name}/MotorSpeedPct" if (suffixFull.StartsWith("Spindle/", StringComparison.Ordinal) && state.FixedTreeCache is { } spindleCache) { @@ -1259,14 +1277,14 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, } if (idx >= 0) { - object? value = field switch + var meters = field switch { - "Load" => state.LastSpindleLoads.TryGetValue(idx, out var l) ? (object)l : null, - "MaxRpm" => idx < spindleCache.SpindleMaxRpms.Count ? (object)spindleCache.SpindleMaxRpms[idx] : null, + "Load" => state.LastSpindleLoads, + "MotorSpeedPct" => state.LastSpindleMotorSpeeds, _ => null, }; - if (value is not null) - return new DataValueSnapshot(value, status, sourceUtc, now); + if (meters is not null && meters.TryGetValue(idx, out var pct)) + return CachedDouble(pct, status, sourceUtc, now); } } } @@ -1477,7 +1495,6 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, FocasSysInfo SysInfo, IReadOnlyList Axes, IReadOnlyList Spindles, - IReadOnlyList SpindleMaxRpms, FocasFixedTreeCapabilities Capabilities) { /// @@ -1503,14 +1520,14 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, /// /// Per-device optional-API capability flags — which of the "this may or may not /// exist on this CNC series" calls succeeded at bootstrap. Drives per-series - /// node suppression so a 16i that doesn't export cnc_rdspmaxrpm simply - /// doesn't get a Spindle/{name}/MaxRpm node (instead of surfacing + /// node suppression so a 16i that doesn't export cnc_rdspmeter simply + /// doesn't get a Spindle/{name}/MotorSpeedPct node (instead of surfacing /// BadDeviceFailure on every read). /// internal sealed record FocasFixedTreeCapabilities( bool Spindles, // cnc_rdspdlname returned 1+ spindle names - bool SpindleLoad, // cnc_rdspload bootstrap probe succeeded - bool SpindleMaxRpm, // cnc_rdspmaxrpm bootstrap probe succeeded + bool SpindleLoad, // cnc_rdspmeter type 0 (load) bootstrap probe succeeded + bool SpindleMotorSpeed, // cnc_rdspmeter type 1 (motor speed) bootstrap probe succeeded bool ServoLoad, // cnc_rdsvmeter bootstrap probe returned data bool ProgramInfo, // cnc_exeprgname2 + cnc_rdblkcount + cnc_rdopmode work bool Timers); // cnc_rdtimer works for at least PowerOn @@ -1580,8 +1597,16 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, public ConcurrentDictionary LastTimers { get; } = new(); /// Gets the last servo load percentages by servo name. public ConcurrentDictionary LastServoLoads { get; } = new(StringComparer.OrdinalIgnoreCase); - /// Gets the last spindle load percentages by spindle index. - public ConcurrentDictionary LastSpindleLoads { get; } = new(); + /// + /// Gets the last spindle load percentages (% of rated load) by spindle index; + /// for a slot the CNC described as holding none. + /// + public ConcurrentDictionary LastSpindleLoads { get; } = new(); + /// + /// Gets the last spindle motor-speed percentages (% of maximum speed) by spindle index; + /// for a slot the CNC described as holding none. + /// + public ConcurrentDictionary LastSpindleMotorSpeeds { get; } = new(); /// Disposes the FOCAS client instance. public void DisposeClient() diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs index 7a1d7d69..0bac3114 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs @@ -140,9 +140,10 @@ public interface IFocasClient : IDisposable // ---- Fixed-tree T3.5 (servo meters) ---- /// - /// Read the servo-load meter percentages across all configured axes. - /// Values are percentages (scaled by 10^Dec). Empty list on a - /// disconnected session or unsupported CNC. + /// Read the servo-load meter percentages across all configured axes. Each record's own + /// value / base^exponent envelope is applied (servo loads are direct percentages); + /// a record whose envelope describes no value carries a null . + /// Empty list on a disconnected session or unsupported CNC. /// /// The cancellation token. /// The servo-load meter percentages across all configured axes. @@ -151,26 +152,31 @@ public interface IFocasClient : IDisposable // ---- Fixed-tree T3.6 (spindle meters) ---- /// - /// Read per-spindle load percentages. Result list index corresponds to - /// spindle index from . Empty list on a - /// disconnected session or when the CNC doesn't support the call (older - /// series like 16i may return EW_FUNC). + /// Read per-spindle load as a percentage of rated load (cnc_rdspmeter type 0; the + /// raw meter is a 1/16384 fraction of full scale). Result list index corresponds to + /// spindle index from ; a slot whose record describes no + /// value is null. Empty list on a disconnected session or when the CNC doesn't support the + /// call (older series like 16i may return EW_FUNC). /// /// The cancellation token. /// Per-spindle load percentages, indexed as per . - Task> GetSpindleLoadsAsync(CancellationToken cancellationToken); + Task> GetSpindleLoadsAsync(CancellationToken cancellationToken); /// - /// Read per-spindle maximum RPM values. Static configuration, fetched once at - /// bootstrap. Index alignment as per . + /// Read per-spindle motor speed as a percentage of maximum speed (cnc_rdspmeter + /// type 1, historically labelled cnc_rdspmaxrpm). A live value, not a max-RPM + /// constant. Index alignment and null semantics as per . /// /// The cancellation token. - /// Per-spindle maximum RPM values, indexed as per . - Task> GetSpindleMaxRpmsAsync(CancellationToken cancellationToken); + /// Per-spindle motor-speed percentages, indexed as per . + Task> GetSpindleMotorSpeedsAsync(CancellationToken cancellationToken); } -/// One servo-meter entry — one axis's current load percentage. -public sealed record FocasServoLoad(string AxisName, double LoadPercent); +/// +/// One servo-meter entry — one axis's current load percentage; null when the CNC's record +/// described no usable value (never a fabricated 0). +/// +public sealed record FocasServoLoad(string AxisName, double? LoadPercent); /// Which cumulative counter reads. public enum FocasTimerKind @@ -297,19 +303,16 @@ public sealed record FocasDynamicSnapshot( /// public readonly record struct FocasPosition(int Raw, int Base = 0, int Exponent = 0, bool HasScale = false) { - /// - /// Largest exponent that can describe a real coordinate. An i32 carries at most ten digits, - /// so anything beyond is a misread block — and left unbounded, raw / 10^400 is - /// raw / Infinity = 0.0, a Good reading of a machine parked at the origin. - /// - public const int MaxExponent = 18; - /// /// False when the block says it holds no usable position: the 0xFFFF vacant marker, a /// base other than 2 or 10, or an absurd exponent. A per-node quality decision — that node /// publishes Bad, its siblings are unaffected. /// - public bool IsValid => !HasScale || (Exponent <= MaxExponent && Base is 2 or 10); + /// + /// The exponent bound matters: left unbounded, raw / 10^400 is + /// raw / Infinity = 0.0 — a Good reading of a machine parked at the origin. + /// + public bool IsValid => !HasScale || Wire.FocasValueEnvelope.IsValid(Base, Exponent); /// /// The position in engineering units: Raw / Base^Exponent when the block carried its @@ -321,7 +324,7 @@ public readonly record struct FocasPosition(int Raw, int Base = 0, int Exponent public double? ToEngineering(int fallbackDecimalPlaces) { if (!IsValid) return null; - if (HasScale) return Exponent == 0 ? Raw : Raw / Math.Pow(Base, Exponent); + if (HasScale) return Wire.FocasValueEnvelope.Scale(Raw, Base, Exponent); var dp = Math.Max(0, fallbackDecimalPlaces); return dp > 0 ? Raw / Math.Pow(10, dp) : Raw; } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/SynchronizedFocasClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/SynchronizedFocasClient.cs index 59542528..e2a3f759 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/SynchronizedFocasClient.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/SynchronizedFocasClient.cs @@ -99,12 +99,12 @@ public sealed class SynchronizedFocasClient : IFocasClient RunBoundedAsync(ct => _inner.GetServoLoadsAsync(ct), cancellationToken); /// - public Task> GetSpindleLoadsAsync(CancellationToken cancellationToken) => + public Task> GetSpindleLoadsAsync(CancellationToken cancellationToken) => RunBoundedAsync(ct => _inner.GetSpindleLoadsAsync(ct), cancellationToken); /// - public Task> GetSpindleMaxRpmsAsync(CancellationToken cancellationToken) => - RunBoundedAsync(ct => _inner.GetSpindleMaxRpmsAsync(ct), cancellationToken); + public Task> GetSpindleMotorSpeedsAsync(CancellationToken cancellationToken) => + RunBoundedAsync(ct => _inner.GetSpindleMotorSpeedsAsync(ct), cancellationToken); /// Disposes the inner client and the call gate. public void Dispose() diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireClient.cs index fa997f6f..097d210c 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireClient.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireClient.cs @@ -420,12 +420,15 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable return new FocasResult>(rc, result); } - /// Read per-spindle load percentages via cnc_rdspload (command 0x0040 with arg1=0). + /// + /// Read the per-spindle load meter via cnc_rdspmeter (command 0x0040, type 0). + /// is % of rated load (16384 = 100%). + /// /// Spindle selector; -1 selects all spindles. /// Cancellation token for the read operation. /// Optional per-call timeout override. /// Optional path ID override; defaults to . - /// The per-spindle load percentages, wrapped in a . + /// The per-spindle load meters, wrapped in a . public Task>> ReadSpindleLoadAsync( short spindleSelector = -1, CancellationToken cancellationToken = default, @@ -433,13 +436,19 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable ushort? pathId = null) => ReadSpindleMetricAsync(0, spindleSelector, cancellationToken, timeout, pathId); - /// Read per-spindle maximum RPMs via cnc_rdspmaxrpm (command 0x0040 with arg1=1). + /// + /// Read the per-spindle motor-speed meter via cnc_rdspmeter (command 0x0040, + /// type 1 — historically labelled cnc_rdspmaxrpm). It is NOT a maximum-RPM constant: + /// it is the live motor speed as a fraction of maximum, + /// (a regression against cnc_rddynamic2 spindle speed on a real 31i-B gives + /// rpm = 1.220749 × meter, i.e. 16384 = the spindle's 20,000 rpm maximum). + /// /// Spindle selector; -1 selects all spindles. /// Cancellation token for the read operation. /// Optional per-call timeout override. /// Optional path ID override; defaults to . - /// The per-spindle maximum RPMs, wrapped in a . - public Task>> ReadSpindleMaxRpmAsync( + /// The per-spindle motor-speed meters, wrapped in a . + public Task>> ReadSpindleMotorSpeedAsync( short spindleSelector = -1, CancellationToken cancellationToken = default, TimeSpan? timeout = null, @@ -771,13 +780,27 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable callTimeout.Token, new RequestBlock(0x0040, metric, spindleSelector, PathId: EffectivePathId(pathId))).ConfigureAwait(false); - return ToResult>(block, payload => - { - var values = new List(); - for (var offset = 0; offset + 8 <= payload.Length; offset += 8) - values.Add(new WireSpindleMetric((short)(values.Count + 1), ReadInt32(payload, offset))); - return values; - }); + return ToResult(block, ParseSpindleMetrics); + } + + /// + /// Decode a cnc_rdspmeter (0x0040) payload: 8-byte records of + /// [+0..3] i32 value, [+4..5] u16 base, [+6..7] u16 exponent, one per + /// slot of a fixed multi-slot buffer (slots past the spindle count are residue and decode + /// as = false). Every slot is returned, in wire order. + /// + /// The response-block payload. + /// The per-slot meters. + internal static IReadOnlyList ParseSpindleMetrics(byte[] payload) + { + var values = new List(); + for (var offset = 0; offset + 8 <= payload.Length; offset += 8) + values.Add(new WireSpindleMetric( + (short)(values.Count + 1), + ReadInt32(payload, offset), + BinaryPrimitives.ReadUInt16BigEndian(payload.AsSpan(offset + 4, 2)), + BinaryPrimitives.ReadUInt16BigEndian(payload.AsSpan(offset + 6, 2)))); + return values; } private async Task> ReadSingleAsync( @@ -958,18 +981,16 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable payload.Length >= 8 ? BinaryPrimitives.ReadInt32LittleEndian(payload.AsSpan(4, 4)) : 0); /// - /// Decode a cnc_rdsvmeter response into records. On the - /// 31i-B (v3) each per-axis LOADELM is 8 bytes — {int32 data; int16 dec; int16 unit} - /// — NOT the 12-byte shape the original parser assumed. The 12-byte stride misaligned after - /// the first record (it read the dec/unit shorts of the prior record as the next record's - /// data → wild values like 655360). Axis NAMES are not in the svmeter payload; they come - /// from the cnc_rdaxisname (0x0089) block requested alongside it and are correlated - /// by index. Validated against a live 31i-B 2026-06-25. - /// Scaling caveat: downstream applies LoadPercent = data / 10^dec; on the 31i-B - /// dec read as 10, which makes idle loads vanishingly small. The data/dec/unit field - /// semantics for servo load are inferred from the wire and not yet confirmed against the - /// machine's servo-meter screen — confirm magnitude at commissioning. The alignment + name - /// fix here is what removes the gross misaligned garbage. + /// Decode a cnc_rdsvmeter response into records. Each + /// per-axis record is 8 bytes — [+0..3] i32 value, [+4..5] u16 base, + /// [+6..7] u16 exponent — the same self-describing envelope as the position blocks, + /// so the load is value / base^exponent, a direct percentage. (A 12-byte stride + /// misaligned after the first record; reading offset 4 as a decimal count divided every load + /// by 10^10 — both fixed against live 31i-B captures, the latter by the sister ignitionoee + /// driver, Focas.Wire-019.) Axis NAMES are not in the svmeter payload; they come from the + /// cnc_rdaxisname (0x0089) block requested alongside it and are correlated by index. + /// The payload is a fixed 32-slot buffer: slots past the controlled-axis count are residue + /// and decode as = false. /// /// The raw cnc_rdsvmeter response payload. /// The raw cnc_rdaxisname response payload used to correlate axis names by index. @@ -989,8 +1010,8 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable (short)(index + 1), name, ReadInt32(svPayload, offset), - ReadInt16(svPayload, offset + 4), - ReadInt16(svPayload, offset + 6))); + BinaryPrimitives.ReadUInt16BigEndian(svPayload.AsSpan(offset + 4, 2)), // BASE, not a decimal + BinaryPrimitives.ReadUInt16BigEndian(svPayload.AsSpan(offset + 6, 2)))); } return result; } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireModels.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireModels.cs index 25491a17..844ea542 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireModels.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireModels.cs @@ -59,18 +59,104 @@ public sealed record WireDynamic( int SpindleSpeed, WireAxisPosition Axis); -/// One servo-meter entry from cnc_rdsvmeter — per-axis load percentage (scale by 10^). +/// +/// The self-describing (value, base, exponent) envelope shared by every value record on +/// the FOCAS wire — cnc_rddynamic2 positions, servo meters, spindle meters: +/// scaled = value / base^exponent, where only base 2 and base 10 are meaningful. +/// +internal static class FocasValueEnvelope +{ + /// + /// Largest exponent that can describe a real value. An i32 carries at most ten digits, so + /// anything beyond is a misread record — and value / 10^400 would be + /// value / Infinity = 0.0, a plausible Good zero. + /// + public const int MaxExponent = 18; + + /// + /// True when the record describes a usable value. A fixed multi-slot buffer's slots past the + /// configured axis/spindle count hold unrelated memory, which shows up exactly here: a base + /// that is neither 2 nor 10, or an absurd exponent (incl. the 0xFFFF vacant marker). + /// + /// The record's base field. + /// The record's exponent field. + /// Whether the record may be published. + public static bool IsValid(int @base, int exponent) => exponent <= MaxExponent && @base is 2 or 10; + + /// value / base^exponent, or null when is false. + /// The raw value. + /// The record's base field. + /// The record's exponent field. + /// The scaled value, or null. + public static double? Scale(int value, int @base, int exponent) => + !IsValid(@base, exponent) ? null : exponent == 0 ? value : value / Math.Pow(@base, exponent); +} + +/// +/// One servo-meter entry from cnc_rdsvmeter (0x0056): one axis's load. The 8-byte record +/// is [+0..3] i32 value, [+4..5] u16 base, [+6..7] u16 exponent and the load +/// is a direct percentage, = value / base^exponent. +/// +/// +/// Offset 4 used to be read as a decimal count ("data / 10^dec"). It is the base — a +/// steady 10 — so every load was divided by 10^10 and published ~0 (a real Makino cutting at +/// 20,000 rpm read servo 0% on every axis). Found by the sister ignitionoee driver, +/// Focas.Wire-019 (v0.17.2). +/// +/// 1-based axis slot. +/// Axis name from the companion 0x0089 block (empty when none). +/// Raw load value. +/// Scale base (2 or 10 when meaningful). +/// Scale exponent. public sealed record WireServoMeter( short Index, string Name, int Value, - short Decimal, - short Unit); + int Base, + int Exponent) +{ + /// False for a record whose envelope describes no usable value (buffer residue). + public bool IsValid => FocasValueEnvelope.IsValid(Base, Exponent); -/// One spindle metric slot from cnc_rdspload / cnc_rdspmaxrpm. + /// The load percentage, Value / Base^Exponent; null when is false. + public double? Scaled => FocasValueEnvelope.Scale(Value, Base, Exponent); +} + +/// +/// One spindle-meter slot from cnc_rdspmeter (0x0040; type 0 = load, type 1 = motor +/// speed). Same 8-byte envelope as , but the scaled value is a +/// fraction of full scale in 1/16384 units — see . +/// +/// 1-based spindle slot. +/// Raw meter value, in 1/16384-of-full-scale units. +/// Scale base. +/// Scale exponent. public sealed record WireSpindleMetric( short Index, - int Value); + int Value, + int Base, + int Exponent) +{ + /// + /// The meter's full-scale count: 16384 = 2^14 = 100% of rated load (type 0) or of + /// maximum spindle speed (type 1). 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 × meter, 16384 × 1.220749 = 20000.75 (Focas.Wire-019). + /// + public const int FullScale = 1 << 14; + + /// False for a record whose envelope describes no usable value (buffer residue). + public bool IsValid => FocasValueEnvelope.IsValid(Base, Exponent); + + /// + /// The meter as a percentage — (Value / Base^Exponent) × 100 / 16384 — or null when + /// is false. Values above 100 are real (short-term overload; the i16 + /// record clamps one count short of 200%). + /// + public double? Percent => FocasValueEnvelope.Scale(Value, Base, Exponent) is { } scaled + ? scaled * 100.0 / FullScale + : null; +} /// /// One axis-name slot from cnc_rdaxisname. is the 1-based diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/WireFocasClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/WireFocasClient.cs index 5cfedbcf..5f3f7c61 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/WireFocasClient.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/WireFocasClient.cs @@ -242,34 +242,32 @@ public sealed class WireFocasClient : IFocasClient var result = await _wire.ReadServoMeterAsync(32, cancellationToken).ConfigureAwait(false); if (!result.IsOk || result.Value is null) return []; return result.Value - .Select(m => new FocasServoLoad(m.Name ?? string.Empty, m.Value / Math.Pow(10.0, m.Decimal))) + .Select(m => new FocasServoLoad(m.Name ?? string.Empty, m.Scaled)) .Where(s => s.AxisName.Length > 0) .ToList(); } /// - public Task> GetSpindleLoadsAsync(CancellationToken cancellationToken) => + public Task> GetSpindleLoadsAsync(CancellationToken cancellationToken) => ReadSpindleMetricAsync((sel, ct) => _wire.ReadSpindleLoadAsync(sel, ct), cancellationToken); /// - public Task> GetSpindleMaxRpmsAsync(CancellationToken cancellationToken) => - ReadSpindleMetricAsync((sel, ct) => _wire.ReadSpindleMaxRpmAsync(sel, ct), cancellationToken); + public Task> GetSpindleMotorSpeedsAsync(CancellationToken cancellationToken) => + ReadSpindleMetricAsync((sel, ct) => _wire.ReadSpindleMotorSpeedAsync(sel, ct), cancellationToken); - private static async Task> ReadSpindleMetricAsync( + /// + /// One cnc_rdspmeter read as per-slot percentages (null for an undescribable slot). + /// Every slot is returned — the driver reads only the slots of discovered spindles. (This + /// used to stop at the first zero after slot 1, which both dropped a genuinely idle second + /// spindle and kept non-zero buffer residue.) + /// + private static async Task> ReadSpindleMetricAsync( Func>>> call, CancellationToken cancellationToken) { var result = await call(-1, cancellationToken).ConfigureAwait(false); if (!result.IsOk || result.Value is null) return []; - var list = new List(); - foreach (var m in result.Value) - { - // Fanuc pads unused spindle slots with 0 — stop at the first trailing zero so the - // list length matches the configured spindle count. - if (m.Value == 0 && list.Count > 0) break; - list.Add(m.Value); - } - return list; + return result.Value.Select(m => m.Percent).ToList(); } /// Disposes the wire client and releases all resources. diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs index 3cd1ed28..ce8671e6 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs @@ -140,18 +140,18 @@ internal class FakeFocasClient : IFocasClient public virtual Task> GetServoLoadsAsync(CancellationToken ct) => Task.FromResult>([.. ServoLoads]); - /// Gets the list of spindle loads. - public List SpindleLoads { get; } = []; - /// Gets the list of spindle maximum RPMs. - public List SpindleMaxRpms { get; } = []; + /// Gets the list of spindle load percentages (null = undescribable slot). + public List SpindleLoads { get; } = []; + /// Gets the list of spindle motor-speed percentages (null = undescribable slot). + public List SpindleMotorSpeeds { get; } = []; /// Gets spindle loads asynchronously. /// The cancellation token. - public virtual Task> GetSpindleLoadsAsync(CancellationToken ct) => - Task.FromResult>([.. SpindleLoads]); - /// Gets spindle maximum RPMs asynchronously. + public virtual Task> GetSpindleLoadsAsync(CancellationToken ct) => + Task.FromResult>([.. SpindleLoads]); + /// Gets spindle motor-speed percentages asynchronously. /// The cancellation token. - public virtual Task> GetSpindleMaxRpmsAsync(CancellationToken ct) => - Task.FromResult>([.. SpindleMaxRpms]); + public virtual Task> GetSpindleMotorSpeedsAsync(CancellationToken ct) => + Task.FromResult>([.. SpindleMotorSpeeds]); /// Disposes the client. public virtual void Dispose() diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/live-spmeter-load-0x0040.bin b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/live-spmeter-load-0x0040.bin new file mode 100644 index 0000000000000000000000000000000000000000..3d98bc572046a3a4d0fea08c6761e0959ecf7db3 GIT binary patch literal 92 zcmZ3mV8H?gW<@53AO=Q;03c>$aA1G{Agz3ufs27*ryUam0~Z&=y$m)m&0xv{p+V|^ O;$VP82VGYLivs`@{|OiX literal 0 HcmV?d00001 diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/live-spmeter-maxrpm-0x0040.bin b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/live-spmeter-maxrpm-0x0040.bin new file mode 100644 index 0000000000000000000000000000000000000000..3a4a2415c7375425babc0fe47fa3e6045a07ad21 GIT binary patch literal 92 zcmZ3mV8H?gW<@53AO=Q;03c>$aA1G{AZ`DbfeXlCvfIhP1!S;g+ym1*rVL;jqz)vG LPAgsy0*M0vB4G(` literal 0 HcmV?d00001 diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasDuplicateSiblingNameTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasDuplicateSiblingNameTests.cs index 87bc65f4..a2d1c33b 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasDuplicateSiblingNameTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasDuplicateSiblingNameTests.cs @@ -34,8 +34,8 @@ public sealed class FocasDuplicateSiblingNameTests // 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, 7]); - c.SpindleMaxRpms.AddRange([8_000, 12_000]); + c.SpindleLoads.AddRange([5.0, 7.0]); + c.SpindleMotorSpeeds.AddRange([40.0, 60.0]); return c; }, }; @@ -159,17 +159,17 @@ public sealed class FocasDuplicateSiblingNameTests // 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.Int32, Writable: false), - new FocasTagDefinition("raw/s2load", Host, "FIXED:Spindle/S1_2/Load", FocasDataType.Int32, Writable: false), - new FocasTagDefinition("raw/s1max", Host, "FIXED:Spindle/S1_1/MaxRpm", FocasDataType.Int32, Writable: false), - new FocasTagDefinition("raw/s2max", Host, "FIXED:Spindle/S1_2/MaxRpm", FocasDataType.Int32, Writable: false)); + 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/s1max", "raw/s2max"], CancellationToken.None); + var read = await drv.ReadAsync(["raw/s1load", "raw/s2load", "raw/s1speed", "raw/s2speed"], CancellationToken.None); - read[0].Value.ShouldBe(5); - read[1].Value.ShouldBe(7); - read[2].Value.ShouldBe(8_000); - read[3].Value.ShouldBe(12_000); + 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); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasMeterDecodeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasMeterDecodeTests.cs new file mode 100644 index 00000000..ef0cd867 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasMeterDecodeTests.cs @@ -0,0 +1,127 @@ +using System.Buffers.Binary; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; + +/// +/// Servo (cnc_rdsvmeter, 0x0056) and spindle (cnc_rdspmeter, 0x0040) meter decode. +/// Both are arrays of 8-byte records carrying the same self-describing envelope as every other +/// value block on this wire — [+0..3] i32 value, [+4..5] u16 base (2 or 10), +/// [+6..7] u16 exponent — so scaled = value / base^exponent. +/// Ported from the sister ignitionoee FOCAS driver v0.17.2 / v0.17.3 (Focas.Wire-019). +/// +/// +/// Servo: offset 4 is the BASE, not a decimal count. It holds a steady 10, so the +/// old value / 10^decimal divided every load by 10^10 and published ~0 — on a real +/// Makino cutting at 20,000 rpm the panel read servo 0% on every axis. Servo meters are +/// direct percentages (the live capture shows −57..227). +/// Spindle: a fraction of full scale in 1/16384 units. Settled on the real Z-34184 +/// by regressing the type-1 meter against the independently read cnc_rddynamic2 spindle +/// speed over 596–20,000 rpm: rpm = 1.220749 × meter, and 16384 × 1.220749 = +/// 20000.75 — full scale is the spindle's 20,000 rpm maximum. So the type-1 meter +/// (cnc_rdspmaxrpm by name) is the motor speed as a % of maximum, not a max-rpm +/// constant; the type-0 load meter shares the envelope and the 16384 = 100% scale (its +/// acceleration peaks pin at 32767 — one count short of a 200% overload). +/// +[Trait("Category", "Unit")] +public sealed class FocasMeterDecodeTests +{ + private static byte[] Record(int value, int @base, int exponent) + { + var b = new byte[8]; + BinaryPrimitives.WriteInt32BigEndian(b.AsSpan(0, 4), value); + BinaryPrimitives.WriteUInt16BigEndian(b.AsSpan(4, 2), (ushort)@base); + BinaryPrimitives.WriteUInt16BigEndian(b.AsSpan(6, 2), (ushort)exponent); + return b; + } + + // ---- servo ---- + + [Fact] + public void The_live_servo_capture_reads_direct_percentages_not_value_over_ten_to_the_tenth() + { + var blocks = WireFixtures.Blocks("svmeter-pair-0x0056-0x0089.bin"); + var meters = FocasWireClient.ParseServoMeters( + blocks.First(b => b.Command == 0x0056).Payload, + blocks.First(b => b.Command == 0x0089).Payload, + maxCount: 32); + + meters[0].Base.ShouldBe(10); + meters[0].Exponent.ShouldBe(0); + meters.Take(3).Select(m => m.Scaled).ShouldBe([0.0, 50.0, -3.0]); + } + + [Fact] + public void A_servo_record_applies_its_own_exponent() + { + var m = FocasWireClient.ParseServoMeters(Record(1234, 10, 1), Array.Empty(), 32).Single(); + m.Scaled!.Value.ShouldBe(123.4, 1e-9); + } + + [Theory] + [InlineData(5, 7, 0)] // base neither 2 nor 10 + [InlineData(5, 10, 2560)] // residue past the controlled-axis count + public void An_undescribable_servo_record_has_no_value_not_zero(int value, int @base, int exponent) + { + var m = FocasWireClient.ParseServoMeters(Record(value, @base, exponent), Array.Empty(), 32).Single(); + m.IsValid.ShouldBeFalse(); + m.Scaled.ShouldBeNull(); + } + + // ---- spindle ---- + + [Fact] + public void The_live_spindle_load_capture_is_a_percentage_of_16384() + { + var metrics = FocasWireClient.ParseSpindleMetrics( + WireFixtures.Payload("live-spmeter-load-0x0040.bin", 0x0040)); + + // Slot 1: raw 0x23C3 = 9155, base 10, exponent 0 → 9155 × 100 / 16384 ≈ 55.88 %. + metrics[0].Value.ShouldBe(9155); + metrics[0].Percent!.Value.ShouldBe(9155 * 100.0 / 16384, 1e-9); + // Slot 2 is buffer residue (exponent 0x0A00 = 2560): no value, not a number. + metrics[1].IsValid.ShouldBeFalse(); + metrics[1].Percent.ShouldBeNull(); + } + + [Fact] + public void The_live_motor_speed_capture_is_a_percentage_of_maximum_speed() + { + var metrics = FocasWireClient.ParseSpindleMetrics( + WireFixtures.Payload("live-spmeter-maxrpm-0x0040.bin", 0x0040)); + + // 0x3FFD = 16381 ≈ 99.98 % — a spindle at (just under) its 20,000 rpm maximum. + metrics[0].Value.ShouldBe(16381); + metrics[0].Percent!.Value.ShouldBe(16381 * 100.0 / 16384, 1e-9); + } + + [Theory] + [InlineData(16382, 19998)] + [InlineData(12286, 14998)] + [InlineData(9830, 12000)] + [InlineData(695, 850)] + [InlineData(488, 595)] + public void Percent_reproduces_the_live_motor_speed_regression(int meter, int rpm) + { + var m = FocasWireClient.ParseSpindleMetrics(Record(meter, 10, 0)).Single(); + m.Percent!.Value.ShouldBe(rpm * 100.0 / 20000.0, 0.02); + } + + [Fact] + public void Full_scale_is_16384_and_overload_is_not_clamped() + { + WireSpindleMetric.FullScale.ShouldBe(16384); + FocasWireClient.ParseSpindleMetrics(Record(16384, 10, 0)).Single().Percent.ShouldBe(100.0); + var overload = FocasWireClient.ParseSpindleMetrics(Record(32767, 10, 0)).Single().Percent!.Value; + overload.ShouldBeInRange(199.9, 200.0); + } + + [Fact] + public void The_spindle_envelope_exponent_applies_before_the_full_scale_division() + { + // 163840 / 10^1 = 16384 counts = 100 %. + FocasWireClient.ParseSpindleMetrics(Record(163840, 10, 1)).Single().Percent!.Value.ShouldBe(100.0, 1e-9); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasMeterPublishTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasMeterPublishTests.cs new file mode 100644 index 00000000..3294aa8f --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasMeterPublishTests.cs @@ -0,0 +1,134 @@ +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; + +/// +/// Driver-level publish of servo / spindle meters (Focas.Wire-019 port): percentages publish +/// Good as Float64, an undescribable record publishes BadNoData (never a Good 0), and the +/// spindle motor-speed meter is a live, polled MotorSpeedPct — not the bootstrap-time +/// MaxRpm constant it was mislabelled as. +/// +[Trait("Category", "Unit")] +public sealed class FocasMeterPublishTests +{ + private const string Host = "focas://10.0.0.5:8193"; + + private static FocasDriver NewDriver(FakeFocasClient client) => + new(new FocasDriverOptions + { + Devices = [new FocasDeviceOptions(Host)], + Probe = new FocasProbeOptions { Enabled = false }, + FixedTree = new FocasFixedTreeOptions { Enabled = true, PollInterval = TimeSpan.FromMilliseconds(10) }, + }, "drv-1", new FakeFocasClientFactory { Customise = () => client }); + + private static async Task ReadSettledAsync(FocasDriver drv, string reference, Func settled) + { + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5); + DataValueSnapshot last; + do + { + last = (await drv.ReadAsync([reference], CancellationToken.None)).Single(); + if (settled(last)) return last; + await Task.Delay(15); + } while (DateTime.UtcNow < deadline); + return last; + } + + private static FakeFocasClient MachineClient() + { + var c = new FakeFocasClient(); + c.AxisNames.Clear(); + c.AxisNames.AddRange([new("X", ""), new("Y", "")]); + c.ServoLoads.AddRange([new("X", 46.0), new("Y", null)]); // Y: undescribable record + c.SpindleNames.Clear(); + c.SpindleNames.Add(new("S", "1", "", "")); + c.SpindleLoads.Add(55.88); + c.SpindleMotorSpeeds.Add(2.49); + return c; + } + + [Fact] + public async Task Servo_loads_publish_percentages_and_an_undescribable_record_is_bad() + { + var drv = NewDriver(MachineClient()); + await drv.InitializeAsync("{}", CancellationToken.None); + + var x = await ReadSettledAsync(drv, $"{Host}/Axes/X/ServoLoad", s => s.StatusCode == FocasStatusMapper.Good); + var y = await ReadSettledAsync(drv, $"{Host}/Axes/Y/ServoLoad", s => s.StatusCode == FocasStatusMapper.BadNoData); + + x.Value.ShouldBe(46.0); + y.StatusCode.ShouldBe(FocasStatusMapper.BadNoData); + y.Value.ShouldBeNull(); + await drv.ShutdownAsync(CancellationToken.None); + } + + [Fact] + public async Task Spindle_meters_publish_float64_percentages_and_motor_speed_is_polled_live() + { + var client = MachineClient(); + var drv = NewDriver(client); + await drv.InitializeAsync("{}", CancellationToken.None); + + var load = await ReadSettledAsync(drv, $"{Host}/Spindle/S1/Load", s => s.StatusCode == FocasStatusMapper.Good); + load.Value.ShouldBe(55.88); + (await ReadSettledAsync(drv, $"{Host}/Spindle/S1/MotorSpeedPct", s => s.StatusCode == FocasStatusMapper.Good)) + .Value.ShouldBe(2.49); + + // The spindle speeds up: the poll loop must follow it (MaxRpm was read once at bootstrap). + client.SpindleMotorSpeeds[0] = 99.98; + (await ReadSettledAsync(drv, $"{Host}/Spindle/S1/MotorSpeedPct", s => Equals(s.Value, 99.98))) + .Value.ShouldBe(99.98); + await drv.ShutdownAsync(CancellationToken.None); + } + + [Fact] + public async Task An_undescribable_spindle_slot_is_bad_not_zero() + { + var client = MachineClient(); + client.SpindleLoads[0] = null; + var drv = NewDriver(client); + await drv.InitializeAsync("{}", CancellationToken.None); + + var load = await ReadSettledAsync(drv, $"{Host}/Spindle/S1/Load", s => s.StatusCode == FocasStatusMapper.BadNoData); + + load.StatusCode.ShouldBe(FocasStatusMapper.BadNoData); + load.Value.ShouldBeNull(); + await drv.ShutdownAsync(CancellationToken.None); + } + + [Fact] + public async Task The_spindle_folder_offers_MotorSpeedPct_not_MaxRpm() + { + var drv = NewDriver(MachineClient()); + await drv.InitializeAsync("{}", CancellationToken.None); + await ReadSettledAsync(drv, $"{Host}/Spindle/S1/Load", s => s.StatusCode == FocasStatusMapper.Good); + + var builder = new NameRecordingBuilder(); + await drv.DiscoverAsync(builder, CancellationToken.None); + + builder.Variables.ShouldContain("MotorSpeedPct"); + builder.Variables.ShouldNotContain("MaxRpm"); + await drv.ShutdownAsync(CancellationToken.None); + } + + private sealed class NameRecordingBuilder : IAddressSpaceBuilder + { + public List Variables { get; } = []; + public IAddressSpaceBuilder Folder(string browseName, string displayName) => this; + public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo) + { + Variables.Add(browseName); + return new Handle(attributeInfo.FullName); + } + public void AddProperty(string browseName, DriverDataType dataType, object? value) { } + + private sealed class Handle(string fullRef) : IVariableHandle + { + public string FullReference => fullRef; + public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => throw new NotSupportedException(); + } + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasWireV3Tests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasWireV3Tests.cs index d3866896..c0b6c638 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasWireV3Tests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasWireV3Tests.cs @@ -98,7 +98,7 @@ public sealed class FocasWireV3Tests [Fact] public void ParseServoMeters_uses_an_eight_byte_stride_and_names_from_the_axis_block() { - // Three 8-byte records {int32 data; int16 dec=10; int16 unit=0}: data = 0, 50, -3. + // Three 8-byte records {int32 value; u16 base=10; u16 exponent=0}: value = 0, 50, -3. byte[] sv = [ 0x00, 0x00, 0x00, 0x00, 0x00, 0x0a, 0x00, 0x00, // axis 1: data=0 @@ -120,7 +120,8 @@ public sealed class FocasWireV3Tests meters[0].Value.ShouldBe(0); meters[1].Name.ShouldBe("Y"); meters[1].Value.ShouldBe(50); // 8-byte stride: a 12-byte stride would misread this as 655360 - meters[1].Decimal.ShouldBe((short)10); + meters[1].Base.ShouldBe(10); // offset 4 is the envelope BASE, not a decimal count + meters[1].Scaled.ShouldBe(50.0); meters[2].Name.ShouldBe("Z"); meters[2].Value.ShouldBe(-3); }