diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasDriverOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasDriverOptions.cs
index da05b679..7929cf98 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasDriverOptions.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasDriverOptions.cs
@@ -132,12 +132,11 @@ public sealed class FocasAlarmProjectionOptions
/// default) to skip range validation and preserve legacy behaviour.
///
///
-/// Axis positions returned by cnc_rddynamic2 are scaled integers. The driver
-/// divides AbsolutePosition / MachinePosition / RelativePosition / DistanceToGo by
-/// 10^PositionDecimalPlaces at the publish seam so they surface in engineering
-/// units on the Float64 axis nodes. Default 0 (no scaling) is byte-identical to
-/// legacy behaviour. Auto-fetching this via cnc_getfigure is deferred (wire-gated),
-/// so it is config-supplied. Negative values are clamped to 0 (no scaling).
+/// Fallback only. A real CNC's cnc_rddynamic2 position block carries its own
+/// scale (raw / base^exponent), which the driver always applies and which wins over
+/// this setting. Only a block that carries no scale (a 4-byte payload, e.g. an older
+/// simulator) is divided by 10^PositionDecimalPlaces. Default 0 (no scaling).
+/// Negative values are clamped to 0.
///
public sealed record FocasDeviceOptions(
string HostAddress,
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 ea5acf3b..164c8070 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs
@@ -925,12 +925,6 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
var sys = await client.GetSysInfoAsync(ct).ConfigureAwait(false);
var axes = await client.GetAxisNamesAsync(ct).ConfigureAwait(false);
- // Per-axis decimal-place figures (cnc_getfigure), fetched once. Auto figures win
- // over the manual PositionDecimalPlaces config at the publish seam; an empty list
- // (or a failed/empty figure read) makes every axis fall back to the config knob.
- // Defensive: a figure-read failure must NOT fault device init — default to empty.
- state.PositionFigures = await SafeProbe(() => client.GetPositionFiguresAsync(ct), []);
-
// Optional-API probes — each returns empty / throws when unsupported.
var spindles = await SafeProbe(() => client.GetSpindleNamesAsync(ct), []);
var spindleMaxRpms = await SafeProbe(() => client.GetSpindleMaxRpmsAsync(ct), []);
@@ -999,7 +993,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
var snap = await client.ReadDynamicAsync(axisIndex, ct).ConfigureAwait(false);
// Keyed by the disambiguated segment, matching what DiscoverAsync emitted —
// two same-named axes must not share (and overwrite) one cache slot (#548).
- PublishAxisSnapshot(state, cache.AxisSegments[i], snap, i); // i = 0-based axis index → PositionFigures lookup
+ PublishAxisSnapshot(state, cache.AxisSegments[i], snap);
if (i == 0) { firstAxisSnap = snap; PublishRateSnapshot(state, snap); }
}
@@ -1102,40 +1096,34 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// → path, which hits
/// and returns these cached values.
///
- private static void PublishAxisSnapshot(DeviceState state, string axisSegment, FocasDynamicSnapshot snap, int axisIndex)
+ private static void PublishAxisSnapshot(DeviceState state, string axisSegment, FocasDynamicSnapshot snap)
{
var host = state.Options.HostAddress;
- // cnc_rddynamic2 returns positions as scaled integers; divide by 10^figures so they
- // surface in engineering units on the Float64 axis nodes. The figure is per-axis:
- // an auto cnc_getfigure figure WINS, and the configured PositionDecimalPlaces is the
- // fallback when the CNC didn't report one for that axis (see AxisFactor). A figure of
- // 0 yields factor 1.0 — i.e. the integer widened to double, byte-identical to legacy
- // behaviour (12345 / 1.0 == 12345.0). The managed WireFocasClient fetches figures live
- // via ReadPositionFiguresAsync; the config knob is the per-axis fallback only. FeedRate /
+ // Each cnc_rddynamic2 position block carries its OWN scale (raw / base^exponent — see
+ // FocasPosition), so positions surface in engineering units with no second read; the
+ // configured PositionDecimalPlaces applies only to a block that carried no scale. A block
+ // describing no usable position (vacant 0xFFFF marker, odd base, absurd exponent) is
+ // cached as NaN and read back as BadNoData for that node alone (see NoValue). FeedRate /
// SpindleSpeed (rate snapshot) and ServoLoad are NOT position-scaled (published elsewhere).
- var factor = AxisFactor(state, axisIndex);
- state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axisSegment}/AbsolutePosition")] = snap.AbsolutePosition / factor;
- state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axisSegment}/MachinePosition")] = snap.MachinePosition / factor;
- state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axisSegment}/RelativePosition")] = snap.RelativePosition / factor;
- state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axisSegment}/DistanceToGo")] = snap.DistanceToGo / factor;
+ var dp = state.Options.PositionDecimalPlaces;
+ state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axisSegment}/AbsolutePosition")] = snap.AbsolutePosition.ToEngineering(dp) ?? NoValue;
+ state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axisSegment}/MachinePosition")] = snap.MachinePosition.ToEngineering(dp) ?? NoValue;
+ state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axisSegment}/RelativePosition")] = snap.RelativePosition.ToEngineering(dp) ?? NoValue;
+ state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axisSegment}/DistanceToGo")] = snap.DistanceToGo.ToEngineering(dp) ?? NoValue;
}
///
- /// Resolve the position-scale factor (10^figure) for a single axis. Auto
- /// (cnc_getfigure) wins per-axis; manual PositionDecimalPlaces is the
- /// fallback when the CNC didn't report a figure for that axis. Both clamp non-negative;
- /// 0 ⇒ factor 1.0 (legacy byte-identical). The managed wire backend fetches figures live
- /// via ReadPositionFiguresAsync; the fallback branch fires only when the CNC
- /// reports no figure for a given axis (or the figure read returns empty).
+ /// Cache sentinel for "the CNC answered, but this field holds no usable value" (an
+ /// undescribable position / meter record). The read path turns it into
+ /// for that node only — never a Good number.
///
- private static double AxisFactor(DeviceState state, int axisIndex)
- {
- var figures = state.PositionFigures;
- var dp = axisIndex >= 0 && axisIndex < figures.Count && figures[axisIndex] >= 0
- ? figures[axisIndex]
- : Math.Max(0, state.Options.PositionDecimalPlaces);
- return dp > 0 ? Math.Pow(10, dp) : 1.0;
- }
+ private const double NoValue = double.NaN;
+
+ /// Builds the snapshot for one cached double, mapping to BadNoData.
+ private static DataValueSnapshot CachedDouble(double value, uint status, DateTime sourceUtc, DateTime now) =>
+ double.IsNaN(value)
+ ? new DataValueSnapshot(null, status == FocasStatusMapper.Good ? FocasStatusMapper.BadNoData : status, sourceUtc, now)
+ : new DataValueSnapshot(value, status, sourceUtc, now);
private static void PublishRateSnapshot(DeviceState state, FocasDynamicSnapshot snap)
{
@@ -1241,7 +1229,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
if (state.LastFixedSnapshots.TryGetValue(
FixedTreeReference(state.Options.HostAddress, path), out var raw))
- return new DataValueSnapshot(raw, status, sourceUtc, now);
+ return CachedDouble(raw, status, sourceUtc, now);
// Servo-load match: path shape is "Axes/{name}/ServoLoad"
var suffixFull = path;
@@ -1249,7 +1237,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
{
var axisName = suffixFull["Axes/".Length..^"/ServoLoad".Length];
if (state.LastServoLoads.TryGetValue(axisName, out var load))
- return new DataValueSnapshot(load, status, sourceUtc, now);
+ return CachedDouble(load, status, sourceUtc, now);
}
// Spindle matches: "Spindle/{name}/Load" + "Spindle/{name}/MaxRpm"
@@ -1578,9 +1566,10 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
public DateTime? LastFixedPollUtc { get; set; }
///
/// Gets the last fixed-tree snapshots by field name. Double-typed so axis
- /// positions can carry the 10^PositionDecimalPlaces engineering-unit
- /// scale applied at ; integer fields
- /// (feed rate, spindle speed) widen to double on store.
+ /// positions can carry the engineering-unit scale applied at
+ /// ; integer fields (feed rate, spindle speed) widen
+ /// to double on store. (NaN) marks a field the CNC described as
+ /// holding no value.
///
public ConcurrentDictionary LastFixedSnapshots { get; } = new(StringComparer.OrdinalIgnoreCase);
/// Gets or sets the last program information snapshot.
@@ -1593,13 +1582,6 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
public ConcurrentDictionary LastServoLoads { get; } = new(StringComparer.OrdinalIgnoreCase);
/// Gets the last spindle load percentages by spindle index.
public ConcurrentDictionary LastSpindleLoads { get; } = new();
- ///
- /// Gets or sets the per-axis position decimal-place figures fetched once at init via
- /// cnc_getfigure (parallel to the axis-name list; index = axis). An auto figure
- /// for an axis WINS over the configured PositionDecimalPlaces; an empty list (or
- /// a failed/empty figure read) makes every axis fall back to that config knob.
- ///
- public IReadOnlyList PositionFigures { get; set; } = [];
/// Disposes the FOCAS client instance.
public void DisposeClient()
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverFactoryExtensions.cs
index 8fbf4538..60a3a31f 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverFactoryExtensions.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverFactoryExtensions.cs
@@ -290,9 +290,9 @@ public static class FocasDriverFactoryExtensions
public string? Series { get; init; }
///
- /// Gets or sets the axis-position decimal places. cnc_rddynamic2 returns
- /// positions as scaled integers; the driver divides by 10^PositionDecimalPlaces
- /// so they surface in engineering units. Omitted / 0 = no scaling (legacy).
+ /// Gets or sets the fallback axis-position decimal places, applied only to a
+ /// cnc_rddynamic2 position block that carries no scale of its own (a real CNC's
+ /// block does, and its scale wins). Omitted / 0 = no scaling.
///
public int? PositionDecimalPlaces { get; init; }
}
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 3242223e..7a1d7d69 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs
@@ -167,16 +167,6 @@ public interface IFocasClient : IDisposable
/// The cancellation token.
/// Per-spindle maximum RPM values, indexed as per .
Task> GetSpindleMaxRpmsAsync(CancellationToken cancellationToken);
-
- ///
- /// Read the per-axis position decimal-place figure via cnc_getfigure. The returned
- /// list is parallel to (index = axis). An EMPTY list means the
- /// CNC/backend does not report figures — the driver then falls back to the configured
- /// PositionDecimalPlaces. Values are clamped non-negative.
- ///
- /// The cancellation token.
- /// Per-axis position decimal-place figures; empty when the CNC/backend doesn't report them.
- Task> GetPositionFiguresAsync(CancellationToken cancellationToken);
}
/// One servo-meter entry — one axis's current load percentage.
@@ -238,12 +228,6 @@ public sealed record FocasSpindleName(string Name, string Suffix1, string Suffix
}
}
-///
-/// Fast-poll bundle for one axis. Position values are scaled integers; the caller
-/// divides by 10^DecimalPlaces to get the decimal value. DecimalPlaces is
-/// supplied by the caller — either via device config (PositionDecimalPlaces) or
-/// the per-axis (cnc_getfigure) path.
-///
///
/// Program + operation-mode snapshot. Name is the currently-executing
/// program filename (e.g. "O0001.NC"); ONumber is its Fanuc O-number (1-9999).
@@ -277,6 +261,10 @@ public static class FocasOpMode
};
}
+///
+/// Fast-poll bundle for one axis (cnc_rddynamic2). Each position carries its own scale
+/// (see ); the driver converts it to engineering units at publish.
+///
public sealed record FocasDynamicSnapshot(
int AxisIndex,
int AlarmFlags,
@@ -285,10 +273,63 @@ public sealed record FocasDynamicSnapshot(
int SequenceNumber,
int ActualFeedRate,
int ActualSpindleSpeed,
- int AbsolutePosition,
- int MachinePosition,
- int RelativePosition,
- int DistanceToGo);
+ FocasPosition AbsolutePosition,
+ FocasPosition MachinePosition,
+ FocasPosition RelativePosition,
+ FocasPosition DistanceToGo);
+
+///
+/// One cnc_rddynamic2 position (command 0x0026) as the CNC reported it. The 8-byte
+/// wire block is self-describing — [0..3] i32 raw, [4..5] u16 base (2 or 10),
+/// [6..7] u16 exponent (0xFFFF = the slot holds no valid position) — and the real
+/// coordinate is raw / base^exponent. Confirmed on a real FANUC 31i-B: every block of the
+/// ref-dynamic2-axis1.bin capture reports base 10 / exponent 4 (280.1574 mm, not
+/// 2801574). That envelope is why no separate figure read exists: the cnc_getfigure
+/// (0x00D3) this driver used to send is not a FOCAS wire command, and the 31i-B refuses it.
+///
+/// The raw i32 position value.
+/// The block's scale base (only 2 and 10 are meaningful).
+/// The block's scale exponent.
+///
+/// True when the block carried the base/exponent envelope. A scale-less block (a 4-byte payload
+/// from an older simulator, or a raw integer a test supplies) falls back to the device's
+/// configured PositionDecimalPlaces.
+///
+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 position in engineering units: Raw / Base^Exponent when the block carried its
+ /// own scale (the configured fallback is then ignored), else Raw / 10^fallbackDecimalPlaces.
+ /// Null when is false.
+ ///
+ /// The device's configured PositionDecimalPlaces.
+ /// The scaled position, or null when the block describes none.
+ public double? ToEngineering(int fallbackDecimalPlaces)
+ {
+ if (!IsValid) return null;
+ if (HasScale) return Exponent == 0 ? Raw : Raw / Math.Pow(Base, Exponent);
+ var dp = Math.Max(0, fallbackDecimalPlaces);
+ return dp > 0 ? Raw / Math.Pow(10, dp) : Raw;
+ }
+
+ /// A bare integer is a scale-less position (legacy shape; tests and fakes).
+ /// The raw position value.
+ public static implicit operator FocasPosition(int raw) => new(raw);
+}
///
/// One active alarm surfaced by . Shape
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 66010716..59542528 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/SynchronizedFocasClient.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/SynchronizedFocasClient.cs
@@ -106,10 +106,6 @@ public sealed class SynchronizedFocasClient : IFocasClient
public Task> GetSpindleMaxRpmsAsync(CancellationToken cancellationToken) =>
RunBoundedAsync(ct => _inner.GetSpindleMaxRpmsAsync(ct), cancellationToken);
- ///
- public Task> GetPositionFiguresAsync(CancellationToken cancellationToken) =>
- RunBoundedAsync(ct => _inner.GetPositionFiguresAsync(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 9b823200..fa997f6f 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
@@ -339,21 +339,59 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
var rc = AggregateRc(blocks);
if (rc != 0) return new FocasResult(rc, null);
+ return new FocasResult(rc, ParseDynamic2(blocks));
+ }
+
+ ///
+ /// Aggregate the cnc_rddynamic2 response blocks of one axis read into a
+ /// . The four 0x0026 blocks are taken in request order
+ /// (absolute / machine / relative / distance-to-go), each decoded with its own scale by
+ /// .
+ ///
+ /// The response blocks of one request.
+ /// The decoded fast-poll bundle.
+ internal static WireDynamic ParseDynamic2(IReadOnlyList blocks)
+ {
var programPayload = FindPayload(blocks, 0x001c);
- return new FocasResult(
- rc,
- new WireDynamic(
- ReadFirstInt32(blocks, 0x001a),
- programPayload.Length >= 4 ? ReadInt32(programPayload, 0) : 0,
- programPayload.Length >= 8 ? ReadInt32(programPayload, 4) : 0,
- ReadFirstInt32(blocks, 0x001d),
- ReadFirstInt32(blocks, 0x0024),
- ReadFirstInt32(blocks, 0x0025),
- new WireAxisPosition(
- ReadSelectorPosition(blocks, 0x0026, 0),
- ReadSelectorPosition(blocks, 0x0026, 1),
- ReadSelectorPosition(blocks, 0x0026, 2),
- ReadSelectorPosition(blocks, 0x0026, 3))));
+ return new WireDynamic(
+ ReadFirstInt32(blocks, 0x001a),
+ programPayload.Length >= 4 ? ReadInt32(programPayload, 0) : 0,
+ programPayload.Length >= 8 ? ReadInt32(programPayload, 4) : 0,
+ ReadFirstInt32(blocks, 0x001d),
+ ReadFirstInt32(blocks, 0x0024),
+ ReadFirstInt32(blocks, 0x0025),
+ new WireAxisPosition(
+ ReadSelectorPosition(blocks, 0x0026, 0),
+ ReadSelectorPosition(blocks, 0x0026, 1),
+ ReadSelectorPosition(blocks, 0x0026, 2),
+ ReadSelectorPosition(blocks, 0x0026, 3)));
+ }
+
+ ///
+ /// Decode one cnc_rddynamic2 position block (0x0026). The 8-byte block is
+ /// self-describing — [0..3] i32 raw, [4..5] u16 base, [6..7] u16 exponent,
+ /// value = raw / base^exponent — pinned on a real FANUC 31i-B (every block of
+ /// ref-dynamic2-axis1.bin reports base 10 / exponent 4). A 4..7-byte block carries no
+ /// envelope and is returned scale-less (the driver then applies the configured
+ /// PositionDecimalPlaces); a shorter one decodes as a scale-less 0, as before.
+ ///
+ ///
+ /// Validity (vacant 0xFFFF exponent, a base other than 2/10, an absurd exponent) is a
+ /// per-node quality decision made by , not a fault here:
+ /// raising would let one empty slot take out the whole poll. Ported from the sister
+ /// ignitionoee driver v0.17.0 (Dynamic2.scaledPosition, Focas.Wire-014).
+ ///
+ /// The 0x0026 response-block payload.
+ /// The position with whatever scale the block carried.
+ internal static FocasPosition ParsePositionBlock(byte[] payload)
+ {
+ if (payload.Length >= 8)
+ return new FocasPosition(
+ ReadInt32(payload, 0),
+ BinaryPrimitives.ReadUInt16BigEndian(payload.AsSpan(4, 2)),
+ BinaryPrimitives.ReadUInt16BigEndian(payload.AsSpan(6, 2)),
+ HasScale: true);
+ return new FocasPosition(payload.Length >= 4 ? ReadInt32(payload, 0) : 0);
}
/// Read servo-meter load percentages via cnc_rdsvmeter (command 0x0056).
@@ -382,42 +420,6 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
return new FocasResult>(rc, result);
}
- ///
- /// Read the per-axis position decimal-place figures via cnc_getfigure
- /// (command 0x00d3). The response payload is a self-delimiting sequence of
- /// big-endian short decimal-place counts — one per configured axis — which the
- /// driver applies as a 10^figure scale on raw position reads.
- ///
- ///
- /// The 0x00d3 command id is co-designed with the in-tree focas_mock sim and
- /// validated against it; like the rest of this managed wire backend the binary shape is
- /// sim-consistent and has not been validated against a real Fanuc CNC (bench-CNC-gated).
- ///
- /// Cancellation token for the read operation.
- /// Optional per-call timeout override.
- /// Optional path ID override; defaults to .
- /// The per-axis position decimal-place figures, wrapped in a .
- public async Task>> ReadPositionFiguresAsync(
- CancellationToken cancellationToken = default,
- TimeSpan? timeout = null,
- ushort? pathId = null)
- {
- using var callTimeout = CreateCallTimeout(cancellationToken, timeout);
- var requestPathId = EffectivePathId(pathId);
- var block = await SendSingleRequestAsync(
- callTimeout.Token,
- new RequestBlock(0x00d3, PathId: requestPathId)).ConfigureAwait(false);
-
- if (block.Rc != 0) return new FocasResult>(block.Rc, null);
-
- var payload = block.Payload;
- var figures = new List(payload.Length / 2);
- for (var offset = 0; offset + 2 <= payload.Length; offset += 2)
- figures.Add(ReadInt16(payload, offset));
-
- return new FocasResult>(block.Rc, figures);
- }
-
/// Read per-spindle load percentages via cnc_rdspload (command 0x0040 with arg1=0).
/// Spindle selector; -1 selects all spindles.
/// Cancellation token for the read operation.
@@ -1044,17 +1046,17 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
return payload.Length >= 4 ? ReadInt32(payload, 0) : 0;
}
- private static int ReadSelectorPosition(IReadOnlyList blocks, ushort command, int selectorIndex)
+ private static FocasPosition ReadSelectorPosition(IReadOnlyList blocks, ushort command, int selectorIndex)
{
var seen = 0;
foreach (var block in blocks)
{
if (block.Command != command) continue;
if (seen == selectorIndex)
- return block.Payload.Length >= 4 ? ReadInt32(block.Payload, 0) : 0;
+ return ParsePositionBlock(block.Payload);
seen++;
}
- return 0;
+ return new FocasPosition(0);
}
private static WireSysInfo ParseSysInfo(byte[] payload)
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 7e246fc9..25491a17 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
@@ -35,12 +35,15 @@ public sealed record WireStatus(
short Edit,
short TmMode);
-/// Four-slot position quadruple for one axis: absolute / machine / relative / distance-to-go.
+///
+/// Four-slot position quadruple for one axis: absolute / machine / relative / distance-to-go,
+/// each carrying the scale its own 0x0026 block reported.
+///
public sealed record WireAxisPosition(
- int Absolute,
- int Machine,
- int Relative,
- int Distance);
+ FocasPosition Absolute,
+ FocasPosition Machine,
+ FocasPosition Relative,
+ FocasPosition Distance);
///
/// Fast-poll bundle for one axis from cnc_rddynamic2 — alarm flags, active program
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 2b96b73c..5cfedbcf 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
@@ -181,6 +181,8 @@ public sealed class WireFocasClient : IFocasClient
ThrowIfRcNonZero(result.Rc, "cnc_rddynamic2", result.IsOk);
var d = result.Value!;
var pos = d.Axis ?? new WireAxisPosition(0, 0, 0, 0);
+ // Positions keep the scale their own 0x0026 block reported; the driver converts them to
+ // engineering units at publish (FocasPosition.ToEngineering).
return new FocasDynamicSnapshot(
AxisIndex: axisIndex,
AlarmFlags: d.Alarm,
@@ -253,24 +255,6 @@ public sealed class WireFocasClient : IFocasClient
public Task> GetSpindleMaxRpmsAsync(CancellationToken cancellationToken) =>
ReadSpindleMetricAsync((sel, ct) => _wire.ReadSpindleMaxRpmAsync(sel, ct), cancellationToken);
- ///
- // Reads via FocasWireClient.ReadPositionFiguresAsync; returns an empty list when the read
- // fails (non-zero RC — e.g. the series lacks cnc_getfigure) or is not connected. Never
- // throws — figure reads degrade to the configured PositionDecimalPlaces rather than faulting.
- public async Task> GetPositionFiguresAsync(CancellationToken cancellationToken)
- {
- if (!_wire.IsConnected) return [];
- try
- {
- var result = await _wire.ReadPositionFiguresAsync(cancellationToken).ConfigureAwait(false);
- return result.IsOk && result.Value is { } figures ? figures : [];
- }
- catch (FocasWireException)
- {
- return [];
- }
- }
-
private static async Task> ReadSpindleMetricAsync(
Func>>> call,
CancellationToken cancellationToken)
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 a488df99..3cd1ed28 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
@@ -153,13 +153,6 @@ internal class FakeFocasClient : IFocasClient
public virtual Task> GetSpindleMaxRpmsAsync(CancellationToken ct) =>
Task.FromResult>([.. SpindleMaxRpms]);
- /// Gets or sets the per-axis position decimal-place figures returned by .
- public IReadOnlyList PositionFigures { get; set; } = [];
- /// Gets the per-axis position decimal-place figures asynchronously.
- /// The cancellation token.
- public virtual Task> GetPositionFiguresAsync(CancellationToken ct) =>
- Task.FromResult(PositionFigures);
-
/// Disposes the client.
public virtual void Dispose()
{
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/live-dynamic2-axis1-0x0026.bin b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/live-dynamic2-axis1-0x0026.bin
new file mode 100644
index 00000000..7f51296a
Binary files /dev/null and b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/live-dynamic2-axis1-0x0026.bin differ
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/live-dynamic2-axis2-0x0026.bin b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/live-dynamic2-axis2-0x0026.bin
new file mode 100644
index 00000000..cc7308a8
Binary files /dev/null and b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/live-dynamic2-axis2-0x0026.bin differ
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPositionAutoScaleTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPositionAutoScaleTests.cs
deleted file mode 100644
index 9c698a6b..00000000
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPositionAutoScaleTests.cs
+++ /dev/null
@@ -1,164 +0,0 @@
-using Shouldly;
-using Xunit;
-using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
-
-namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
-
-///
-/// Phase 4b — per-axis auto-scale from cnc_getfigure figures. The driver fetches
-/// per-axis position decimal-place figures at init and applies them at the
-/// publish seam. Precedence: an auto figure for an axis WINS;
-/// the configured PositionDecimalPlaces is the per-axis fallback when the CNC did
-/// not report a figure for that axis. These tests drive the Fake to exercise the driver's
-/// auto-scale-vs-fallback logic independently of the wire path.
-///
-[Trait("Category", "Unit")]
-public sealed class FocasPositionAutoScaleTests
-{
- private const string Host = "focas://10.0.0.5:8193";
-
- // A single-axis CNC (X only) so the snapshot we seed is unambiguous.
- private static FakeFocasClient SingleAxisClient(
- FocasDynamicSnapshot snap, IReadOnlyList figures)
- {
- var c = new FakeFocasClient();
- c.AxisNames.Clear();
- c.AxisNames.Add(new("X", ""));
- c.DynamicByAxis[1] = snap; // FOCAS axis indexing is 1-based
- c.PositionFigures = figures;
- return c;
- }
-
- // A two-axis CNC (X, Y) — each axis gets its own seeded snapshot + figure.
- private static FakeFocasClient TwoAxisClient(
- FocasDynamicSnapshot xSnap, FocasDynamicSnapshot ySnap, IReadOnlyList figures)
- {
- var c = new FakeFocasClient();
- c.AxisNames.Clear();
- c.AxisNames.Add(new("X", ""));
- c.AxisNames.Add(new("Y", ""));
- c.DynamicByAxis[1] = xSnap; // axis 0 → 1-based index 1
- c.DynamicByAxis[2] = ySnap; // axis 1 → 1-based index 2
- c.PositionFigures = figures;
- return c;
- }
-
- private static FocasDynamicSnapshot AbsSnap(int axisIndex, int absolutePosition) =>
- new(AxisIndex: axisIndex, AlarmFlags: 0, ProgramNumber: 0, MainProgramNumber: 0,
- SequenceNumber: 0, ActualFeedRate: 0, ActualSpindleSpeed: 0,
- AbsolutePosition: absolutePosition, MachinePosition: 0,
- RelativePosition: 0, DistanceToGo: 0);
-
- private static FocasDriver NewDriver(int positionDecimalPlaces, FakeFocasClientFactory factory)
- {
- return new FocasDriver(new FocasDriverOptions
- {
- Devices = [new FocasDeviceOptions(Host, PositionDecimalPlaces: positionDecimalPlaces)],
- Probe = new FocasProbeOptions { Enabled = false },
- FixedTree = new FocasFixedTreeOptions
- {
- Enabled = true,
- PollInterval = TimeSpan.FromMilliseconds(10),
- },
- }, "drv-1", factory);
- }
-
- // Drive the fixed-tree poll loop and wait (bounded) for the cached value to appear.
- private static async Task
@@ -200,29 +200,6 @@ public sealed class FocasWireProtocolTests
Should.Throw(() => FocasWireProtocol.ParseResponseBlocks(body));
}
- // ---- The cnc_getfigure figure stride: decode a response block into shorts ----
-
- [Fact]
- public void ParseResponseBlocks_yields_a_payload_that_decodes_as_a_sequence_of_figure_shorts()
- {
- // cnc_getfigure (command 0x00d3) returns one big-endian short per axis. Build the
- // response block and assert the payload decodes to the [3, 1, 0] figure sequence the
- // driver's auto-scale path reads.
- var figurePayload = new byte[6];
- BinaryPrimitives.WriteInt16BigEndian(figurePayload.AsSpan(0, 2), 3);
- BinaryPrimitives.WriteInt16BigEndian(figurePayload.AsSpan(2, 2), 1);
- BinaryPrimitives.WriteInt16BigEndian(figurePayload.AsSpan(4, 2), 0);
-
- var body = BuildResponseBody((command: (ushort)0x00d3, rc: (short)0, payload: figurePayload));
- var block = FocasWireProtocol.ParseResponseBlocks(body).Single();
-
- var figures = new List();
- for (var offset = 0; offset + 2 <= block.Payload.Length; offset += 2)
- figures.Add(BinaryPrimitives.ReadInt16BigEndian(block.Payload.AsSpan(offset, 2)));
-
- figures.ShouldBe(new[] { 3, 1, 0 });
- }
-
// ---- ReadAscii / ReadNameRecord ----
[Fact]