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 b6537530..c133937b 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
@@ -109,10 +109,27 @@ public sealed class FocasAlarmProjectionOptions
/// address validation at FocasDriver.InitializeAsync; leave as
/// to skip validation (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).
+///
public sealed record FocasDeviceOptions(
string HostAddress,
string? DeviceName = null,
- FocasCncSeries Series = FocasCncSeries.Unknown);
+ FocasCncSeries Series = FocasCncSeries.Unknown,
+ int PositionDecimalPlaces = 0)
+{
+ ///
+ /// Axis-position decimal places, clamped to a non-negative value so the
+ /// 10^PositionDecimalPlaces divide at the publish seam can never misbehave.
+ ///
+ public int PositionDecimalPlaces { get; init; } =
+ PositionDecimalPlaces < 0 ? 0 : PositionDecimalPlaces;
+}
///
/// One FOCAS-backed OPC UA variable. is the canonical FOCAS
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 7273c002..7aeda5ea 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs
@@ -819,10 +819,19 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
private static void PublishAxisSnapshot(DeviceState state, FocasAxisName axis, FocasDynamicSnapshot snap)
{
var host = state.Options.HostAddress;
- state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axis.Display}/AbsolutePosition")] = snap.AbsolutePosition;
- state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axis.Display}/MachinePosition")] = snap.MachinePosition;
- state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axis.Display}/RelativePosition")] = snap.RelativePosition;
- state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axis.Display}/DistanceToGo")] = snap.DistanceToGo;
+ // cnc_rddynamic2 returns positions as scaled integers; divide by
+ // 10^PositionDecimalPlaces so they surface in engineering units on the Float64
+ // axis nodes. PositionDecimalPlaces is clamped non-negative at config parse, and a
+ // value of 0 yields factor 1.0 — i.e. the integer widened to double, byte-identical
+ // to legacy behaviour (12345 / 1.0 == 12345.0). FeedRate / SpindleSpeed (rate
+ // snapshot) and ServoLoad are NOT position-scaled and are published elsewhere.
+ var factor = state.Options.PositionDecimalPlaces > 0
+ ? Math.Pow(10, state.Options.PositionDecimalPlaces)
+ : 1.0;
+ state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axis.Display}/AbsolutePosition")] = snap.AbsolutePosition / factor;
+ state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axis.Display}/MachinePosition")] = snap.MachinePosition / factor;
+ state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axis.Display}/RelativePosition")] = snap.RelativePosition / factor;
+ state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axis.Display}/DistanceToGo")] = snap.DistanceToGo / factor;
}
private static void PublishRateSnapshot(DeviceState state, FocasDynamicSnapshot snap)
@@ -879,7 +888,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
{
if (!reference.StartsWith(state.Options.HostAddress + "/", StringComparison.OrdinalIgnoreCase)) continue;
if (state.LastFixedSnapshots.TryGetValue(reference, out var raw))
- return new DataValueSnapshot((double)raw, FocasStatusMapper.Good, now, now);
+ return new DataValueSnapshot(raw, FocasStatusMapper.Good, now, now);
// Servo-load match: reference shape is "{host}/Axes/{name}/ServoLoad"
var suffixFull = reference[(state.Options.HostAddress.Length + 1)..];
@@ -1150,8 +1159,13 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
public CancellationTokenSource? FixedTreeCts { get; set; }
/// Gets or sets the fixed-tree cache for this device.
public FocasFixedTreeCache? FixedTreeCache { get; set; }
- /// Gets the last fixed tree snapshots by field name.
- public Dictionary LastFixedSnapshots { get; } = new(StringComparer.OrdinalIgnoreCase);
+ ///
+ /// 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.
+ ///
+ public Dictionary LastFixedSnapshots { get; } = new(StringComparer.OrdinalIgnoreCase);
/// Gets or sets the last program information snapshot.
public FocasProgramInfo? LastProgramInfo { get; set; }
/// Gets or sets the cached first-axis dynamic snapshot — feeds Program/Number, /MainNumber, /Sequence.
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 13a033fa..0ccf9cb4 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverFactoryExtensions.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverFactoryExtensions.cs
@@ -65,7 +65,8 @@ public static class FocasDriverFactoryExtensions
HostAddress: d.HostAddress ?? throw new InvalidOperationException(
$"FOCAS config for '{driverInstanceId}' has a device missing HostAddress"),
DeviceName: d.DeviceName,
- Series: ParseSeries(d.Series ?? dto.Series)))]
+ Series: ParseSeries(d.Series ?? dto.Series),
+ PositionDecimalPlaces: d.PositionDecimalPlaces ?? 0))]
: [],
Tags = dto.Tags is { Count: > 0 }
? [.. dto.Tags.Select(t => new FocasTagDefinition(
@@ -234,6 +235,13 @@ public static class FocasDriverFactoryExtensions
/// Gets or sets the CNC series for this device (overrides top-level series if provided).
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).
+ ///
+ public int? PositionDecimalPlaces { get; init; }
}
internal sealed class FocasTagDto
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPositionScalingTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPositionScalingTests.cs
new file mode 100644
index 00000000..ebc818ae
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPositionScalingTests.cs
@@ -0,0 +1,134 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
+
+///
+/// Phase 4 data-type tier — axis-position scaling. cnc_rddynamic2 returns
+/// positions as scaled integers; the driver applies a 10^PositionDecimalPlaces
+/// divide at the publish seam so positions surface in
+/// engineering units on the Float64 axis nodes. DecimalPlaces is config-supplied
+/// (auto-fetch via cnc_getfigure is deferred — wire-gated).
+///
+[Trait("Category", "Unit")]
+public sealed class FocasPositionScalingTests
+{
+ 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)
+ {
+ var c = new FakeFocasClient();
+ c.AxisNames.Clear();
+ c.AxisNames.Add(new("X", ""));
+ c.DynamicByAxis[1] = snap; // FOCAS axis indexing is 1-based
+ return c;
+ }
+
+ 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