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 00000000..3d98bc57
Binary files /dev/null and b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/live-spmeter-load-0x0040.bin differ
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 00000000..3a4a2415
Binary files /dev/null and b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/live-spmeter-maxrpm-0x0040.bin differ
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);
}