diff --git a/docs/drivers/FOCAS-Test-Fixture.md b/docs/drivers/FOCAS-Test-Fixture.md index 4cda662c..7875aedc 100644 --- a/docs/drivers/FOCAS-Test-Fixture.md +++ b/docs/drivers/FOCAS-Test-Fixture.md @@ -61,16 +61,21 @@ binary PDUs against the documented command IDs. Covered assertions: - Session open / close (`cnc_allclibhndl3` + `cnc_freelibhndl`) - Parameter read-back after `mock_patch` seed → `cnc_rdparam` -- Macro read-back after seed → `cnc_rdmacro` (scaled-decimal - translation verified) +- Macro read-back after seed → `cnc_rdmacro` (8-byte ODBM shape: + 9-digit mantissa, `dec_val` at offset 6; vacant / fractional-on-integer + macros read Bad) - PMC range read after seed → `pmc_rdpmcrng` - `IAlarmSource` raise + clear transitions after `mock_patch` alarm-list changes → `cnc_rdalmmsg2` - Fixed-tree bootstrap: identity / axes / spindle / program / timers / servo meters populate via `cnc_sysinfo`, `cnc_rdaxisname`, `cnc_rdspdlname`, `cnc_rddynamic2`, `cnc_exeprgname2`, - `cnc_rdblkcount`, `cnc_rdopmode`, `cnc_rdsvmeter`, `cnc_rdspload`, - `cnc_rdspmaxrpm`, `cnc_rdtimer` + `cnc_rdblkcount`, `cnc_rdopmode`, `cnc_rdsvmeter`, `cnc_rdspmeter` + (load + motor speed, 1/16384 of full scale), `cnc_rdtimer`; positions + scale from each `cnc_rddynamic2` block's own base/exponent (the mock + refuses the non-existent `cnc_getfigure` 0x00D3, as a real 31i-B does). + The vendored mock's local divergence from upstream is listed in + `Docker/focas-mock/VENDORED.md`. - Per-series profile selection via `mock_load_profile` — tests can pin one profile and assert series-gated capability suppression diff --git a/docs/drivers/FOCAS.md b/docs/drivers/FOCAS.md index caa1d29b..3df703f6 100644 --- a/docs/drivers/FOCAS.md +++ b/docs/drivers/FOCAS.md @@ -31,8 +31,10 @@ protocol using the documented command IDs. Writes return > 31i-B (`10.201.31.5`) 2026-06-25:** sysinfo, axis/spindle names, dynamic positions/feed/spindle, > program/mode, **macros**, **timers** (8-byte payload is little-endian {minute, msec}), **PMC range** > (request widened to the data-type byte width), **servo meters** (8-byte LOADELM stride + names from -> the 0x0089 block; load *scaling* still to be confirmed at commissioning), and **alarms** all read -> correctly. The driver `cnc_rddynamic2` poll is 1-based. **`cnc_rdparam` is unsupported on this +> the 0x0089 block), and **alarms** all read correctly. Several *decode* errors that survived that +> validation (because they decode without failing — just wrongly) were later corrected from the +> sister ignitionoee driver's live work on the same family of controls; see +> [Wire decode corrections (2026-10)](#wire-decode-corrections-2026-10). The driver `cnc_rddynamic2` poll is 1-based. **`cnc_rdparam` is unsupported on this > control** — every request-framing variant returns `EW_FUNC` (likely a wrong v3 command id; needs a > reference FWLIB trace). The Test-Connect probe now runs a real wire session (initiate + cnc_statinfo) > instead of a bare TCP check. Full finding, captured wire bytes, validation results, and analysis: @@ -187,11 +189,14 @@ fixed-tree paths are discovered from the machine, not composed from parts. Two caveats worth knowing before you browse: -- **A real CNC can report duplicate sibling names, and they are disambiguated - automatically** (#548). One observed Makino returns two axes both called `AA`; - every member of such a collision set gets a 1-based occurrence suffix in slot - order — the tree offers `Axes/AA_1/` and `Axes/AA_2/`, each reading its own - axis — while unique names stay bare (`Axes/Y/`). The bare name of a duplicate +- **A CNC that reports duplicate sibling names is disambiguated automatically** + (#548): every member of a collision set gets a 1-based occurrence suffix in slot + order (`Axes/AA_1/`, `Axes/AA_2/`), each reading its own axis, while unique names + stay bare (`Axes/Y/`). ⚠️ The Makino that motivated #548 does **not** in fact + report duplicates — its axes are `AA6` and `AA7`, and the wire layer used to read + only 2 of each 4-byte name record. Since the name-record fix those axes publish as + `Axes/AA6/` and `Axes/AA7/`; tags committed against `Axes/AA_1/…` / `Axes/AA_2/…` + on such a machine must be re-browsed and re-committed. The bare name of a duplicate set deliberately does not resolve: a tag authored against `Axes/AA/…` on such a machine reads `BadNodeIdUnknown` rather than one arbitrary axis's data. The suffix counts occurrences of the *same* name, so adding an unrelated axis @@ -212,8 +217,8 @@ What gets populated (all under `FOCAS/{deviceHostAddress}/`, addressed as | `Identity/` | `SeriesNumber`, `Version`, `MaxAxes`, `CncType`, `MtType`, `AxisCount` | `cnc_sysinfo` once at bootstrap | | `Axes/{name}/` | `AbsolutePosition`, `MachinePosition`, `RelativePosition`, `DistanceToGo`, `ServoLoad` — one folder per discovered axis | `cnc_rdaxisname` once + `cnc_rddynamic2` + `cnc_rdsvmeter` per tick | | `Axes/FeedRate/Actual`, `Axes/SpindleSpeed/Actual` | Current feed + spindle RPM | `cnc_rddynamic2` | -| `Spindle/{name}/` | `Load` (percentage), `MaxRpm` — one folder per discovered spindle | `cnc_rdspdlname` once + `cnc_rdspload` + `cnc_rdspmaxrpm` | -| `Program/` | `Name` (filename), `ONumber`, `Number`, `MainNumber`, `Sequence`, `BlockCount` | `cnc_exeprgname2` + `cnc_rdblkcount` + cached `cnc_rddynamic2` | +| `Spindle/{name}/` | `Load` (Float64, % of rated load), `MotorSpeedPct` (Float64, % of maximum speed) — one folder per discovered spindle | `cnc_rdspdlname` once + `cnc_rdspmeter` type 0 + type 1 per tick | +| `Program/` | `Name` (filename), `ONumber`, `Number`, `MainNumber`, `Sequence`, `BlockCount` — a field the CNC did not report (refused sub-read, payload without an O-number) reads `BadNoData`, never `0` | `cnc_exeprgname2` + `cnc_rdblkcount` + cached `cnc_rddynamic2` | | `OperationMode/` | `Mode` (int), `ModeText` ("AUTO", "MDI", "EDIT", …) | `cnc_rdopmode` | | `Timers/` | `PowerOnSeconds`, `OperatingSeconds`, `CuttingSeconds`, `CycleSeconds` | `cnc_rdtimer` × 4 | @@ -228,27 +233,29 @@ per-axis `ServoLoad` independently. Identity + `Axes/*` position reads (which every Fanuc CNC supports) are always emitted. Position values (`AbsolutePosition`, `MachinePosition`, `RelativePosition`, `DistanceToGo`) -are CNC-internal scaled integers exposed as `Float64` OPC UA nodes. The driver converts them -to engineering units using the per-device `PositionDecimalPlaces` config field (default `0` -= no scaling). When set to a positive integer *d*, each position value is divided by `10^d` -before publishing, so a CNC that reports millimetres × 1000 is corrected by setting -`PositionDecimalPlaces: 3`. +are published as `Float64` in engineering units. **Each `cnc_rddynamic2` position block carries +its own scale** — `[i32 raw][u16 base][u16 exponent]`, value = `raw / base^exponent` (a real +31i-B reports base 10 / exponent 4: `2801574` → `280.1574` mm) — and the driver always applies +it. A block that describes no usable position (the `0xFFFF` vacant-slot marker, a base other +than 2 or 10, an exponent > 18) publishes `BadNoData` on that node alone. + +`PositionDecimalPlaces` (per device, default `0`) is now only the **fallback for a block that +carries no scale** (a 4-byte payload, e.g. an older simulator): such a position is divided by +`10^PositionDecimalPlaces`. Negative values are clamped to `0`. ```jsonc "Devices": [ { "HostAddress": "focas://10.20.30.40:8193", "Series": "ThirtyOne_i", - "PositionDecimalPlaces": 3 // 123456 → 123.456 mm + "PositionDecimalPlaces": 3 // only used if the CNC's blocks carry no scale } ] ``` -The driver now attempts to auto-scale each axis position using the CNC's `cnc_getfigure` figure when available; `PositionDecimalPlaces` is the **per-axis fallback** used only when the CNC reports no figure for that axis. **Caveat:** the managed `WireFocasClient` (FOCAS/2 Ethernet wire protocol) does not issue `cnc_getfigure` today, so on real hardware all axes still fall back to the manual `PositionDecimalPlaces` config field. Live auto-fetch is a follow-up requiring a `cnc_getfigure` command in `FocasWireClient`. Negative values from `PositionDecimalPlaces` are clamped to `0`. - -**Follow-ups (known gaps, non-blocking):** -- No upper-bound clamp on the figure magnitude returned by the CNC — a misbehaving figure could overflow `Math.Pow`; a max-figure guard should be added. -- The negative-figure branch (falls back to manual `PositionDecimalPlaces`) is handled but not explicitly unit-tested. +There is no `cnc_getfigure` read: the `0x00D3` command the driver used to send is not a FOCAS +wire command (a real 31i-B refuses it with `rc=1`), so on hardware every axis had silently +fallen back to `PositionDecimalPlaces` and published raw counts. **Still hand-authored**: `PARAM:6711`, `MACRO:500`, `R100` etc. — specific numbers whose meaning is MTB-specific, and which no browse can discover. Those @@ -330,7 +337,34 @@ latency spike once per cadence. | Every read returns `BadNotWritable` on writes | Expected — OtOpcUa is read-only against FOCAS | If you actually need writes, open a feature request — the driver's managed wire client doesn't expose the write commands | | `BadOutOfRange` on reads for a macro/parameter | Config address outside the declared `Series` range | Check `docs/v2/focas-version-matrix.md` — either fix the address or widen the `Series` | | Alarm events never fire | `AlarmProjection.Enabled` left at default (false) | Set it to `true` in the driver config | -| Axis position values seem 1000× too large | `PositionDecimalPlaces` not set | Add `"PositionDecimalPlaces": 3` (or the MTB-specific value) to the device entry in `Devices[]` | +| Axis position values seem 1000× too large | The CNC's position blocks carry no scale (older control / simulator) and `PositionDecimalPlaces` is not set | Add `"PositionDecimalPlaces": 3` (or the MTB-specific value) to the device entry in `Devices[]`. A real 30i/31i carries the scale in each block and needs no setting | +| A fixed-tree node or `MACRO:` tag reads `BadNoData` | The CNC answered but described no value — a vacant macro variable, an undescribable position/meter record, a program field it did not report | Check the address on the control; this is the control's answer, not a comms fault | +| An integer-typed `MACRO:` tag reads `BadTypeMismatch` | The macro holds a fractional value (e.g. `148.5`) | Author the tag as `Float64`, or point it at a whole-valued variable — a fraction is never truncated into an integer tag | + +## Wire decode corrections (2026-10) + +Each of these decoded without ever failing — just wrongly — so live validation that only checked +"the read succeeds" passed them. All were found by the sister **ignitionoee** FOCAS driver +against a real Makino 31i-B (Z-34184, series G431) and ported here with its captured payloads as +test fixtures (`tests/.../Driver.FOCAS.Tests/Fixtures/v3/`). Client-visible changes are marked ⚠️. + +| What | Before | Now | +|------|--------|-----| +| `cnc_rdmacro` decimal | read at payload offset 4 — a constant `10` length field — so `#500 = 1.0` published `0.01` | `dec_val` at offset 6; `mantissa / 10^dec`. Vacant variable → `BadNoData`; fractional value on an integer tag → `BadTypeMismatch`; integer tags receive the exact whole value | +| Axis positions | scaled by `cnc_getfigure` (0x00D3), which does not exist — on hardware: raw counts | scaled by each block's own `base^exponent`; undescribable block → `BadNoData` | +| Axis / spindle names | first 2 of 4 record bytes — `AA6`/`AA7` collapsed to `AA` | whole record up to the first NUL (residue after it discarded) — ⚠️ `Axes/AA_1`→`Axes/AA6` on such a machine | +| `ServoLoad` | `value / 10^base` (offset 4 is the base, 10) — always ~0 | `value / base^exponent`, a direct percentage | +| `Spindle/{n}/Load` | raw meter count, Int32 (`9155`) | ⚠️ Float64 % of rated load (`55.9`): the meter is a 1/16384 fraction of full scale | +| `Spindle/{n}/MaxRpm` | the type-1 meter read once at bootstrap and served as a "max RPM" | ⚠️ **renamed `MotorSpeedPct`**, Float64 % of maximum speed, polled every tick | +| Program `ONumber` / `Name` / `BlockCount` / `Mode` | defaulted to `0` / `""` when not reported (Mode `0` = MDI) | `BadNoData` | + +Not ported (and why): the ignitionoee `RunState` run-word decode and fault debounce (this driver +publishes no run-state — `cnc_statinfo` is used only as the connectivity probe); `cnc_rdparam3` +(0x008D) and its echo guard (this driver never sends 0x008D — the real 31i-B answers it with +264-byte records that are not a parameter read); the IODBPSD-offset and Fanuc-real `FLOAT` +re-interpretation of `cnc_rdparam` (no successful `cnc_rdparam` capture exists on either side — +the reference 31i-B refuses it with `EW_FUNC`, which already reads `BadNotSupported` here); and +the all-axes multi-block `cnc_rddynamic2` batching (a latency optimisation, not a correctness fix). ## Further reading 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/FixedTreeNames.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FixedTreeNames.cs index 2666310d..e614146e 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FixedTreeNames.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FixedTreeNames.cs @@ -41,11 +41,18 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS; /// config redeploy). /// /// -/// Suffix collisions with genuine names are structurally impossible for axes (FOCAS axis -/// displays are at most two characters — name + suffix — so they cannot contain _), -/// but the generator still checks: a generated segment that would collide with anything -/// else in the set escalates its ordinal until unique, so the output is always -/// collision-free whatever the input. +/// Suffix collisions with genuine names are not expected (a FOCAS name record is at most four +/// ASCII characters and controls do not use _), but the generator still checks: a +/// generated segment that would collide with anything else in the set escalates its ordinal +/// until unique, so the output is always collision-free whatever the input. +/// +/// +/// Note on the Makino that motivated this. Its two "AA" axes were a decode artefact: +/// the name records actually read "AA6" and "AA7", and the wire layer read only +/// the first two bytes of each 4-byte record. With the full record decoded +/// () that machine reports unique names and +/// its axes publish bare (Axes/AA6, Axes/AA7). This disambiguation remains as the +/// guard for a control that genuinely reports duplicates. /// /// internal static class FixedTreeNames 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..bbf4d0e9 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); } } @@ -925,15 +926,9 @@ 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), []); + 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)); @@ -942,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. @@ -964,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) { @@ -999,7 +992,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); } } @@ -1025,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); } } @@ -1102,40 +1094,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) { @@ -1160,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) => @@ -1241,7 +1247,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,10 +1255,10 @@ 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" + // Spindle matches: "Spindle/{name}/Load" + "Spindle/{name}/MotorSpeedPct" if (suffixFull.StartsWith("Spindle/", StringComparison.Ordinal) && state.FixedTreeCache is { } spindleCache) { @@ -1271,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); } } } @@ -1301,8 +1307,8 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, "Program/MainNumber" => state.LastProgramAxisRef?.MainProgramNumber, "Program/Sequence" => state.LastProgramAxisRef?.SequenceNumber, "OperationMode/Mode" => state.LastProgramInfo?.Mode, - "OperationMode/ModeText" => state.LastProgramInfo is { } pi - ? FocasOpMode.ToText(pi.Mode) : null, + "OperationMode/ModeText" => state.LastProgramInfo?.Mode is { } mode + ? FocasOpMode.ToText(mode) : null, "Timers/PowerOnSeconds" => TimerValue(state, FocasTimerKind.PowerOn), "Timers/OperatingSeconds" => TimerValue(state, FocasTimerKind.Operating), "Timers/CuttingSeconds" => TimerValue(state, FocasTimerKind.Cutting), @@ -1311,6 +1317,18 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, }; if (value is not null) return new DataValueSnapshot(value, status, sourceUtc, now); + + // The program bundle has been read, but the CNC did not report this field (a refused + // sub-read, or no O-number in the payload). That is Bad — never a fabricated 0, and + // not "no such node" either. + if (state.LastProgramInfo is not null + && path is "Program/Name" or "Program/ONumber" or "Program/BlockCount" + or "OperationMode/Mode" or "OperationMode/ModeText") + return new DataValueSnapshot( + null, + status == FocasStatusMapper.Good ? FocasStatusMapper.BadNoData : status, + sourceUtc, + now); } return null; } @@ -1489,7 +1507,6 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, FocasSysInfo SysInfo, IReadOnlyList Axes, IReadOnlyList Spindles, - IReadOnlyList SpindleMaxRpms, FocasFixedTreeCapabilities Capabilities) { /// @@ -1515,14 +1532,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 @@ -1578,9 +1595,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. @@ -1591,15 +1609,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 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. + /// Gets the last spindle load percentages (% of rated load) by spindle index; + /// for a slot the CNC described as holding none. /// - public IReadOnlyList PositionFigures { get; set; } = []; + 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/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/FocasStatusMapper.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasStatusMapper.cs index 33f65bba..90fdcbf3 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasStatusMapper.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasStatusMapper.cs @@ -19,6 +19,14 @@ public static class FocasStatusMapper public const uint BadTimeout = 0x800A0000u; public const uint BadTypeMismatch = 0x80740000u; + /// + /// The CNC answered, but its answer describes no usable value — a vacant macro variable, a + /// position / meter record whose own scale envelope is undecodable, a field the control did + /// not report. Distinct from a comms fault (the device is fine) and never a fabricated + /// Good 0. + /// + public const uint BadNoData = 0x809B0000u; + /// /// Map common FWLIB EW_* return codes. The values below match Fanuc's published /// numeric conventions (EW_OK=0, EW_FUNC=1, EW_NUMBER=3, EW_LENGTH=4, EW_ATTRIB=7, 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..2d5d8725 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,36 +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); - - /// - /// 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); + /// 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 @@ -238,22 +234,19 @@ 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). /// Mode is the numeric code from cnc_rdopmode — see . +/// A null field means the CNC did not report it (its sub-read was refused, or the +/// payload was too short to carry it); the driver publishes that node Bad. It is never +/// defaulted to 0, which is a genuine O-number, block count, and mode (MDI). /// public sealed record FocasProgramInfo( - string Name, - int ONumber, - int BlockCount, - int Mode); + string? Name, + int? ONumber, + int? BlockCount, + int? Mode); /// Human-readable text for the integer. public static class FocasOpMode @@ -277,6 +270,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 +282,60 @@ 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) +{ + /// + /// 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. + /// + /// + /// 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 + /// 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 Wire.FocasValueEnvelope.Scale(Raw, 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..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,16 +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> GetPositionFiguresAsync(CancellationToken cancellationToken) => - RunBoundedAsync(ct => _inner.GetPositionFiguresAsync(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 02dce813..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 @@ -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). @@ -383,47 +421,14 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable } /// - /// 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. + /// Read the per-spindle load meter via cnc_rdspmeter (command 0x0040, type 0). + /// is % of rated load (16384 = 100%). /// - /// - /// 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. /// 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, @@ -431,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, @@ -577,9 +588,33 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable ushort? pathId = null) => ReadSingleWithTimeoutAsync( 0x0015, - payload => new WireMacro(number, payload.Length >= 4 ? ReadInt32(payload, 0) : 0, payload.Length >= 6 ? ReadInt16(payload, 4) : (short)0), + payload => ParseMacro(number, payload), cancellationToken, timeout, EffectivePathId(pathId), number, number); + /// + /// Decode a cnc_rdmacro (0x0015) payload. The on-wire ODBM record is 8 + /// big-endian bytes: [0..3] i32 mantissa, [4..5] i16 length field (a + /// constant 10 on every observed response), [6..7] i16 dec_val + /// (0xFFFF = vacant). + /// + /// + /// The middle field is the trap: it is not the decimal count but looks like a plausible one, + /// so reading the decimal at offset 4 decoded every macro on every control without ever + /// failing — just wrongly (the captured #500 = 1.0 published 0.01). Pinned by + /// a live Makino 31i-B capture; found and fixed by the sister ignitionoee driver + /// (Focas.Wire-013, 2026-08-05). A payload shorter than 8 bytes carries no + /// dec_val and is refused rather than guessed at. + /// + /// The macro number the request asked for. + /// The response-block payload. + /// The decoded macro. + /// The payload is shorter than 8 bytes. + internal static WireMacro ParseMacro(short number, byte[] payload) + { + RequireLength(payload, 8, "cnc_rdmacro"); + return new WireMacro(number, ReadInt32(payload, 0), ReadInt16(payload, 6)); + } + /// /// Read a PMC range via pmc_rdpmcrng. is the numeric /// address-letter code (see ); @@ -745,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( @@ -932,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. @@ -963,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; } @@ -1020,17 +1067,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) @@ -1090,7 +1137,17 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable return alarms; } - private static IReadOnlyList ReadNameRecords(byte[] payload, short maxCount, Func factory) + /// + /// Decode an axis-name (0x0089) or spindle-name (0x008A) payload: an array of 4-byte records, + /// each named by . Blank records are skipped but + /// keep their slot's 1-based index. + /// + /// The record type the factory builds. + /// The response-block payload. + /// Maximum number of records to inspect. + /// Builds one record from its 1-based index and name. + /// The non-blank records, in wire order. + internal static IReadOnlyList ReadNameRecords(byte[] payload, short maxCount, Func factory) { var names = new List(); for (var offset = 0; offset + 4 <= payload.Length && offset / 4 < maxCount; offset += 4) 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 abdc1d74..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 @@ -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 @@ -56,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 @@ -86,13 +175,31 @@ public sealed record WireParameter( int Value); /// -/// Macro variable from cnc_rdmacro. Scaled decimal: the callable value is -/// Value / 10^Decimal. +/// Macro variable from cnc_rdmacro. Scaled decimal: the real value is +/// Value / 10^Decimal (). FANUC normalises every macro to a +/// 9-significant-digit mantissa, so alone is never a value to publish +/// (live: #502 = 270 arrives as 270000000 / 10^6). /// +/// Macro variable number (the request argument; not carried in the payload). +/// The 9-significant-digit mantissa. +/// Implied decimal places; negative (0xFFFF on the wire) = vacant. public sealed record WireMacro( short Number, int Value, - short Decimal); + short Decimal) +{ + /// + /// True when the CNC reported the variable as vacant (undefined / never set): + /// dec_val == 0xFFFF. A vacant macro's mantissa is 0, indistinguishable from a + /// genuine 0, so this must be tested before means anything. Any + /// negative decimal is treated as vacant — no other negative has ever been observed and + /// failing closed is the safe reading. + /// + public bool IsVacant => Decimal < 0; + + /// The real value, Value / 10^Decimal; null when . + public double? Scaled => IsVacant ? null : Decimal == 0 ? Value : Value / Math.Pow(10.0, Decimal); +} /// PMC range read-back from pmc_rdpmcrng: one or more values of the requested width. public sealed record WirePmcRange( diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireProtocol.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireProtocol.cs index 35df33d3..2c6b394e 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireProtocol.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireProtocol.cs @@ -239,19 +239,34 @@ internal static class FocasWireProtocol } /// - /// Read an axis/spindle name record — the first 2 bytes of a 2-byte (axis) or 4-byte - /// (spindle) slot. Trailing spaces and NULs are stripped so "X " becomes - /// "X". + /// Read one 4-byte axis/spindle name record: the ASCII up to (not including) the record's + /// FIRST NUL, at most 4 bytes, with trailing spaces trimmed — "X \0\0" → "X", + /// "AA6\0" → "AA6", "S1\0N" → "S1". /// - /// The bytes to decode. - /// The decoded name record. + /// + /// All four bytes are name bytes. This used to read only the first two, which + /// collapsed the real 31i-B's paired axes "AA6"/"AA7" (live capture + /// ref-axisname-0x0089.bin) to two axes both called "AA" — the duplicate Gitea + /// #548 then had to disambiguate as AA_1/AA_2. + /// The first NUL terminates, not trailing-NUL trimming. A control writes only the + /// bytes its name needs and leaves the rest of the buffer as it found it: Makino Z-34184's + /// cnc_rdspdlname answered 53 31 00 4e — "S1" plus the 'N' of an + /// earlier "CNC" response. Both rules are ported from the sister ignitionoee FOCAS + /// driver (Focas.Wire-001 / Focas.Wire-022). + /// + /// The record bytes (4 on the wire; a shorter span is read as far as it goes). + /// The decoded name; empty for a blank record. public static string ReadNameRecord(ReadOnlySpan bytes) { - if (bytes.Length < 2) return string.Empty; - var buffer = bytes.Slice(0, Math.Min(2, bytes.Length)).ToArray(); - return Encoding.ASCII.GetString(buffer).TrimEnd(' ', '\0'); + var record = bytes.Slice(0, Math.Min(NameRecordLength, bytes.Length)); + var nul = record.IndexOf((byte)0); + if (nul >= 0) record = record.Slice(0, nul); + return Encoding.ASCII.GetString(record).TrimEnd(' '); } + /// Byte length of one axis/spindle name record. + public const int NameRecordLength = 4; + private static byte[] BuildRequestBlock(RequestBlock request) { var extra = request.ExtraPayload ?? Array.Empty(); 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 bea99a40..acc104c1 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 @@ -62,7 +62,7 @@ public sealed class WireFocasClient : IFocasClient { FocasAreaKind.Pmc => await ReadPmcAsync(address, type, cancellationToken).ConfigureAwait(false), FocasAreaKind.Parameter => await ReadParameterAsync(address, type, cancellationToken).ConfigureAwait(false), - FocasAreaKind.Macro => await ReadMacroAsync(address, cancellationToken).ConfigureAwait(false), + FocasAreaKind.Macro => await ReadMacroAsync(address, type, cancellationToken).ConfigureAwait(false), _ => (null, FocasStatusMapper.BadNotSupported), }; } @@ -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, @@ -215,12 +217,29 @@ public sealed class WireFocasClient : IFocasClient throw new InvalidOperationException( $"cnc_exeprgname2 failed EW_{nameResult.Rc} and cnc_rdopmode failed EW_{modeResult.Rc}."); - var wireName = nameResult.Value; + return MapProgramInfo(nameResult, blkResult, modeResult); + } + + /// + /// Combine the three program sub-reads into a , leaving any + /// field the CNC did not report null. A refused read, or an cnc_exeprgname2 payload + /// too short to carry the trailing O-number, used to publish a fabricated 0 at Good + /// quality — indistinguishable from a real 0 (a 31i reports O-number 0 for a program run + /// by path) and, for the mode, from MDI. Ported from the sister ignitionoee driver (101a3b1). + /// + /// The cnc_exeprgname2 result. + /// The cnc_rdblkcount result. + /// The cnc_rdopmode result. + /// The snapshot, null where the CNC did not say. + internal static FocasProgramInfo MapProgramInfo( + FocasResult name, FocasResult blockCount, FocasResult mode) + { + var wireName = name.IsOk ? name.Value : null; return new FocasProgramInfo( - Name: wireName?.Name ?? string.Empty, - ONumber: wireName?.ONumber ?? 0, - BlockCount: blkResult.IsOk ? blkResult.Value : 0, - Mode: modeResult.IsOk ? modeResult.Value : 0); + Name: wireName?.Name, + ONumber: wireName?.ONumber, + BlockCount: blockCount.IsOk ? blockCount.Value : null, + Mode: mode.IsOk ? mode.Value : null); } /// @@ -240,52 +259,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); - /// - // 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( + /// + /// 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. @@ -368,17 +367,14 @@ public sealed class WireFocasClient : IFocasClient } private async Task<(object? value, uint status)> ReadMacroAsync( - FocasAddress address, CancellationToken cancellationToken) + FocasAddress address, FocasDataType type, CancellationToken cancellationToken) { try { var result = await _wire.ReadMacroAsync((short)address.Number, cancellationToken).ConfigureAwait(false); if (!result.IsOk || result.Value is null) return (null, FocasStatusMapper.MapFocasReturn(result.Rc)); - var m = result.Value; - // Macro value is scaled-decimal: the real value is Value / 10^Decimal. - var scaled = m.Value / Math.Pow(10.0, m.Decimal); - return ((object)scaled, FocasStatusMapper.Good); + return MapMacro(result.Value, type, address.BitIndex); } catch (FocasWireException ex) { @@ -386,6 +382,62 @@ public sealed class WireFocasClient : IFocasClient } } + /// + /// Turn a decoded macro into the value + quality its tag publishes. + /// + /// + /// A vacant variable has no value at all → , + /// never a Good 0 (its mantissa is 0, indistinguishable from a genuine zero). + /// Float tags get mantissa / 10^decimal. Integer tags (Byte / Int16 / + /// Int32 / Bit) get the exact whole value, tested with integer arithmetic + /// (mantissa % 10^decimal) so no epsilon is involved; a fractional macro on an + /// integer tag is — truncating or rounding + /// would publish a plausible wrong count at Good quality — and a whole value that does not + /// fit the type is . Ported from the sister + /// ignitionoee driver's macroInteger/macroReal (Focas-017/018). + /// + /// The decoded macro. + /// The tag's declared data type. + /// Optional bit index for a tag. + /// The boxed value (null when Bad) and its status. + internal static (object? value, uint status) MapMacro(WireMacro m, FocasDataType type, int? bitIndex) + { + if (m.Scaled is not { } scaled) return (null, FocasStatusMapper.BadNoData); + + switch (type) + { + case FocasDataType.Float64: + return (scaled, FocasStatusMapper.Good); + case FocasDataType.Float32: + return ((float)scaled, FocasStatusMapper.Good); + case FocasDataType.Byte or FocasDataType.Int16 or FocasDataType.Int32 or FocasDataType.Bit: + if (!TryWholeMacroValue(m, out var whole)) return (null, FocasStatusMapper.BadTypeMismatch); + return type switch + { + FocasDataType.Bit => (bitIndex is int bit ? ((whole >> bit) & 1L) != 0 : whole != 0, FocasStatusMapper.Good), + FocasDataType.Byte when whole is >= sbyte.MinValue and <= sbyte.MaxValue => ((sbyte)whole, FocasStatusMapper.Good), + FocasDataType.Int16 when whole is >= short.MinValue and <= short.MaxValue => ((short)whole, FocasStatusMapper.Good), + FocasDataType.Int32 when whole is >= int.MinValue and <= int.MaxValue => ((int)whole, FocasStatusMapper.Good), + _ => (null, FocasStatusMapper.BadOutOfRange), + }; + default: + return (scaled, FocasStatusMapper.Good); + } + } + + /// Exact mantissa / 10^decimal when it is a whole number; integer arithmetic only. + private static bool TryWholeMacroValue(WireMacro m, out long whole) + { + whole = 0; + if (m.Value == 0) return true; // 0 is whole at any decimal + if (m.Decimal > 18) return false; // an i32 mantissa over 10^19+ is always fractional + var divisor = 1L; + for (var i = 0; i < m.Decimal; i++) divisor *= 10L; + if (m.Value % divisor != 0) return false; + whole = m.Value / divisor; + return true; + } + private void RequireConnected() { if (!_wire.IsConnected) diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/focas-mock/VENDORED.md b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/focas-mock/VENDORED.md index 701c4cc6..35907315 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/focas-mock/VENDORED.md +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/focas-mock/VENDORED.md @@ -43,3 +43,18 @@ Copy-Item "$src\Dockerfile" "$dest\" Update the snapshot date at the top of this file afterward. No other files belong here — the Docker build context is just the Python package and its metadata. + +## Local divergence (do not lose on refresh) + +`src/focas_mock/server.py` and `defaults.py` carry OtOpcUa-local edits that make the wire +shapes match a real FANUC 31i-B (Makino Z-34184) instead of shapes the driver and the mock had +agreed on between themselves. A refresh that overwrites `src/` must re-apply them: + +- the little-endian `cnc_rdtimer` minute/msec encoding (2026-06-25, live-validated); +- the alarm count cap on `cnc_rdalmmsg2`; +- (2026-10-07, ported from the sister ignitionoee FOCAS mock) `0x00D3` ("cnc_getfigure", not a + real FOCAS wire command) refused with `EW_FUNC`; `0x0026` position blocks carrying their own + `base/exponent` (`position_figures` is now that exponent) and a signed `arg2`; + `cnc_rdmacro` as the 8-byte ODBM shape (`dec_val` at `[6..7]`, 9-digit mantissa, unset + macro = vacant); servo/spindle meter records with the `base/exponent` envelope, spindle + meters in 1/16384-of-full-scale counts. diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/focas-mock/src/focas_mock/defaults.py b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/focas-mock/src/focas_mock/defaults.py index 45857ac1..4a2430d0 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/focas-mock/src/focas_mock/defaults.py +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/focas-mock/src/focas_mock/defaults.py @@ -78,9 +78,12 @@ def make_default_state(profile: Mapping[str, Any]) -> dict[str, Any]: "acts": 3200, "acts2": [3200] + [0] * (max_spindles - 1), "axis_names": axis_names[:max_axis], - # Per-axis position decimal places served by cnc_getfigure (command 0x00d3). - # Default every configured axis to 0 ⇒ 10^0 = 1.0 scale, leaving existing - # position reads unchanged. mock_patch can override individual axes. + # Per-axis position scale, served INSIDE each cnc_rddynamic2 (0x0026) block as + # base 10 / this exponent — NOT by a separate command (0x00d3 "cnc_getfigure" was + # never real; the mock refuses it with EW_FUNC like the control does). Default 0 ⇒ + # 10^0 = 1.0, leaving existing position reads unchanged. mock_patch can override an + # axis with an int exponent, or a dict {"base": .., "exponent": ..} — e.g. + # {"exponent": 65535} for the vacant marker. "position_figures": {name: 0 for name in axis_names[:max_axis]}, "spindle_names": spindle_names, "parameters": { diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/focas-mock/src/focas_mock/server.py b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/focas-mock/src/focas_mock/server.py index 827355ea..52e4ff05 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/focas-mock/src/focas_mock/server.py +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/focas-mock/src/focas_mock/server.py @@ -7,6 +7,7 @@ from typing import Any, Awaitable, Callable from .constants import ( ADMIN_METHODS, + EW_ATTRIB, EW_FUNC, EW_HANDLE, EW_NUMBER, @@ -24,7 +25,52 @@ from .profiles import load_profile Handler = Callable[[dict[str, Any]], Awaitable[tuple[int, dict[str, Any] | None, str | None]]] +class WireRefusal(Exception): + """One response block refuses with this FOCAS ``rc``. + + Raised from a payload builder when the COMMAND exists but its ARGUMENTS name nothing the + control would answer (``cnc_rddynamic2`` with ``arg2 = 0``, for instance). Command-level + refusals live in ``FocasMockServer._WIRE_REFUSED_COMMANDS``. Ported from the sister + ignitionoee mock (Focas.Wire-020). + """ + + def __init__(self, rc: int) -> None: + super().__init__(f"refused with rc={rc}") + self.rc = rc + + class FocasMockServer: + # OtOpcUa-local changes to this vendored snapshot (2026-10-07), ported from the sister + # ignitionoee FOCAS mock so the wire shapes match a real FANUC 31i-B (Makino Z-34184) rather + # than shapes the driver and the mock once agreed on between themselves: + # - 0x00D3 ("cnc_getfigure") is refused with EW_FUNC, as the real control does; + # - 0x0026 position blocks carry their own base/exponent; + # - cnc_rdmacro is the 8-byte ODBM shape with dec_val at [6..7]; + # - servo/spindle meter records carry the base/exponent envelope, spindle meters in + # 1/16384-of-full-scale counts. + # The little-endian cnc_rdtimer encoding and the alarm count cap stay as they were. + + # cnc_rdmacro (ODBM) encoding constants — see _wire_rdmacro / _normalize_macro. + _MACRO_ODBM_LENGTH = 10 + _MACRO_VACANT_DEC = -1 + _MACRO_SIGNIFICANT_DIGITS = 9 + + # Commands this mock REFUSES, and with which rc — the real control's answer. 0x00D3 does not + # exist in the FOCAS wire protocol; serving it let a driver validate a command the real + # 31i-B refuses with rc=1. + _WIRE_REFUSED_COMMANDS = {0xD3: EW_FUNC} + + # Default base served inside a 0x0026 position block (every block of the live 31i-B + # capture reports base 10). + _POSITION_DEFAULT_BASE = 10 + + # Servo/spindle meter records carry the same base/exponent envelope (live: base 10, exp 0). + _METER_BASE = 10 + + # SPINDLE meters (0x0040) are a fraction of full scale in 1/16384 units: 16384 = 100% of + # rated load (metric 0) or of maximum spindle speed (metric 1). SERVO meters are direct %. + _METER_FULL_SCALE = 16384 + def __init__( self, host: str, @@ -208,10 +254,22 @@ class FocasMockServer: def _wire_response_block(self, request_block: bytes) -> bytes: command = int.from_bytes(request_block[6:8], "big") if len(request_block) >= 8 else 0 - payload = self._wire_payload(command, request_block) + refused_rc = self._WIRE_REFUSED_COMMANDS.get(command) + if refused_rc is not None: + return self._make_wire_response_block(command, b"", request_block, rc=refused_rc) + try: + payload = self._wire_payload(command, request_block) + except WireRefusal as refusal: + return self._make_wire_response_block(command, b"", request_block, rc=refusal.rc) return self._make_wire_response_block(command, payload, request_block) - def _make_wire_response_block(self, command: int, payload: bytes, request_block: bytes | None = None) -> bytes: + def _make_wire_response_block( + self, + command: int, + payload: bytes, + request_block: bytes | None = None, + rc: int = EW_OK, + ) -> bytes: block_len = 0x10 + len(payload) block = bytearray(block_len) block[0:2] = block_len.to_bytes(2, "big") @@ -219,6 +277,7 @@ class FocasMockServer: block[2:8] = request_block[2:8] else: block[6:8] = command.to_bytes(2, "big") + block[8:10] = int(rc).to_bytes(2, "big", signed=True) block[14:16] = len(payload).to_bytes(2, "big") block[16:] = payload return bytes(block) @@ -256,8 +315,6 @@ class FocasMockServer: return self._wire_spindle_metric(request_block) if command == 0x56: return self._wire_servo_meter() - if command == 0xD3: - return self._wire_position_figures() if command == 0x57: mode = self.store.snapshot()["operation_mode"] return self._u16(int(mode.get("mode", 0) if isinstance(mode, dict) else mode)) @@ -322,23 +379,54 @@ class FocasMockServer: return self._u32(value) def _wire_dynamic_axis(self, request_block: bytes) -> bytes: + """Serves `cnc_rddynamic2` (0x0026) positions, honouring a SIGNED `arg2` the way the real + 31i-B does (probed 2026-08-12 by the sister ignitionoee project, Focas.Wire-017/020): + `arg2 = 1..n` -> exactly one 8-byte block for that axis; `arg2 = -1` -> every slot in one + block; `arg2 = 0` or past the axis count -> refused (EW_ATTRIB).""" selector = self._block_u32(request_block, 8) - axis = self._block_u32(request_block, 12) + axis = self._block_i32(request_block, 12) field = {4: "absolute", 1: "machine", 6: "relative", 7: "distance"}.get(selector, "absolute") state = self.store.snapshot() names = list(state["axis_names"]) dynamic_axes = state["dynamic"]["axes"] + if axis == 0 or axis > len(names): + raise WireRefusal(EW_ATTRIB) + if axis > 0: + name = names[axis - 1] + return self._wire_position_block(state, name, dynamic_axes.get(name, {}).get(field, 0)) payload = bytearray() for name in names: - position = dynamic_axes.get(name, {}) - payload += self._u32(int(position.get(field, 0))) - payload += self._u32(0) - if axis > 0 and axis <= len(names): - name = names[axis - 1] - position = dynamic_axes.get(name, {}) - return self._u32(int(position.get(field, 0))) + self._u32(0) + payload += self._wire_position_block(state, name, dynamic_axes.get(name, {}).get(field, 0)) return bytes(payload) + def _wire_position_block(self, state: dict[str, Any], name: str, raw: int) -> bytes: + """Encodes one 8-byte `cnc_rddynamic2` position block: value, then its OWN scale. + + [0..3] i32 raw value + [4..5] u16 base (2 or 10) + [6..7] u16 exponent (0xFFFF = the slot holds no valid position) + + `position_figures[name]` is the exponent (base 10); a dict value + `{"base": .., "exponent": ..}` overrides the base too, so a profile can serve a + deliberately undecodable block and prove the client Bads that one axis node. + """ + base, exponent = self._position_scale(state, name) + return ( + self._u32(int(raw)) + + (base & 0xFFFF).to_bytes(2, "big") + + (exponent & 0xFFFF).to_bytes(2, "big") + ) + + def _position_scale(self, state: dict[str, Any], name: str) -> tuple[int, int]: + """Returns the (base, exponent) pair `position_figures` configures for one axis.""" + configured = state.get("position_figures", {}).get(name, 0) + if isinstance(configured, dict): + return ( + int(configured.get("base", self._POSITION_DEFAULT_BASE)), + int(configured.get("exponent", 0)), + ) + return self._POSITION_DEFAULT_BASE, int(configured) + def _wire_wrparam(self, request_block: bytes) -> None: datano = self._block_u32(request_block, 8) extra = request_block[28:] @@ -346,11 +434,38 @@ class FocasMockServer: self.store.merge_patch({"parameters": {str(datano): {"type": "long", "value": value, "decimal": 0}}}) def _wire_rdmacro(self, request_block: bytes) -> bytes: + """Encodes an ODBM `cnc_rdmacro` (0x0015) response payload, as a real 31i-B sends it + (golden capture `ref-macro-500-0x0015.bin`: `05 f5 e1 00 00 0a 00 08` = 1.0): + + [0..3] i32 mantissa (normalised to 9 significant digits) + [4..5] i16 length field — a CONSTANT 10 + [6..7] i16 dec_val — decimal places; 0xFFFF (-1) = VACANT + + A macro the profile does not mock is emitted as vacant, as a real control answers an + undefined variable. + """ number = self._block_u32(request_block, 8) - macro = self.store.snapshot()["macros"].get(str(number), {}) - value = int(macro.get("value", 0)) if isinstance(macro, dict) else 0 - decimal = int(macro.get("decimal", 0)) if isinstance(macro, dict) else 0 - return self._u32(value) + self._u16(decimal) + self._u16(0) + macro = self.store.snapshot()["macros"].get(str(number)) + if isinstance(macro, dict): + mantissa, dec_val = self._normalize_macro( + int(macro.get("value", 0)), int(macro.get("decimal", 0)) + ) + else: + mantissa, dec_val = 0, self._MACRO_VACANT_DEC + return self._u32(mantissa) + self._u16(self._MACRO_ODBM_LENGTH) + self._u16(dec_val) + + def _normalize_macro(self, value: int, decimal: int) -> tuple[int, int]: + """Turns a configured fixed-point `(value, decimal)` into the `(mantissa, dec_val)` a CNC + returns: the mantissa padded to 9 significant digits, `dec_val` raised to match + (live: `#502 = 270` -> `270000000/10^6`). The real value is unchanged.""" + if decimal < 0: + return 0, self._MACRO_VACANT_DEC + if value == 0: + return 0, decimal + pad = self._MACRO_SIGNIFICANT_DIGITS - len(str(abs(value))) + if pad <= 0: + return value, decimal + return value * (10**pad), decimal + pad def _wire_wrmacro(self, request_block: bytes) -> None: number = self._block_u32(request_block, 8) @@ -377,31 +492,34 @@ class FocasMockServer: payload = bytearray() for name in state["axis_names"]: entry = configured.get(name, {"name": name, "value": 0}) + # 8-byte record (live 31i-B): value, then the base/exponent envelope. No axis name — + # the client pairs names positionally from the companion 0x0089 block. payload += self._u32(int(entry.get("value", 0))) - payload += self._u16(0) - payload += self._u16(1) - payload += self._name_record(name, 4) - return bytes(payload) - - def _wire_position_figures(self) -> bytes: - state = self.store.snapshot() - figures = state.get("position_figures", {}) - payload = bytearray() - for name in state["axis_names"]: - payload += self._u16(int(figures.get(name, 0))) + payload += self._u16(self._METER_BASE) + payload += self._u16(int(entry.get("exponent", 0))) return bytes(payload) def _wire_spindle_metric(self, request_block: bytes) -> bytes: + # Both metrics are 1/16384-of-full-scale COUNTS on the wire, not the human units the + # config holds. Metric 0: load % of rated (16384 = 100%). Metric 1: motor speed as a + # fraction of the spindle's maximum (`speed / max_rpm`) — NOT the maximum itself. metric = self._block_u32(request_block, 8) spindle = self.store.snapshot()["spindle"] - if metric == 1: - values = [int(value) for value in spindle.get("max_rpm", [])] - else: - values = [int(entry.get("load", 0)) for entry in spindle.get("load", [])] + maxima = [int(value) for value in spindle.get("max_rpm", [])] + entries = spindle.get("load", []) + values = [] + for index, entry in enumerate(entries): + if metric == 1: + maximum = maxima[index] if index < len(maxima) else 0 + speed = int(entry.get("speed", 0)) + values.append(round(speed * self._METER_FULL_SCALE / maximum) if maximum else 0) + else: + values.append(round(int(entry.get("load", 0)) * self._METER_FULL_SCALE / 100)) payload = bytearray() for value in values[:4]: payload += self._u32(value) - payload += self._u32(0) + payload += self._u16(self._METER_BASE) + payload += self._u16(0) return bytes(payload) def _wire_program_name(self) -> bytes: @@ -480,6 +598,12 @@ class FocasMockServer: return 0 return int.from_bytes(block[offset : offset + 4], "big") + def _block_i32(self, block: bytes, offset: int) -> int: + """A SIGNED request argument — `-1` is a real selector (all axes / all spindles).""" + if offset + 4 > len(block): + return 0 + return int.from_bytes(block[offset : offset + 4], "big", signed=True) + def _u16(self, value: int) -> bytes: return int(value).to_bytes(2, "big", signed=True) diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendCoverageTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendCoverageTests.cs index f09d7e39..cc37571c 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendCoverageTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendCoverageTests.cs @@ -236,14 +236,15 @@ public sealed class WireBackendCoverageTests } /// - /// Verifies that per-axis position figures fetched via cnc_getfigure (wire command - /// 0x00D3) scale the published AbsolutePosition, and that the auto figure wins over - /// the configured PositionDecimalPlaces fallback knob. - /// Seed: X axis absolute = 12345, figure = 3 → published value = 12.345 (÷ 10^3). - /// Config knob = 1 → fallback would give 1234.5. 12.345 uniquely proves the wire path. + /// Verifies that the scale carried INSIDE each cnc_rddynamic2 position block + /// (base 10 / exponent) scales the published AbsolutePosition, and that it wins over + /// the configured PositionDecimalPlaces fallback knob. (The mock refuses the old + /// cnc_getfigure 0x00D3 with EW_FUNC, as a real 31i-B does.) + /// Seed: X axis absolute = 12345, exponent = 3 → published value = 12.345 (÷ 10^3). + /// Config knob = 1 → fallback would give 1234.5. 12.345 uniquely proves the block scale. /// [Fact] - public async Task Position_figures_scale_axis_via_wire_backend() + public async Task Position_block_scale_scales_axis_via_wire_backend() { if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason); var ct = TestContext.Current.CancellationToken; @@ -261,13 +262,13 @@ public sealed class WireBackendCoverageTests X = new { absolute = 12345, machine = 12345, relative = 0, distance = 0 }, }, }, - // Per-axis decimal-place figures for cnc_getfigure (command 0x00D3). - // The mock's _wire_position_figures() reads state["position_figures"][axisName]. + // Per-axis exponent served inside each 0x0026 position block (base 10). + // The mock's _position_scale() reads state["position_figures"][axisName]. position_figures = new { X = 3 }, }, ct); - // PositionDecimalPlaces = 1 is intentionally different from the auto figure (3) - // so the assertion 12.345 uniquely proves the cnc_getfigure path won. + // PositionDecimalPlaces = 1 is intentionally different from the block exponent (3) + // so the assertion 12.345 uniquely proves the block's own scale won. var driver = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions(DeviceHost, PositionDecimalPlaces: 1)], @@ -295,13 +296,90 @@ public sealed class WireBackendCoverageTests state.ShouldNotBeNull(); var published = state.LastFixedSnapshots[$"{DeviceHost}/Axes/X/AbsolutePosition"]; - // 12345 ÷ 10^3 = 12.345 → auto figure (3) won. + // 12345 ÷ 10^3 = 12.345 → the block's own scale (3) won. // 12345 ÷ 10^1 = 1234.5 → would mean config knob (1) was used instead. // 12345 ÷ 10^0 = 12345.0 → would mean no scaling at all. published.ShouldBe(12.345, tolerance: 0.0001); } } + /// + /// "The control described no value" reads Bad over the real wire, never a Good number: + /// a vacant 0x0026 position block (exponent 0xFFFF) Bads only its own axis + /// node, an unset macro (vacant dec_val) Bads its tag, and a fractional macro on an + /// integer tag is refused rather than truncated — while a normalised 9-digit macro + /// mantissa de-scales to its real value. + /// + [Fact] + public async Task Vacant_positions_and_macros_read_bad_over_the_wire() + { + if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason); + var ct = TestContext.Current.CancellationToken; + + await _fx.LoadProfileAsync("FWLIB64", ct); + await _fx.PatchStateAsync(new + { + axis_names = new[] { "X", "Y" }, + dynamic = new + { + alarm = 0, prgnum = 1, prgmnum = 1, seqnum = 1, actf = 0, acts = 0, + axes = new + { + X = new { absolute = 1, machine = 1, relative = 0, distance = 0 }, + Y = new { absolute = 370177, machine = 370177, relative = 0, distance = 0 }, + }, + }, + position_figures = new Dictionary + { + ["X"] = new { exponent = 0xFFFF }, // vacant-slot marker + ["Y"] = 3, + }, + macros = new Dictionary + { + ["502"] = new { value = 270, @decimal = 0 }, // served as 270000000 / 10^6 + ["508"] = new { value = 1485, @decimal = 1 }, // 148.5 + }, + }, ct); + + var drv = new FocasDriver(new FocasDriverOptions + { + Devices = [new FocasDeviceOptions(DeviceHost)], + RawTags = FocasRawTags.Entries( + new FocasTagDefinition("Count502", DeviceHost, "MACRO:502", FocasDataType.Int32, Writable: false), + new FocasTagDefinition("Frac508", DeviceHost, "MACRO:508", FocasDataType.Int32, Writable: false), + new FocasTagDefinition("Unset611", DeviceHost, "MACRO:611", FocasDataType.Float64, Writable: false)), + Probe = new FocasProbeOptions { Enabled = false }, + FixedTree = new FocasFixedTreeOptions + { + Enabled = true, + PollInterval = TimeSpan.FromMilliseconds(100), + ProgramPollInterval = TimeSpan.Zero, + TimerPollInterval = TimeSpan.Zero, + }, + }, driverInstanceId: "wire-vacant", clientFactory: new WireFocasClientFactory()); + + await using (drv) + { + await drv.InitializeAsync("{}", ct); + await WaitFor(() => + drv.GetDeviceState(DeviceHost)?.LastFixedSnapshots + .ContainsKey($"{DeviceHost}/Axes/Y/AbsolutePosition") == true, + TimeSpan.FromSeconds(5)); + + var snaps = await drv.ReadAsync( + [$"{DeviceHost}/Axes/X/AbsolutePosition", $"{DeviceHost}/Axes/Y/AbsolutePosition", + "Count502", "Frac508", "Unset611"], ct); + + snaps[0].StatusCode.ShouldBe(FocasStatusMapper.BadNoData); + snaps[1].StatusCode.ShouldBe(FocasStatusMapper.Good); + Convert.ToDouble(snaps[1].Value).ShouldBe(370.177, tolerance: 1e-9); + snaps[2].StatusCode.ShouldBe(FocasStatusMapper.Good); + snaps[2].Value.ShouldBe(270); + snaps[3].StatusCode.ShouldBe(FocasStatusMapper.BadTypeMismatch); + snaps[4].StatusCode.ShouldBe(FocasStatusMapper.BadNoData); + } + } + private static async Task WaitFor(Func pred, TimeSpan timeout) { var deadline = DateTime.UtcNow + timeout; diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendTests.cs index 6eff4b42..19ef82d0 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendTests.cs @@ -210,9 +210,13 @@ public sealed class WireBackendTests } } - /// Verifies that spindle load and max RPM data populate via the wire backend. + /// + /// Verifies that the spindle load and motor-speed meters populate via the wire backend as + /// percentages of full scale (the mock serves 1/16384-of-full-scale counts, as a real + /// 31i-B does): S1 load 56% / speed 3200 of 6000 rpm = 53.3%, S2 load 12% / 1800 of 4500 = 40%. + /// [Fact] - public async Task Spindle_load_and_max_rpm_populate_via_wire_backend() + public async Task Spindle_load_and_motor_speed_populate_via_wire_backend() { if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason); var ct = TestContext.Current.CancellationToken; @@ -260,21 +264,21 @@ public sealed class WireBackendTests await WaitFor(() => { var state = driver.GetDeviceState(DeviceHost); - return state?.FixedTreeCache is { Capabilities.SpindleLoad: true, Capabilities.SpindleMaxRpm: true } + return state?.FixedTreeCache is { Capabilities.SpindleLoad: true, Capabilities.SpindleMotorSpeed: true } && state.LastSpindleLoads.Count >= 2; }, TimeSpan.FromSeconds(5)); var snapshots = await driver.ReadAsync( [$"{DeviceHost}/Spindle/S1/Load", - $"{DeviceHost}/Spindle/S1/MaxRpm", + $"{DeviceHost}/Spindle/S1/MotorSpeedPct", $"{DeviceHost}/Spindle/S2/Load", - $"{DeviceHost}/Spindle/S2/MaxRpm"], ct); + $"{DeviceHost}/Spindle/S2/MotorSpeedPct"], ct); snapshots.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.Good); - Convert.ToInt32(snapshots[0].Value).ShouldBe(56); - Convert.ToInt32(snapshots[1].Value).ShouldBe(6000); - Convert.ToInt32(snapshots[2].Value).ShouldBe(12); - Convert.ToInt32(snapshots[3].Value).ShouldBe(4500); + Convert.ToDouble(snapshots[0].Value).ShouldBe(56.0, tolerance: 0.01); + Convert.ToDouble(snapshots[1].Value).ShouldBe(3200 * 100.0 / 6000, tolerance: 0.01); + Convert.ToDouble(snapshots[2].Value).ShouldBe(12.0, tolerance: 0.01); + Convert.ToDouble(snapshots[3].Value).ShouldBe(40.0, tolerance: 0.01); } } 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..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,25 +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]); - - /// 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); + 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-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/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/FocasMacroDecodeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasMacroDecodeTests.cs new file mode 100644 index 00000000..15bcdaab --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasMacroDecodeTests.cs @@ -0,0 +1,143 @@ +using System.Buffers.Binary; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; + +/// +/// cnc_rdmacro (0x0015) decode, pinned to real FANUC 31i-B payloads. Ported from the +/// sister ignitionoee FOCAS driver's v0.15.0 fix (review finding Focas.Wire-013 / Focas-017). +/// +/// +/// The on-wire ODBM payload is 8 bytes: [0..3] i32 mantissa, +/// [4..5] i16 — a constant 10 on every observed response (a length field, NOT the +/// decimal), and [6..7] i16 dec_val (0xFFFF = vacant). Reading the decimal at +/// offset 4 decodes every macro on every control without ever failing — just wrongly: the +/// captured #500 = 1.0 (05 f5 e1 00 00 0a 00 08) read as 100000000 / 10^10 = +/// 0.01. FANUC normalises a macro to a 9-significant-digit mantissa, so the live vectors +/// below (Makino Z-34184, 2026-08-05) all carry large mantissas. +/// +[Trait("Category", "Unit")] +public sealed class FocasMacroDecodeTests +{ + private static byte[] Odbm(int mantissa, int decVal, int lengthField = 10) + { + var p = new byte[8]; + BinaryPrimitives.WriteInt32BigEndian(p.AsSpan(0, 4), mantissa); + BinaryPrimitives.WriteUInt16BigEndian(p.AsSpan(4, 2), (ushort)lengthField); + BinaryPrimitives.WriteUInt16BigEndian(p.AsSpan(6, 2), (ushort)(decVal & 0xFFFF)); + return p; + } + + [Fact] + public void ParseMacro_decodes_the_live_capture_with_the_decimal_at_offset_6() + { + var payload = WireFixtures.Payload("ref-macro-500-0x0015.bin", 0x0015); + + var m = FocasWireClient.ParseMacro(500, payload); + + m.Number.ShouldBe((short)500); + m.Value.ShouldBe(100_000_000); + m.Decimal.ShouldBe((short)8); // offset 6 — NOT the constant 10 at offset 4 + m.IsVacant.ShouldBeFalse(); + m.Scaled!.Value.ShouldBe(1.0, 1e-12); + } + + [Fact] + public void ParseMacro_ignores_the_length_field_at_offset_4() + { + var a = FocasWireClient.ParseMacro(508, Odbm(148_500_000, 6)); + var b = FocasWireClient.ParseMacro(508, Odbm(148_500_000, 6, lengthField: 0)); + + b.ShouldBe(a); + b.Scaled!.Value.ShouldBe(148.5, 1e-9); + } + + [Theory] + [InlineData(270_000_000, 6, 270.0)] // #502 + [InlineData(148_500_000, 6, 148.5)] // #508 + [InlineData(208_850_000, 4, 20885.0)] // #610 + [InlineData(202_608_050, 1, 20_260_805.0)] // #3011 date + [InlineData(-100_000_000, 12, -1.0e-4)] // #802 + [InlineData(969_479_000, 9, 0.969479)] // #801 + [InlineData(1234, 0, 1234.0)] + public void ParseMacro_scales_the_live_vectors(int mantissa, int dec, double expected) + { + FocasWireClient.ParseMacro(1, Odbm(mantissa, dec)).Scaled!.Value.ShouldBe(expected, 1e-9); + } + + [Fact] + public void A_vacant_macro_has_no_value_not_zero() + { + var m = FocasWireClient.ParseMacro(611, Odbm(0, 0xFFFF)); + + m.Decimal.ShouldBe((short)-1); + m.IsVacant.ShouldBeTrue(); + m.Scaled.ShouldBeNull(); + } + + [Fact] + public void A_genuine_zero_is_not_vacant() + { + var m = FocasWireClient.ParseMacro(3901, Odbm(0, 0)); + m.IsVacant.ShouldBeFalse(); + m.Scaled.ShouldBe(0.0); + } + + [Theory] + [InlineData(0)] + [InlineData(4)] + [InlineData(6)] + [InlineData(7)] + public void ParseMacro_refuses_a_payload_too_short_to_carry_dec_val(int length) + { + Should.Throw(() => FocasWireClient.ParseMacro(1, new byte[length])); + } + + // ---- published value / quality ---- + + [Fact] + public void Float64_publishes_the_scaled_value_good() + { + var (value, status) = WireFocasClient.MapMacro(FocasWireClient.ParseMacro(508, Odbm(148_500_000, 6)), FocasDataType.Float64, null); + status.ShouldBe(FocasStatusMapper.Good); + value.ShouldBe(148.5); + } + + [Fact] + public void Int32_publishes_the_exact_whole_value_not_the_mantissa() + { + var (value, status) = WireFocasClient.MapMacro(FocasWireClient.ParseMacro(502, Odbm(270_000_000, 6)), FocasDataType.Int32, null); + status.ShouldBe(FocasStatusMapper.Good); + value.ShouldBe(270); + } + + [Fact] + public void A_fractional_macro_on_an_integer_tag_is_bad_not_truncated() + { + var (value, status) = WireFocasClient.MapMacro(FocasWireClient.ParseMacro(508, Odbm(148_500_000, 6)), FocasDataType.Int32, null); + status.ShouldBe(FocasStatusMapper.BadTypeMismatch); + value.ShouldBeNull(); + } + + [Theory] + [InlineData(FocasDataType.Float64)] + [InlineData(FocasDataType.Float32)] + [InlineData(FocasDataType.Int32)] + public void A_vacant_macro_is_bad_for_every_type(FocasDataType type) + { + var (value, status) = WireFocasClient.MapMacro(FocasWireClient.ParseMacro(611, Odbm(0, 0xFFFF)), type, null); + status.ShouldBe(FocasStatusMapper.BadNoData); + value.ShouldBeNull(); + } + + [Fact] + public void A_whole_macro_out_of_range_for_its_integer_type_is_bad() + { + // 70000 is whole, but does not fit Int16. + var (value, status) = WireFocasClient.MapMacro(FocasWireClient.ParseMacro(1, Odbm(700_000_000, 4)), FocasDataType.Int16, null); + status.ShouldBe(FocasStatusMapper.BadOutOfRange); + value.ShouldBeNull(); + } +} 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/FocasNameRecordTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasNameRecordTests.cs new file mode 100644 index 00000000..9e0a6be1 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasNameRecordTests.cs @@ -0,0 +1,78 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; + +/// +/// Axis-name (0x0089) / spindle-name (0x008A) record decode. Each record is 4 bytes; the name is +/// the ASCII up to the record's FIRST NUL. Ported from the sister ignitionoee FOCAS driver +/// (Focas.Wire-001 and Focas.Wire-022, v0.18.1). +/// +/// +/// Reading only 2 bytes collides real names. The live 31i-B capture +/// (ref-axisname-0x0089.bin) carries "AA6" and "AA7"; the old 2-byte read +/// turned both into "AA", which is exactly the "one observed Makino returns two axis +/// folders both called AA" that Gitea #548 then had to disambiguate as AA_1/AA_2. +/// Trailing-only trimming keeps buffer residue. A control writes only the bytes the +/// name needs; on Makino Z-34184 (2026-08-19) cnc_rdspdlname came back +/// 53 31 00 4e — "S1" then the 'N' of an earlier "CNC". The name +/// ends at the first NUL. +/// +[Trait("Category", "Unit")] +public sealed class FocasNameRecordTests +{ + [Fact] + public void The_live_axis_name_capture_keeps_three_character_names_distinct() + { + var payload = WireFixtures.Payload("ref-axisname-0x0089.bin", 0x0089); + + var names = FocasWireClient.ReadNameRecords(payload, 32, (index, name) => (index, name)); + + names.Select(n => n.name).ShouldBe(["X", "Y", "Z", "B", "C", "AA6", "AA7"]); + names.Select(n => n.index).ShouldBe([(short)1, 2, 3, 4, 5, 6, 7]); + } + + [Fact] + public void The_live_spindle_name_capture_decodes() + { + var payload = WireFixtures.Payload("ref-spdlname-0x008a.bin", 0x008a); + + FocasWireClient.ReadNameRecords(payload, 8, (_, name) => name).ShouldBe(["S1"]); + } + + [Fact] + public void A_name_ends_at_its_first_NUL_so_residue_after_it_is_discarded() + { + // The Z-34184 spindle-name record: "S1", NUL, then an 'N' left over from a previous "CNC". + FocasWireProtocol.ReadNameRecord([0x53, 0x31, 0x00, 0x4e]).ShouldBe("S1"); + // A single-character name with residue in BOTH trailing bytes. + FocasWireProtocol.ReadNameRecord([0x58, 0x00, 0x43, 0x4e]).ShouldBe("X"); + } + + [Fact] + public void All_four_record_bytes_are_name_bytes() + { + FocasWireProtocol.ReadNameRecord("AA6\0"u8.ToArray()).ShouldBe("AA6"); + FocasWireProtocol.ReadNameRecord("ABCD"u8.ToArray()).ShouldBe("ABCD"); + FocasWireProtocol.ReadNameRecord("X \0\0"u8.ToArray()).ShouldBe("X"); + } + + [Fact] + public void A_record_that_starts_with_NUL_is_blank() + { + FocasWireProtocol.ReadNameRecord([0x00, 0x41, 0x41, 0x00]).ShouldBe(string.Empty); + } + + [Fact] + public void Servo_meter_names_come_out_whole_too() + { + var blocks = WireFixtures.Blocks("svmeter-pair-0x0056-0x0089.bin"); + var sv = blocks.First(b => b.Command == 0x0056).Payload; + var names = blocks.First(b => b.Command == 0x0089).Payload; + + var meters = FocasWireClient.ParseServoMeters(sv, names, maxCount: 32); + + meters.Take(7).Select(m => m.Name).ShouldBe(["X", "Y", "Z", "B", "C", "AA6", "AA7"]); + } +} 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 ReadWhenPublishedAsync(FocasDriver drv, string reference) - { - var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5); - while (DateTime.UtcNow < deadline) - { - var snaps = await drv.ReadAsync([reference], CancellationToken.None); - var snap = snaps.Single(); - if (snap.StatusCode == FocasStatusMapper.Good && snap.Value is not null) - return snap.Value; - await Task.Delay(15); - } - return null; - } - - /// - /// An auto figure for an axis WINS over the configured manual value: figure 3 ⇒ ÷10^3 - /// (12345 → 12.345), even though PositionDecimalPlaces: 1 would have given ÷10. - /// - [Fact] - public async Task Auto_figure_wins_over_manual_config() - { - var factory = new FakeFocasClientFactory - { - Customise = () => SingleAxisClient(AbsSnap(1, 12345), figures: [3]), - }; - var drv = NewDriver(positionDecimalPlaces: 1, factory); - await drv.InitializeAsync("{}", CancellationToken.None); - - var abs = await ReadWhenPublishedAsync(drv, $"{Host}/Axes/X/AbsolutePosition"); - abs.ShouldBe(12.345); // ÷10^3 (auto), NOT 1234.5 (manual ÷10) - - await drv.ShutdownAsync(CancellationToken.None); - } - - /// - /// When cnc_getfigure reports nothing (empty list — the real wire backend's - /// behaviour), the driver falls back to the configured PositionDecimalPlaces: - /// manual 2 ⇒ ÷10^2 (12345 → 123.45). - /// - [Fact] - public async Task Falls_back_to_manual_when_getfigure_empty() - { - var factory = new FakeFocasClientFactory - { - Customise = () => SingleAxisClient(AbsSnap(1, 12345), figures: []), - }; - var drv = NewDriver(positionDecimalPlaces: 2, factory); - await drv.InitializeAsync("{}", CancellationToken.None); - - var abs = await ReadWhenPublishedAsync(drv, $"{Host}/Axes/X/AbsolutePosition"); - abs.ShouldBe(123.45); // ÷10^2 (manual fallback) - - await drv.ShutdownAsync(CancellationToken.None); - } - - /// - /// Figures are applied per-axis independently: figures [3, 1] ⇒ axis 0 ÷10^3 and - /// axis 1 ÷10^1. - /// - [Fact] - public async Task Per_axis_figures_scale_independently() - { - var factory = new FakeFocasClientFactory - { - Customise = () => TwoAxisClient( - AbsSnap(1, 12345), AbsSnap(2, 67890), figures: [3, 1]), - }; - var drv = NewDriver(positionDecimalPlaces: 0, factory); - await drv.InitializeAsync("{}", CancellationToken.None); - - var x = await ReadWhenPublishedAsync(drv, $"{Host}/Axes/X/AbsolutePosition"); - var y = await ReadWhenPublishedAsync(drv, $"{Host}/Axes/Y/AbsolutePosition"); - x.ShouldBe(12.345); // ÷10^3 - y.ShouldBe(6789.0); // ÷10^1 - - await drv.ShutdownAsync(CancellationToken.None); - } - - /// - /// The legacy manual-only path is unchanged: no auto figures + PositionDecimalPlaces: 0 - /// ⇒ factor 1.0, so the raw integer is published widened to double (12345 → 12345.0). - /// - [Fact] - public async Task Manual_only_legacy_path_unchanged() - { - var factory = new FakeFocasClientFactory - { - Customise = () => SingleAxisClient(AbsSnap(1, 12345), figures: []), - }; - var drv = NewDriver(positionDecimalPlaces: 0, factory); - await drv.InitializeAsync("{}", CancellationToken.None); - - var abs = await ReadWhenPublishedAsync(drv, $"{Host}/Axes/X/AbsolutePosition"); - abs.ShouldBe(12345.0); // factor 1.0 — raw widened to double - - await drv.ShutdownAsync(CancellationToken.None); - } -} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPositionBlockScaleTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPositionBlockScaleTests.cs new file mode 100644 index 00000000..26f5e001 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPositionBlockScaleTests.cs @@ -0,0 +1,105 @@ +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 self-describing cnc_rddynamic2 positions. Each block's own +/// base^exponent wins over the configured PositionDecimalPlaces (which is now only +/// the fallback for a scale-less block), and a block describing no usable position Bads its own +/// node without disturbing its siblings. Replaces the cnc_getfigure (0x00D3) auto-scale +/// tests — that command is not a FOCAS wire command; the real 31i-B refuses it with rc=1. +/// +[Trait("Category", "Unit")] +public sealed class FocasPositionBlockScaleTests +{ + private const string Host = "focas://10.0.0.5:8193"; + + private static FocasDynamicSnapshot Snap(int axisIndex, FocasPosition abs, FocasPosition mach) => + new(AxisIndex: axisIndex, AlarmFlags: 0, ProgramNumber: 0, MainProgramNumber: 0, + SequenceNumber: 0, ActualFeedRate: 0, ActualSpindleSpeed: 0, + AbsolutePosition: abs, MachinePosition: mach, RelativePosition: 0, DistanceToGo: 0); + + private static FocasDriver NewDriver(int positionDecimalPlaces, FakeFocasClient client) => + new(new FocasDriverOptions + { + Devices = [new FocasDeviceOptions(Host, PositionDecimalPlaces: positionDecimalPlaces)], + Probe = new FocasProbeOptions { Enabled = false }, + FixedTree = new FocasFixedTreeOptions { Enabled = true, PollInterval = TimeSpan.FromMilliseconds(10) }, + }, "drv-1", new FakeFocasClientFactory { Customise = () => client }); + + private static async Task ReadWhenPolledAsync(FocasDriver drv, string reference) + { + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5); + DataValueSnapshot last; + do + { + // Until the first poll lands the reference resolves to nothing; once it has, the node + // reads either Good or (for an undescribable block) BadNoData. + last = (await drv.ReadAsync([reference], CancellationToken.None)).Single(); + if (last.StatusCode is FocasStatusMapper.Good or FocasStatusMapper.BadNoData) return last; + await Task.Delay(15); + } while (DateTime.UtcNow < deadline); + return last; + } + + private static FakeFocasClient XAxisClient(FocasDynamicSnapshot snap) + { + var c = new FakeFocasClient(); + c.AxisNames.Clear(); + c.AxisNames.Add(new("X", "")); + c.DynamicByAxis[1] = snap; + return c; + } + + [Fact] + public async Task The_blocks_own_scale_wins_over_PositionDecimalPlaces() + { + // The live 31i-B shape: raw 2801574, base 10, exponent 4 → 280.1574 mm. + var client = XAxisClient(Snap(1, new FocasPosition(2_801_574, 10, 4, HasScale: true), 0)); + var drv = NewDriver(positionDecimalPlaces: 1, client); + await drv.InitializeAsync("{}", CancellationToken.None); + + var abs = await ReadWhenPolledAsync(drv, $"{Host}/Axes/X/AbsolutePosition"); + + abs.StatusCode.ShouldBe(FocasStatusMapper.Good); + ((double)abs.Value!).ShouldBe(280.1574, 1e-9); // NOT 280157.4 (the ÷10 fallback) + await drv.ShutdownAsync(CancellationToken.None); + } + + [Fact] + public async Task A_scale_less_block_falls_back_to_PositionDecimalPlaces() + { + var client = XAxisClient(Snap(1, 12345, 0)); + var drv = NewDriver(positionDecimalPlaces: 2, client); + await drv.InitializeAsync("{}", CancellationToken.None); + + var abs = await ReadWhenPolledAsync(drv, $"{Host}/Axes/X/AbsolutePosition"); + + abs.StatusCode.ShouldBe(FocasStatusMapper.Good); + abs.Value.ShouldBe(123.45); + await drv.ShutdownAsync(CancellationToken.None); + } + + [Fact] + public async Task A_vacant_block_bads_its_own_node_and_not_its_sibling() + { + var client = XAxisClient(Snap( + 1, + abs: new FocasPosition(0, 10, 0xFFFF, HasScale: true), // vacant-slot marker + mach: new FocasPosition(-22000, 10, 3, HasScale: true))); + var drv = NewDriver(positionDecimalPlaces: 0, client); + await drv.InitializeAsync("{}", CancellationToken.None); + + var mach = await ReadWhenPolledAsync(drv, $"{Host}/Axes/X/MachinePosition"); + var abs = await ReadWhenPolledAsync(drv, $"{Host}/Axes/X/AbsolutePosition"); + + mach.StatusCode.ShouldBe(FocasStatusMapper.Good); + mach.Value.ShouldBe(-22.0); + abs.StatusCode.ShouldBe(FocasStatusMapper.BadNoData); + abs.Value.ShouldBeNull("an undescribable block is not a Good 0.0 at the origin"); + await drv.ShutdownAsync(CancellationToken.None); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPositionDecodeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPositionDecodeTests.cs new file mode 100644 index 00000000..e515e3d5 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPositionDecodeTests.cs @@ -0,0 +1,107 @@ +using System.Buffers.Binary; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; + +/// +/// cnc_rddynamic2 position blocks (0x0026) are self-describing: [0..3] i32 raw, +/// [4..5] u16 base (2 or 10), [6..7] u16 exponent (0xFFFF = vacant slot); +/// value = raw / base^exponent. Ported from the sister ignitionoee FOCAS driver v0.17.0 +/// (which also deleted the fabricated cnc_getfigure 0x00D3 — the real 31i-B refuses it +/// with rc=1) and Focas.Wire-014 (absurd exponents are refused, not divided by). +/// +[Trait("Category", "Unit")] +public sealed class FocasPositionDecodeTests +{ + private static byte[] Block(int raw, int @base, int exponent) + { + var b = new byte[8]; + BinaryPrimitives.WriteInt32BigEndian(b.AsSpan(0, 4), raw); + BinaryPrimitives.WriteUInt16BigEndian(b.AsSpan(4, 2), (ushort)@base); + BinaryPrimitives.WriteUInt16BigEndian(b.AsSpan(6, 2), (ushort)exponent); + return b; + } + + [Fact] + public void The_live_dynamic2_capture_scales_every_position_from_its_own_block() + { + // ref-dynamic2-axis1.bin: four 0x0026 blocks (absolute / machine / relative / distance of + // axis 1), every one base 10, exponent 4. Undecoded they read 2801574 / 2801200 / 32 / 0. + var dynamic = FocasWireClient.ParseDynamic2(WireFixtures.Blocks("ref-dynamic2-axis1.bin")); + + dynamic.Axis.Absolute.ToEngineering(0)!.Value.ShouldBe(280.1574, 1e-9); + dynamic.Axis.Machine.ToEngineering(0)!.Value.ShouldBe(280.1200, 1e-9); + dynamic.Axis.Relative.ToEngineering(0)!.Value.ShouldBe(0.0032, 1e-9); + dynamic.Axis.Distance.ToEngineering(0)!.Value.ShouldBe(0.0, 1e-9); + dynamic.FeedRate.ShouldBe(0); + dynamic.SpindleSpeed.ShouldBe(0); + } + + [Fact] + public void The_block_scale_wins_over_the_configured_decimal_places() + { + var p = FocasWireClient.ParsePositionBlock(Block(123456, 10, 3)); + p.ToEngineering(fallbackDecimalPlaces: 1)!.Value.ShouldBe(123.456, 1e-9); + } + + [Theory] + [InlineData("live-dynamic2-axis1-0x0026.bin", 0.0374)] // Z-34184 X + [InlineData("live-dynamic2-axis2-0x0026.bin", 370.177)] // Z-34184 Y + public void Axis_scoped_live_requests_return_that_axis_scaled(string fixture, double expected) + { + var blocks = WireFixtures.Blocks(fixture).Where(b => b.Command == 0x0026).ToList(); + blocks.Count.ShouldBe(1, "an axis-scoped request returns exactly one 0x0026 block"); + + FocasWireClient.ParsePositionBlock(blocks[0].Payload).ToEngineering(0)!.Value.ShouldBe(expected, 1e-9); + } + + [Theory] + [InlineData(40, 2, 3, 5.0)] // inch-mode binary base + [InlineData(-22000, 10, 3, -22.0)] + [InlineData(42, 10, 0, 42.0)] + public void Positions_scale_by_base_to_the_exponent(int raw, int @base, int exponent, double expected) + { + FocasWireClient.ParsePositionBlock(Block(raw, @base, exponent)).ToEngineering(0)!.Value.ShouldBe(expected, 1e-9); + } + + [Theory] + [InlineData(1, 10, 0xFFFF)] // vacant-slot marker + [InlineData(1234, 7, 2)] // base neither 2 nor 10 + [InlineData(1234, 0, 2)] + [InlineData(123456, 10, 400)] // would divide by Infinity → a Good 0.0 at the origin + [InlineData(123456, 10, 40)] + public void An_undescribable_block_has_no_value(int raw, int @base, int exponent) + { + var p = FocasWireClient.ParsePositionBlock(Block(raw, @base, exponent)); + p.IsValid.ShouldBeFalse(); + p.ToEngineering(3).ShouldBeNull(); + } + + [Fact] + public void Every_plausible_exponent_still_decodes() + { + for (var exp = 0; exp <= 18; exp++) + FocasWireClient.ParsePositionBlock(Block(1, 10, exp)).IsValid.ShouldBeTrue($"exponent {exp}"); + } + + [Fact] + public void A_block_without_a_scale_falls_back_to_the_configured_decimal_places() + { + // A 4-byte block carries no envelope (older simulators); only then does + // PositionDecimalPlaces apply. + var p = FocasWireClient.ParsePositionBlock([0x00, 0x00, 0x30, 0x39]); // 12345 + p.HasScale.ShouldBeFalse(); + p.ToEngineering(2)!.Value.ShouldBe(123.45, 1e-9); + p.ToEngineering(0)!.Value.ShouldBe(12345.0); + } + + [Fact] + public void A_raw_integer_position_converts_implicitly_as_a_scale_less_value() + { + FocasPosition p = 12345; + p.HasScale.ShouldBeFalse(); + p.Raw.ShouldBe(12345); + } +} 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 index 860de0dc..af5b0f29 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPositionScalingTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPositionScalingTests.cs @@ -5,12 +5,11 @@ 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. The managed wire backend fetches per-axis -/// figures live via cnc_getfigure; the config knob is the per-axis fallback used -/// when the CNC reports no figure for an axis. These tests cover the config-supplied path. +/// Phase 4 data-type tier — axis-position scaling, config-supplied fallback path. A real +/// CNC's cnc_rddynamic2 position block carries its own base^exponent scale +/// (covered by ); a scale-less position — the +/// raw integers these tests seed — is divided by 10^PositionDecimalPlaces at the +/// publish seam. /// [Trait("Category", "Unit")] public sealed class FocasPositionScalingTests diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasProgramInfoQualityTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasProgramInfoQualityTests.cs new file mode 100644 index 00000000..ad2479b1 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasProgramInfoQualityTests.cs @@ -0,0 +1,92 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS; +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; + +/// +/// "The control did not say" must read Bad, never a fabricated Good 0. Ported from the sister +/// ignitionoee FOCAS driver (commit 101a3b1, 2026-08-12): a cnc_exeprgname2 payload too +/// short to carry the O-number published 0 at Good quality — indistinguishable from a +/// 31i's genuine 0 for a path-executed program. The same idiom covered every sub-read of the +/// program bundle here: a refused cnc_rdopmode published Mode 0 (= MDI, a real +/// mode) and a refused cnc_rdblkcount a BlockCount of 0. +/// +[Trait("Category", "Unit")] +public sealed class FocasProgramInfoQualityTests +{ + private const string Host = "focas://10.0.0.5:8193"; + + private static FocasResult Name(string name, int? oNumber) => new(0, new WireProgramName(name, oNumber)); + + [Fact] + public void A_payload_without_an_O_number_maps_to_null_not_zero() + { + var info = WireFocasClient.MapProgramInfo(Name("O1111", null), new(0, 42), new(0, 1)); + + info.Name.ShouldBe("O1111"); + info.ONumber.ShouldBeNull(); + info.BlockCount.ShouldBe(42); + info.Mode.ShouldBe(1); + } + + [Fact] + public void A_genuine_zero_O_number_stays_zero() + { + // Live Z-34184: a 256-byte payload whose o_num is present and genuinely 0. + WireFocasClient.MapProgramInfo(Name("//CNC_MEM/USER/LIBRARY/O1111", 0), new(0, 0), new(0, 1)) + .ONumber.ShouldBe(0); + } + + [Fact] + public void Refused_sub_reads_map_to_null_not_a_fabricated_value() + { + var info = WireFocasClient.MapProgramInfo(Name("O1", 1), new FocasResult(1, 0), new FocasResult(1, 0)); + + info.BlockCount.ShouldBeNull(); + info.Mode.ShouldBeNull("Mode 0 is MDI — a real mode, not 'unknown'"); + + var nameless = WireFocasClient.MapProgramInfo(new FocasResult(1, null), new(0, 3), new(0, 1)); + nameless.Name.ShouldBeNull(); + nameless.ONumber.ShouldBeNull(); + } + + [Fact] + public async Task A_missing_field_reads_BadNoData_while_its_siblings_stay_good() + { + var client = new FakeFocasClient { ProgramInfo = new FocasProgramInfo("O1111", null, 12, null) }; + var drv = new FocasDriver(new FocasDriverOptions + { + Devices = [new FocasDeviceOptions(Host)], + Probe = new FocasProbeOptions { Enabled = false }, + FixedTree = new FocasFixedTreeOptions + { + Enabled = true, + PollInterval = TimeSpan.FromMilliseconds(10), + ProgramPollInterval = TimeSpan.FromMilliseconds(10), + }, + }, "drv-1", new FakeFocasClientFactory { Customise = () => client }); + await drv.InitializeAsync("{}", CancellationToken.None); + + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5); + while (DateTime.UtcNow < deadline && drv.GetDeviceState(Host)?.LastProgramInfo is null) + await Task.Delay(15); + + var read = await drv.ReadAsync( + [$"{Host}/Program/Name", $"{Host}/Program/BlockCount", $"{Host}/Program/ONumber", + $"{Host}/OperationMode/Mode", $"{Host}/OperationMode/ModeText"], + CancellationToken.None); + + read[0].StatusCode.ShouldBe(FocasStatusMapper.Good); + read[0].Value.ShouldBe("O1111"); + read[1].StatusCode.ShouldBe(FocasStatusMapper.Good); + read[1].Value.ShouldBe(12); + read[2].StatusCode.ShouldBe(FocasStatusMapper.BadNoData); + read[2].Value.ShouldBeNull(); + read[3].StatusCode.ShouldBe(FocasStatusMapper.BadNoData); + read[4].StatusCode.ShouldBe(FocasStatusMapper.BadNoData); + + await drv.ShutdownAsync(CancellationToken.None); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasWireProtocolTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasWireProtocolTests.cs index ad6bb6fd..eb501502 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasWireProtocolTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasWireProtocolTests.cs @@ -10,7 +10,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; /// Offline byte-level coverage for the managed FOCAS/2 wire-protocol decode layer /// () — the big-endian framing + block-envelope decode that /// is this driver's analogue of P/Invoke struct marshalling. Before this suite the entire -/// wire decode surface (including the cnc_getfigure figure stride) ran without a +/// wire decode surface ran without a /// single byte-level test; every other test drives the IFocasClient seam through the /// Fake, which bypasses all framing. Recorded as Driver.FOCAS-013. /// @@ -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] @@ -239,11 +216,12 @@ public sealed class FocasWireProtocolTests } [Fact] - public void ReadNameRecord_reads_two_bytes_and_strips_padding() + public void ReadNameRecord_strips_padding() { + // Full record semantics (first NUL, all four bytes) are pinned in FocasNameRecordTests. FocasWireProtocol.ReadNameRecord("X \0\0"u8.ToArray()).ShouldBe("X"); FocasWireProtocol.ReadNameRecord("XY"u8.ToArray()).ShouldBe("XY"); - FocasWireProtocol.ReadNameRecord(new byte[] { 0x58 }).ShouldBe(string.Empty); // < 2 bytes + FocasWireProtocol.ReadNameRecord(ReadOnlySpan.Empty).ShouldBe(string.Empty); } // ---- helpers ---- 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); } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/WireFixtures.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/WireFixtures.cs new file mode 100644 index 00000000..0960bd37 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/WireFixtures.cs @@ -0,0 +1,31 @@ +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; + +/// +/// Loads the raw FOCAS response PDUs under Fixtures/v3/ — byte-for-byte captures from a +/// real FANUC 31i-B (Makino, series G431) — and splits them into response blocks with the +/// production . The live-* captures +/// were taken by the sister ignitionoee FOCAS driver's read-only probes of Z-34184 (2026-08-12) +/// and are shared verbatim; the ref-* ones are this repo's own 2026-06-25 capture. +/// +internal static class WireFixtures +{ + private const int PduHeaderLength = 10; + + /// Every response block of the named capture, in wire order. + /// File name under Fixtures/v3/. + /// The parsed response blocks. + public static IReadOnlyList Blocks(string name) + { + var raw = File.ReadAllBytes(Path.Combine(AppContext.BaseDirectory, "Fixtures", "v3", name)); + return FocasWireProtocol.ParseResponseBlocks(raw.AsSpan(PduHeaderLength)); + } + + /// The payload of the first block carrying . + /// File name under Fixtures/v3/. + /// The FOCAS command id to locate. + /// That block's payload bytes. + public static byte[] Payload(string name, ushort command) => + Blocks(name).First(b => b.Command == command).Payload; +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests.csproj b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests.csproj index 5d596302..4a98f116 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests.csproj +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests.csproj @@ -19,6 +19,11 @@ + + + + +