Merge FOCAS wire-decode corrections ported from ignitionoee
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
+55
-21
@@ -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
|
||||
|
||||
|
||||
@@ -132,12 +132,11 @@ public sealed class FocasAlarmProjectionOptions
|
||||
/// default) to skip range validation and preserve legacy behaviour.
|
||||
/// </param>
|
||||
/// <param name="PositionDecimalPlaces">
|
||||
/// Axis positions returned by <c>cnc_rddynamic2</c> are scaled integers. The driver
|
||||
/// divides AbsolutePosition / MachinePosition / RelativePosition / DistanceToGo by
|
||||
/// <c>10^PositionDecimalPlaces</c> at the publish seam so they surface in engineering
|
||||
/// units on the Float64 axis nodes. Default <c>0</c> (no scaling) is byte-identical to
|
||||
/// legacy behaviour. Auto-fetching this via <c>cnc_getfigure</c> is deferred (wire-gated),
|
||||
/// so it is config-supplied. Negative values are clamped to 0 (no scaling).
|
||||
/// <b>Fallback only.</b> A real CNC's <c>cnc_rddynamic2</c> position block carries its own
|
||||
/// scale (<c>raw / base^exponent</c>), 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 <c>10^PositionDecimalPlaces</c>. Default <c>0</c> (no scaling).
|
||||
/// Negative values are clamped to 0.
|
||||
/// </param>
|
||||
public sealed record FocasDeviceOptions(
|
||||
string HostAddress,
|
||||
|
||||
@@ -41,11 +41,18 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
/// config redeploy).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Suffix collisions with genuine names are structurally impossible for axes (FOCAS axis
|
||||
/// displays are at most two characters — name + suffix — so they cannot contain <c>_</c>),
|
||||
/// 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 <c>_</c>), 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Note on the Makino that motivated this.</b> Its two "AA" axes were a decode artefact:
|
||||
/// the name records actually read <c>"AA6"</c> and <c>"AA7"</c>, and the wire layer read only
|
||||
/// the first two bytes of each 4-byte record. With the full record decoded
|
||||
/// (<see cref="Wire.FocasWireProtocol.ReadNameRecord"/>) that machine reports unique names and
|
||||
/// its axes publish bare (<c>Axes/AA6</c>, <c>Axes/AA7</c>). This disambiguation remains as the
|
||||
/// guard for a control that genuinely reports duplicates.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class FixedTreeNames
|
||||
|
||||
@@ -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,
|
||||
/// <see cref="PollGroupEngine"/> → <see cref="ReadAsync"/> path, which hits
|
||||
/// <see cref="TryReadFixedTree"/> and returns these cached values.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the position-scale factor (10^figure) for a single axis. Auto
|
||||
/// (<c>cnc_getfigure</c>) wins per-axis; manual <c>PositionDecimalPlaces</c> 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 <c>ReadPositionFiguresAsync</c>; 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
|
||||
/// <see cref="FocasStatusMapper.BadNoData"/> for that node only — never a Good number.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>Builds the snapshot for one cached double, mapping <see cref="NoValue"/> to BadNoData.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read and cache both spindle meters (load, motor speed) — each a <c>cnc_rdspmeter</c>
|
||||
/// percentage of full scale (16384 = 100%) — for every spindle slot, as capability allows.
|
||||
/// A slot whose record describes no value is cached as <see cref="NoValue"/> (BadNoData).
|
||||
/// </summary>
|
||||
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<FocasAxisName> Axes,
|
||||
IReadOnlyList<FocasSpindleName> Spindles,
|
||||
IReadOnlyList<int> SpindleMaxRpms,
|
||||
FocasFixedTreeCapabilities Capabilities)
|
||||
{
|
||||
/// <summary>
|
||||
@@ -1515,14 +1532,14 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// <summary>
|
||||
/// 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 <c>cnc_rdspmaxrpm</c> simply
|
||||
/// doesn't get a <c>Spindle/{name}/MaxRpm</c> node (instead of surfacing
|
||||
/// node suppression so a 16i that doesn't export <c>cnc_rdspmeter</c> simply
|
||||
/// doesn't get a <c>Spindle/{name}/MotorSpeedPct</c> node (instead of surfacing
|
||||
/// <c>BadDeviceFailure</c> on every read).
|
||||
/// </summary>
|
||||
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; }
|
||||
/// <summary>
|
||||
/// Gets the last fixed-tree snapshots by field name. Double-typed so axis
|
||||
/// positions can carry the <c>10^PositionDecimalPlaces</c> engineering-unit
|
||||
/// scale applied at <see cref="PublishAxisSnapshot"/>; integer fields
|
||||
/// (feed rate, spindle speed) widen to double on store.
|
||||
/// positions can carry the engineering-unit scale applied at
|
||||
/// <see cref="PublishAxisSnapshot"/>; integer fields (feed rate, spindle speed) widen
|
||||
/// to double on store. <see cref="NoValue"/> (NaN) marks a field the CNC described as
|
||||
/// holding no value.
|
||||
/// </summary>
|
||||
public ConcurrentDictionary<string, double> LastFixedSnapshots { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
/// <summary>Gets or sets the last program information snapshot.</summary>
|
||||
@@ -1591,15 +1609,16 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
public ConcurrentDictionary<FocasTimerKind, FocasTimer> LastTimers { get; } = new();
|
||||
/// <summary>Gets the last servo load percentages by servo name.</summary>
|
||||
public ConcurrentDictionary<string, double> LastServoLoads { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
/// <summary>Gets the last spindle load percentages by spindle index.</summary>
|
||||
public ConcurrentDictionary<int, int> LastSpindleLoads { get; } = new();
|
||||
/// <summary>
|
||||
/// Gets or sets the per-axis position decimal-place figures fetched once at init via
|
||||
/// <c>cnc_getfigure</c> (parallel to the axis-name list; index = axis). An auto figure
|
||||
/// for an axis WINS over the configured <c>PositionDecimalPlaces</c>; 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;
|
||||
/// <see cref="NoValue"/> for a slot the CNC described as holding none.
|
||||
/// </summary>
|
||||
public IReadOnlyList<int> PositionFigures { get; set; } = [];
|
||||
public ConcurrentDictionary<int, double> LastSpindleLoads { get; } = new();
|
||||
/// <summary>
|
||||
/// Gets the last spindle motor-speed percentages (% of maximum speed) by spindle index;
|
||||
/// <see cref="NoValue"/> for a slot the CNC described as holding none.
|
||||
/// </summary>
|
||||
public ConcurrentDictionary<int, double> LastSpindleMotorSpeeds { get; } = new();
|
||||
|
||||
/// <summary>Disposes the FOCAS client instance.</summary>
|
||||
public void DisposeClient()
|
||||
|
||||
@@ -290,9 +290,9 @@ public static class FocasDriverFactoryExtensions
|
||||
public string? Series { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the axis-position decimal places. <c>cnc_rddynamic2</c> returns
|
||||
/// positions as scaled integers; the driver divides by <c>10^PositionDecimalPlaces</c>
|
||||
/// so they surface in engineering units. Omitted / <c>0</c> = no scaling (legacy).
|
||||
/// Gets or sets the fallback axis-position decimal places, applied only to a
|
||||
/// <c>cnc_rddynamic2</c> position block that carries no scale of its own (a real CNC's
|
||||
/// block does, and its scale wins). Omitted / <c>0</c> = no scaling.
|
||||
/// </summary>
|
||||
public int? PositionDecimalPlaces { get; init; }
|
||||
}
|
||||
|
||||
@@ -19,6 +19,14 @@ public static class FocasStatusMapper
|
||||
public const uint BadTimeout = 0x800A0000u;
|
||||
public const uint BadTypeMismatch = 0x80740000u;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>0</c>.
|
||||
/// </summary>
|
||||
public const uint BadNoData = 0x809B0000u;
|
||||
|
||||
/// <summary>
|
||||
/// Map common FWLIB <c>EW_*</c> 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,
|
||||
|
||||
@@ -140,9 +140,10 @@ public interface IFocasClient : IDisposable
|
||||
// ---- Fixed-tree T3.5 (servo meters) ----
|
||||
|
||||
/// <summary>
|
||||
/// Read the servo-load meter percentages across all configured axes.
|
||||
/// Values are percentages (scaled by <c>10^Dec</c>). Empty list on a
|
||||
/// disconnected session or unsupported CNC.
|
||||
/// Read the servo-load meter percentages across all configured axes. Each record's own
|
||||
/// <c>value / base^exponent</c> envelope is applied (servo loads are direct percentages);
|
||||
/// a record whose envelope describes no value carries a null <see cref="FocasServoLoad.LoadPercent"/>.
|
||||
/// Empty list on a disconnected session or unsupported CNC.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The servo-load meter percentages across all configured axes.</returns>
|
||||
@@ -151,36 +152,31 @@ public interface IFocasClient : IDisposable
|
||||
// ---- Fixed-tree T3.6 (spindle meters) ----
|
||||
|
||||
/// <summary>
|
||||
/// Read per-spindle load percentages. Result list index corresponds to
|
||||
/// spindle index from <see cref="GetSpindleNamesAsync"/>. 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 (<c>cnc_rdspmeter</c> type 0; the
|
||||
/// raw meter is a 1/16384 fraction of full scale). Result list index corresponds to
|
||||
/// spindle index from <see cref="GetSpindleNamesAsync"/>; 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).
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>Per-spindle load percentages, indexed as per <see cref="GetSpindleNamesAsync"/>.</returns>
|
||||
Task<IReadOnlyList<int>> GetSpindleLoadsAsync(CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<double?>> GetSpindleLoadsAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Read per-spindle maximum RPM values. Static configuration, fetched once at
|
||||
/// bootstrap. Index alignment as per <see cref="GetSpindleLoadsAsync"/>.
|
||||
/// Read per-spindle motor speed as a percentage of maximum speed (<c>cnc_rdspmeter</c>
|
||||
/// type 1, historically labelled <c>cnc_rdspmaxrpm</c>). A live value, not a max-RPM
|
||||
/// constant. Index alignment and null semantics as per <see cref="GetSpindleLoadsAsync"/>.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>Per-spindle maximum RPM values, indexed as per <see cref="GetSpindleLoadsAsync"/>.</returns>
|
||||
Task<IReadOnlyList<int>> GetSpindleMaxRpmsAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Read the per-axis position decimal-place figure via <c>cnc_getfigure</c>. The returned
|
||||
/// list is parallel to <see cref="GetAxisNamesAsync"/> (index = axis). An EMPTY list means the
|
||||
/// CNC/backend does not report figures — the driver then falls back to the configured
|
||||
/// <c>PositionDecimalPlaces</c>. Values are clamped non-negative.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>Per-axis position decimal-place figures; empty when the CNC/backend doesn't report them.</returns>
|
||||
Task<IReadOnlyList<int>> GetPositionFiguresAsync(CancellationToken cancellationToken);
|
||||
/// <returns>Per-spindle motor-speed percentages, indexed as per <see cref="GetSpindleLoadsAsync"/>.</returns>
|
||||
Task<IReadOnlyList<double?>> GetSpindleMotorSpeedsAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>One servo-meter entry — one axis's current load percentage.</summary>
|
||||
public sealed record FocasServoLoad(string AxisName, double LoadPercent);
|
||||
/// <summary>
|
||||
/// One servo-meter entry — one axis's current load percentage; null when the CNC's record
|
||||
/// described no usable value (never a fabricated 0).
|
||||
/// </summary>
|
||||
public sealed record FocasServoLoad(string AxisName, double? LoadPercent);
|
||||
|
||||
/// <summary>Which cumulative counter <see cref="IFocasClient.GetTimerAsync"/> reads.</summary>
|
||||
public enum FocasTimerKind
|
||||
@@ -238,22 +234,19 @@ public sealed record FocasSpindleName(string Name, string Suffix1, string Suffix
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fast-poll bundle for one axis. Position values are scaled integers; the caller
|
||||
/// divides by <c>10^DecimalPlaces</c> to get the decimal value. DecimalPlaces is
|
||||
/// supplied by the caller — either via device config (<c>PositionDecimalPlaces</c>) or
|
||||
/// the per-axis <see cref="IFocasClient.GetPositionFiguresAsync"/> (<c>cnc_getfigure</c>) path.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// 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 <c>cnc_rdopmode</c> — see <see cref="FocasOpMode"/>.
|
||||
/// <b>A null field means the CNC did not report it</b> (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 <c>0</c>, which is a genuine O-number, block count, and mode (MDI).
|
||||
/// </summary>
|
||||
public sealed record FocasProgramInfo(
|
||||
string Name,
|
||||
int ONumber,
|
||||
int BlockCount,
|
||||
int Mode);
|
||||
string? Name,
|
||||
int? ONumber,
|
||||
int? BlockCount,
|
||||
int? Mode);
|
||||
|
||||
/// <summary>Human-readable text for the <see cref="FocasProgramInfo.Mode"/> integer.</summary>
|
||||
public static class FocasOpMode
|
||||
@@ -277,6 +270,10 @@ public static class FocasOpMode
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fast-poll bundle for one axis (<c>cnc_rddynamic2</c>). Each position carries its own scale
|
||||
/// (see <see cref="FocasPosition"/>); the driver converts it to engineering units at publish.
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// One <c>cnc_rddynamic2</c> position (command <c>0x0026</c>) as the CNC reported it. The 8-byte
|
||||
/// wire block is self-describing — <c>[0..3] i32 raw</c>, <c>[4..5] u16 base</c> (2 or 10),
|
||||
/// <c>[6..7] u16 exponent</c> (<c>0xFFFF</c> = the slot holds no valid position) — and the real
|
||||
/// coordinate is <c>raw / base^exponent</c>. Confirmed on a real FANUC 31i-B: every block of the
|
||||
/// <c>ref-dynamic2-axis1.bin</c> capture reports base 10 / exponent 4 (280.1574 mm, not
|
||||
/// 2801574). That envelope is why no separate figure read exists: the <c>cnc_getfigure</c>
|
||||
/// (0x00D3) this driver used to send is not a FOCAS wire command, and the 31i-B refuses it.
|
||||
/// </summary>
|
||||
/// <param name="Raw">The raw i32 position value.</param>
|
||||
/// <param name="Base">The block's scale base (only 2 and 10 are meaningful).</param>
|
||||
/// <param name="Exponent">The block's scale exponent.</param>
|
||||
/// <param name="HasScale">
|
||||
/// 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 <c>PositionDecimalPlaces</c>.
|
||||
/// </param>
|
||||
public readonly record struct FocasPosition(int Raw, int Base = 0, int Exponent = 0, bool HasScale = false)
|
||||
{
|
||||
/// <summary>
|
||||
/// False when the block says it holds no usable position: the <c>0xFFFF</c> 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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The exponent bound matters: left unbounded, <c>raw / 10^400</c> is
|
||||
/// <c>raw / Infinity = 0.0</c> — a Good reading of a machine parked at the origin.
|
||||
/// </remarks>
|
||||
public bool IsValid => !HasScale || Wire.FocasValueEnvelope.IsValid(Base, Exponent);
|
||||
|
||||
/// <summary>
|
||||
/// The position in engineering units: <c>Raw / Base^Exponent</c> when the block carried its
|
||||
/// own scale (the configured fallback is then ignored), else <c>Raw / 10^fallbackDecimalPlaces</c>.
|
||||
/// Null when <see cref="IsValid"/> is false.
|
||||
/// </summary>
|
||||
/// <param name="fallbackDecimalPlaces">The device's configured <c>PositionDecimalPlaces</c>.</param>
|
||||
/// <returns>The scaled position, or null when the block describes none.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>A bare integer is a scale-less position (legacy shape; tests and fakes).</summary>
|
||||
/// <param name="raw">The raw position value.</param>
|
||||
public static implicit operator FocasPosition(int raw) => new(raw);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One active alarm surfaced by <see cref="IFocasClient.ReadAlarmsAsync"/>. Shape
|
||||
|
||||
@@ -99,16 +99,12 @@ public sealed class SynchronizedFocasClient : IFocasClient
|
||||
RunBoundedAsync(ct => _inner.GetServoLoadsAsync(ct), cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<int>> GetSpindleLoadsAsync(CancellationToken cancellationToken) =>
|
||||
public Task<IReadOnlyList<double?>> GetSpindleLoadsAsync(CancellationToken cancellationToken) =>
|
||||
RunBoundedAsync(ct => _inner.GetSpindleLoadsAsync(ct), cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<int>> GetSpindleMaxRpmsAsync(CancellationToken cancellationToken) =>
|
||||
RunBoundedAsync(ct => _inner.GetSpindleMaxRpmsAsync(ct), cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<int>> GetPositionFiguresAsync(CancellationToken cancellationToken) =>
|
||||
RunBoundedAsync(ct => _inner.GetPositionFiguresAsync(ct), cancellationToken);
|
||||
public Task<IReadOnlyList<double?>> GetSpindleMotorSpeedsAsync(CancellationToken cancellationToken) =>
|
||||
RunBoundedAsync(ct => _inner.GetSpindleMotorSpeedsAsync(ct), cancellationToken);
|
||||
|
||||
/// <summary>Disposes the inner client and the call gate.</summary>
|
||||
public void Dispose()
|
||||
|
||||
@@ -339,21 +339,59 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
var rc = AggregateRc(blocks);
|
||||
if (rc != 0) return new FocasResult<WireDynamic>(rc, null);
|
||||
|
||||
return new FocasResult<WireDynamic>(rc, ParseDynamic2(blocks));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggregate the <c>cnc_rddynamic2</c> response blocks of one axis read into a
|
||||
/// <see cref="WireDynamic"/>. The four <c>0x0026</c> blocks are taken in request order
|
||||
/// (absolute / machine / relative / distance-to-go), each decoded with its own scale by
|
||||
/// <see cref="ParsePositionBlock"/>.
|
||||
/// </summary>
|
||||
/// <param name="blocks">The response blocks of one <see cref="ReadDynamic2Async"/> request.</param>
|
||||
/// <returns>The decoded fast-poll bundle.</returns>
|
||||
internal static WireDynamic ParseDynamic2(IReadOnlyList<ResponseBlock> blocks)
|
||||
{
|
||||
var programPayload = FindPayload(blocks, 0x001c);
|
||||
return new FocasResult<WireDynamic>(
|
||||
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)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decode one <c>cnc_rddynamic2</c> position block (0x0026). The 8-byte block is
|
||||
/// self-describing — <c>[0..3] i32 raw</c>, <c>[4..5] u16 base</c>, <c>[6..7] u16 exponent</c>,
|
||||
/// value = <c>raw / base^exponent</c> — pinned on a real FANUC 31i-B (every block of
|
||||
/// <c>ref-dynamic2-axis1.bin</c> reports base 10 / exponent 4). A 4..7-byte block carries no
|
||||
/// envelope and is returned scale-less (the driver then applies the configured
|
||||
/// <c>PositionDecimalPlaces</c>); a shorter one decodes as a scale-less 0, as before.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Validity (vacant <c>0xFFFF</c> exponent, a base other than 2/10, an absurd exponent) is a
|
||||
/// per-node quality decision made by <see cref="FocasPosition.IsValid"/>, not a fault here:
|
||||
/// raising would let one empty slot take out the whole poll. Ported from the sister
|
||||
/// ignitionoee driver v0.17.0 (<c>Dynamic2.scaledPosition</c>, Focas.Wire-014).
|
||||
/// </remarks>
|
||||
/// <param name="payload">The 0x0026 response-block payload.</param>
|
||||
/// <returns>The position with whatever scale the block carried.</returns>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Read servo-meter load percentages via <c>cnc_rdsvmeter</c> (command <c>0x0056</c>).</summary>
|
||||
@@ -383,47 +421,14 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read the per-axis position decimal-place figures via <c>cnc_getfigure</c>
|
||||
/// (command <c>0x00d3</c>). The response payload is a self-delimiting sequence of
|
||||
/// big-endian <c>short</c> decimal-place counts — one per configured axis — which the
|
||||
/// driver applies as a <c>10^figure</c> scale on raw position reads.
|
||||
/// Read the per-spindle load meter via <c>cnc_rdspmeter</c> (command <c>0x0040</c>, type 0).
|
||||
/// <see cref="WireSpindleMetric.Percent"/> is % of rated load (16384 = 100%).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The <c>0x00d3</c> command id is co-designed with the in-tree <c>focas_mock</c> 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).
|
||||
/// </remarks>
|
||||
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
|
||||
/// <param name="timeout">Optional per-call timeout override.</param>
|
||||
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
|
||||
/// <returns>The per-axis position decimal-place figures, wrapped in a <see cref="FocasResult{T}"/>.</returns>
|
||||
public async Task<FocasResult<IReadOnlyList<int>>> 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<IReadOnlyList<int>>(block.Rc, null);
|
||||
|
||||
var payload = block.Payload;
|
||||
var figures = new List<int>(payload.Length / 2);
|
||||
for (var offset = 0; offset + 2 <= payload.Length; offset += 2)
|
||||
figures.Add(ReadInt16(payload, offset));
|
||||
|
||||
return new FocasResult<IReadOnlyList<int>>(block.Rc, figures);
|
||||
}
|
||||
|
||||
/// <summary>Read per-spindle load percentages via <c>cnc_rdspload</c> (command <c>0x0040</c> with arg1=0).</summary>
|
||||
/// <param name="spindleSelector">Spindle selector; -1 selects all spindles.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
|
||||
/// <param name="timeout">Optional per-call timeout override.</param>
|
||||
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
|
||||
/// <returns>The per-spindle load percentages, wrapped in a <see cref="FocasResult{T}"/>.</returns>
|
||||
/// <returns>The per-spindle load meters, wrapped in a <see cref="FocasResult{T}"/>.</returns>
|
||||
public Task<FocasResult<IReadOnlyList<WireSpindleMetric>>> 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);
|
||||
|
||||
/// <summary>Read per-spindle maximum RPMs via <c>cnc_rdspmaxrpm</c> (command <c>0x0040</c> with arg1=1).</summary>
|
||||
/// <summary>
|
||||
/// Read the per-spindle motor-speed meter via <c>cnc_rdspmeter</c> (command <c>0x0040</c>,
|
||||
/// type 1 — historically labelled <c>cnc_rdspmaxrpm</c>). It is NOT a maximum-RPM constant:
|
||||
/// it is the live motor speed as a fraction of maximum, <see cref="WireSpindleMetric.Percent"/>
|
||||
/// (a regression against <c>cnc_rddynamic2</c> spindle speed on a real 31i-B gives
|
||||
/// <c>rpm = 1.220749 × meter</c>, i.e. 16384 = the spindle's 20,000 rpm maximum).
|
||||
/// </summary>
|
||||
/// <param name="spindleSelector">Spindle selector; -1 selects all spindles.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
|
||||
/// <param name="timeout">Optional per-call timeout override.</param>
|
||||
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
|
||||
/// <returns>The per-spindle maximum RPMs, wrapped in a <see cref="FocasResult{T}"/>.</returns>
|
||||
public Task<FocasResult<IReadOnlyList<WireSpindleMetric>>> ReadSpindleMaxRpmAsync(
|
||||
/// <returns>The per-spindle motor-speed meters, wrapped in a <see cref="FocasResult{T}"/>.</returns>
|
||||
public Task<FocasResult<IReadOnlyList<WireSpindleMetric>>> 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);
|
||||
|
||||
/// <summary>
|
||||
/// Decode a <c>cnc_rdmacro</c> (0x0015) payload. The on-wire <c>ODBM</c> record is 8
|
||||
/// big-endian bytes: <c>[0..3] i32 mantissa</c>, <c>[4..5] i16</c> length field (a
|
||||
/// <b>constant 10</b> on every observed response), <c>[6..7] i16 dec_val</c>
|
||||
/// (<c>0xFFFF</c> = vacant).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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 <c>#500 = 1.0</c> published <c>0.01</c>). 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
|
||||
/// <c>dec_val</c> and is refused rather than guessed at.
|
||||
/// </remarks>
|
||||
/// <param name="number">The macro number the request asked for.</param>
|
||||
/// <param name="payload">The response-block payload.</param>
|
||||
/// <returns>The decoded macro.</returns>
|
||||
/// <exception cref="FocasWireException">The payload is shorter than 8 bytes.</exception>
|
||||
internal static WireMacro ParseMacro(short number, byte[] payload)
|
||||
{
|
||||
RequireLength(payload, 8, "cnc_rdmacro");
|
||||
return new WireMacro(number, ReadInt32(payload, 0), ReadInt16(payload, 6));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read a PMC range via <c>pmc_rdpmcrng</c>. <paramref name="area"/> is the numeric
|
||||
/// address-letter code (see <see cref="FocasPmcArea"/>); <paramref name="dataType"/>
|
||||
@@ -745,13 +780,27 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
callTimeout.Token,
|
||||
new RequestBlock(0x0040, metric, spindleSelector, PathId: EffectivePathId(pathId))).ConfigureAwait(false);
|
||||
|
||||
return ToResult<IReadOnlyList<WireSpindleMetric>>(block, payload =>
|
||||
{
|
||||
var values = new List<WireSpindleMetric>();
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decode a <c>cnc_rdspmeter</c> (0x0040) payload: 8-byte records of
|
||||
/// <c>[+0..3] i32 value</c>, <c>[+4..5] u16 base</c>, <c>[+6..7] u16 exponent</c>, one per
|
||||
/// slot of a fixed multi-slot buffer (slots past the spindle count are residue and decode
|
||||
/// as <see cref="WireSpindleMetric.IsValid"/> = false). Every slot is returned, in wire order.
|
||||
/// </summary>
|
||||
/// <param name="payload">The response-block payload.</param>
|
||||
/// <returns>The per-slot meters.</returns>
|
||||
internal static IReadOnlyList<WireSpindleMetric> ParseSpindleMetrics(byte[] payload)
|
||||
{
|
||||
var values = new List<WireSpindleMetric>();
|
||||
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<FocasResult<T>> ReadSingleAsync<T>(
|
||||
@@ -932,18 +981,16 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
payload.Length >= 8 ? BinaryPrimitives.ReadInt32LittleEndian(payload.AsSpan(4, 4)) : 0);
|
||||
|
||||
/// <summary>
|
||||
/// Decode a <c>cnc_rdsvmeter</c> response into <see cref="WireServoMeter"/> records. On the
|
||||
/// 31i-B (v3) each per-axis LOADELM is <b>8 bytes</b> — {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 <c>cnc_rdaxisname</c> (0x0089) block requested alongside it and are correlated
|
||||
/// by index. Validated against a live 31i-B 2026-06-25.
|
||||
/// <para><b>Scaling caveat:</b> downstream applies LoadPercent = data / 10^dec; on the 31i-B
|
||||
/// <c>dec</c> 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.</para>
|
||||
/// Decode a <c>cnc_rdsvmeter</c> response into <see cref="WireServoMeter"/> records. Each
|
||||
/// per-axis record is <b>8 bytes</b> — <c>[+0..3] i32 value</c>, <c>[+4..5] u16 base</c>,
|
||||
/// <c>[+6..7] u16 exponent</c> — the same self-describing envelope as the position blocks,
|
||||
/// so the load is <c>value / base^exponent</c>, 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
|
||||
/// <c>cnc_rdaxisname</c> (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 <see cref="WireServoMeter.IsValid"/> = false.
|
||||
/// </summary>
|
||||
/// <param name="svPayload">The raw <c>cnc_rdsvmeter</c> response payload.</param>
|
||||
/// <param name="axisNamePayload">The raw <c>cnc_rdaxisname</c> response payload used to correlate axis names by index.</param>
|
||||
@@ -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<ResponseBlock> blocks, ushort command, int selectorIndex)
|
||||
private static FocasPosition ReadSelectorPosition(IReadOnlyList<ResponseBlock> 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<T> ReadNameRecords<T>(byte[] payload, short maxCount, Func<short, string, T> factory)
|
||||
/// <summary>
|
||||
/// Decode an axis-name (0x0089) or spindle-name (0x008A) payload: an array of 4-byte records,
|
||||
/// each named by <see cref="FocasWireProtocol.ReadNameRecord"/>. Blank records are skipped but
|
||||
/// keep their slot's 1-based index.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The record type the factory builds.</typeparam>
|
||||
/// <param name="payload">The response-block payload.</param>
|
||||
/// <param name="maxCount">Maximum number of records to inspect.</param>
|
||||
/// <param name="factory">Builds one record from its 1-based index and name.</param>
|
||||
/// <returns>The non-blank records, in wire order.</returns>
|
||||
internal static IReadOnlyList<T> ReadNameRecords<T>(byte[] payload, short maxCount, Func<short, string, T> factory)
|
||||
{
|
||||
var names = new List<T>();
|
||||
for (var offset = 0; offset + 4 <= payload.Length && offset / 4 < maxCount; offset += 4)
|
||||
|
||||
@@ -35,12 +35,15 @@ public sealed record WireStatus(
|
||||
short Edit,
|
||||
short TmMode);
|
||||
|
||||
/// <summary>Four-slot position quadruple for one axis: absolute / machine / relative / distance-to-go.</summary>
|
||||
/// <summary>
|
||||
/// Four-slot position quadruple for one axis: absolute / machine / relative / distance-to-go,
|
||||
/// each carrying the scale its own <c>0x0026</c> block reported.
|
||||
/// </summary>
|
||||
public sealed record WireAxisPosition(
|
||||
int Absolute,
|
||||
int Machine,
|
||||
int Relative,
|
||||
int Distance);
|
||||
FocasPosition Absolute,
|
||||
FocasPosition Machine,
|
||||
FocasPosition Relative,
|
||||
FocasPosition Distance);
|
||||
|
||||
/// <summary>
|
||||
/// Fast-poll bundle for one axis from <c>cnc_rddynamic2</c> — alarm flags, active program
|
||||
@@ -56,18 +59,104 @@ public sealed record WireDynamic(
|
||||
int SpindleSpeed,
|
||||
WireAxisPosition Axis);
|
||||
|
||||
/// <summary>One servo-meter entry from <c>cnc_rdsvmeter</c> — per-axis load percentage (scale by 10^<see cref="Decimal"/>).</summary>
|
||||
/// <summary>
|
||||
/// The self-describing <c>(value, base, exponent)</c> envelope shared by every value record on
|
||||
/// the FOCAS wire — <c>cnc_rddynamic2</c> positions, servo meters, spindle meters:
|
||||
/// <c>scaled = value / base^exponent</c>, where only base 2 and base 10 are meaningful.
|
||||
/// </summary>
|
||||
internal static class FocasValueEnvelope
|
||||
{
|
||||
/// <summary>
|
||||
/// Largest exponent that can describe a real value. An i32 carries at most ten digits, so
|
||||
/// anything beyond is a misread record — and <c>value / 10^400</c> would be
|
||||
/// <c>value / Infinity = 0.0</c>, a plausible Good zero.
|
||||
/// </summary>
|
||||
public const int MaxExponent = 18;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>0xFFFF</c> vacant marker).
|
||||
/// </summary>
|
||||
/// <param name="base">The record's base field.</param>
|
||||
/// <param name="exponent">The record's exponent field.</param>
|
||||
/// <returns>Whether the record may be published.</returns>
|
||||
public static bool IsValid(int @base, int exponent) => exponent <= MaxExponent && @base is 2 or 10;
|
||||
|
||||
/// <summary><c>value / base^exponent</c>, or null when <see cref="IsValid"/> is false.</summary>
|
||||
/// <param name="value">The raw value.</param>
|
||||
/// <param name="base">The record's base field.</param>
|
||||
/// <param name="exponent">The record's exponent field.</param>
|
||||
/// <returns>The scaled value, or null.</returns>
|
||||
public static double? Scale(int value, int @base, int exponent) =>
|
||||
!IsValid(@base, exponent) ? null : exponent == 0 ? value : value / Math.Pow(@base, exponent);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One servo-meter entry from <c>cnc_rdsvmeter</c> (0x0056): one axis's load. The 8-byte record
|
||||
/// is <c>[+0..3] i32 value</c>, <c>[+4..5] u16 base</c>, <c>[+6..7] u16 exponent</c> and the load
|
||||
/// is a <b>direct</b> percentage, <see cref="Scaled"/> = <c>value / base^exponent</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Offset 4 used to be read as a decimal count ("data / 10^dec"). It is the <b>base</b> — 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).
|
||||
/// </remarks>
|
||||
/// <param name="Index">1-based axis slot.</param>
|
||||
/// <param name="Name">Axis name from the companion 0x0089 block (empty when none).</param>
|
||||
/// <param name="Value">Raw load value.</param>
|
||||
/// <param name="Base">Scale base (2 or 10 when meaningful).</param>
|
||||
/// <param name="Exponent">Scale exponent.</param>
|
||||
public sealed record WireServoMeter(
|
||||
short Index,
|
||||
string Name,
|
||||
int Value,
|
||||
short Decimal,
|
||||
short Unit);
|
||||
int Base,
|
||||
int Exponent)
|
||||
{
|
||||
/// <summary>False for a record whose envelope describes no usable value (buffer residue).</summary>
|
||||
public bool IsValid => FocasValueEnvelope.IsValid(Base, Exponent);
|
||||
|
||||
/// <summary>One spindle metric slot from <c>cnc_rdspload</c> / <c>cnc_rdspmaxrpm</c>.</summary>
|
||||
/// <summary>The load percentage, <c>Value / Base^Exponent</c>; null when <see cref="IsValid"/> is false.</summary>
|
||||
public double? Scaled => FocasValueEnvelope.Scale(Value, Base, Exponent);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One spindle-meter slot from <c>cnc_rdspmeter</c> (0x0040; type 0 = load, type 1 = motor
|
||||
/// speed). Same 8-byte envelope as <see cref="WireServoMeter"/>, but the scaled value is a
|
||||
/// fraction of full scale in <c>1/16384</c> units — see <see cref="Percent"/>.
|
||||
/// </summary>
|
||||
/// <param name="Index">1-based spindle slot.</param>
|
||||
/// <param name="Value">Raw meter value, in 1/16384-of-full-scale units.</param>
|
||||
/// <param name="Base">Scale base.</param>
|
||||
/// <param name="Exponent">Scale exponent.</param>
|
||||
public sealed record WireSpindleMetric(
|
||||
short Index,
|
||||
int Value);
|
||||
int Value,
|
||||
int Base,
|
||||
int Exponent)
|
||||
{
|
||||
/// <summary>
|
||||
/// The meter's full-scale count: <c>16384</c> = 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 <c>cnc_rddynamic2</c> spindle speed over 596–20,000 rpm:
|
||||
/// <c>rpm = 1.220749 × meter</c>, <c>16384 × 1.220749 = 20000.75</c> (Focas.Wire-019).
|
||||
/// </summary>
|
||||
public const int FullScale = 1 << 14;
|
||||
|
||||
/// <summary>False for a record whose envelope describes no usable value (buffer residue).</summary>
|
||||
public bool IsValid => FocasValueEnvelope.IsValid(Base, Exponent);
|
||||
|
||||
/// <summary>
|
||||
/// The meter as a percentage — <c>(Value / Base^Exponent) × 100 / 16384</c> — or null when
|
||||
/// <see cref="IsValid"/> is false. Values above 100 are real (short-term overload; the i16
|
||||
/// record clamps one count short of 200%).
|
||||
/// </summary>
|
||||
public double? Percent => FocasValueEnvelope.Scale(Value, Base, Exponent) is { } scaled
|
||||
? scaled * 100.0 / FullScale
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One axis-name slot from <c>cnc_rdaxisname</c>. <see cref="Index"/> is the 1-based
|
||||
@@ -86,13 +175,31 @@ public sealed record WireParameter(
|
||||
int Value);
|
||||
|
||||
/// <summary>
|
||||
/// Macro variable from <c>cnc_rdmacro</c>. Scaled decimal: the callable value is
|
||||
/// <c>Value / 10^Decimal</c>.
|
||||
/// Macro variable from <c>cnc_rdmacro</c>. Scaled decimal: the real value is
|
||||
/// <c>Value / 10^Decimal</c> (<see cref="Scaled"/>). FANUC normalises every macro to a
|
||||
/// 9-significant-digit mantissa, so <see cref="Value"/> alone is never a value to publish
|
||||
/// (live: <c>#502 = 270</c> arrives as <c>270000000 / 10^6</c>).
|
||||
/// </summary>
|
||||
/// <param name="Number">Macro variable number (the request argument; not carried in the payload).</param>
|
||||
/// <param name="Value">The 9-significant-digit mantissa.</param>
|
||||
/// <param name="Decimal">Implied decimal places; negative (<c>0xFFFF</c> on the wire) = vacant.</param>
|
||||
public sealed record WireMacro(
|
||||
short Number,
|
||||
int Value,
|
||||
short Decimal);
|
||||
short Decimal)
|
||||
{
|
||||
/// <summary>
|
||||
/// True when the CNC reported the variable as vacant (undefined / never set):
|
||||
/// <c>dec_val == 0xFFFF</c>. A vacant macro's mantissa is 0, indistinguishable from a
|
||||
/// genuine 0, so this must be tested before <see cref="Value"/> means anything. Any
|
||||
/// negative decimal is treated as vacant — no other negative has ever been observed and
|
||||
/// failing closed is the safe reading.
|
||||
/// </summary>
|
||||
public bool IsVacant => Decimal < 0;
|
||||
|
||||
/// <summary>The real value, <c>Value / 10^Decimal</c>; null when <see cref="IsVacant"/>.</summary>
|
||||
public double? Scaled => IsVacant ? null : Decimal == 0 ? Value : Value / Math.Pow(10.0, Decimal);
|
||||
}
|
||||
|
||||
/// <summary>PMC range read-back from <c>pmc_rdpmcrng</c>: one or more values of the requested width.</summary>
|
||||
public sealed record WirePmcRange(
|
||||
|
||||
@@ -239,19 +239,34 @@ internal static class FocasWireProtocol
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>"X "</c> becomes
|
||||
/// <c>"X"</c>.
|
||||
/// 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 — <c>"X \0\0"</c> → <c>"X"</c>,
|
||||
/// <c>"AA6\0"</c> → <c>"AA6"</c>, <c>"S1\0N"</c> → <c>"S1"</c>.
|
||||
/// </summary>
|
||||
/// <param name="bytes">The bytes to decode.</param>
|
||||
/// <returns>The decoded name record.</returns>
|
||||
/// <remarks>
|
||||
/// <para><b>All four bytes are name bytes.</b> This used to read only the first two, which
|
||||
/// collapsed the real 31i-B's paired axes <c>"AA6"</c>/<c>"AA7"</c> (live capture
|
||||
/// <c>ref-axisname-0x0089.bin</c>) to two axes both called <c>"AA"</c> — the duplicate Gitea
|
||||
/// #548 then had to disambiguate as <c>AA_1</c>/<c>AA_2</c>.</para>
|
||||
/// <para><b>The first NUL terminates, not trailing-NUL trimming.</b> A control writes only the
|
||||
/// bytes its name needs and leaves the rest of the buffer as it found it: Makino Z-34184's
|
||||
/// <c>cnc_rdspdlname</c> answered <c>53 31 00 4e</c> — <c>"S1"</c> plus the <c>'N'</c> of an
|
||||
/// earlier <c>"CNC"</c> response. Both rules are ported from the sister ignitionoee FOCAS
|
||||
/// driver (Focas.Wire-001 / Focas.Wire-022).</para>
|
||||
/// </remarks>
|
||||
/// <param name="bytes">The record bytes (4 on the wire; a shorter span is read as far as it goes).</param>
|
||||
/// <returns>The decoded name; empty for a blank record.</returns>
|
||||
public static string ReadNameRecord(ReadOnlySpan<byte> 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(' ');
|
||||
}
|
||||
|
||||
/// <summary>Byte length of one axis/spindle name record.</summary>
|
||||
public const int NameRecordLength = 4;
|
||||
|
||||
private static byte[] BuildRequestBlock(RequestBlock request)
|
||||
{
|
||||
var extra = request.ExtraPayload ?? Array.Empty<byte>();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Combine the three program sub-reads into a <see cref="FocasProgramInfo"/>, leaving any
|
||||
/// field the CNC did not report null. A refused read, or an <c>cnc_exeprgname2</c> payload
|
||||
/// too short to carry the trailing O-number, used to publish a fabricated <c>0</c> 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).
|
||||
/// </summary>
|
||||
/// <param name="name">The <c>cnc_exeprgname2</c> result.</param>
|
||||
/// <param name="blockCount">The <c>cnc_rdblkcount</c> result.</param>
|
||||
/// <param name="mode">The <c>cnc_rdopmode</c> result.</param>
|
||||
/// <returns>The snapshot, null where the CNC did not say.</returns>
|
||||
internal static FocasProgramInfo MapProgramInfo(
|
||||
FocasResult<WireProgramName> name, FocasResult<int> blockCount, FocasResult<short> 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<int>> GetSpindleLoadsAsync(CancellationToken cancellationToken) =>
|
||||
public Task<IReadOnlyList<double?>> GetSpindleLoadsAsync(CancellationToken cancellationToken) =>
|
||||
ReadSpindleMetricAsync((sel, ct) => _wire.ReadSpindleLoadAsync(sel, ct), cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<int>> GetSpindleMaxRpmsAsync(CancellationToken cancellationToken) =>
|
||||
ReadSpindleMetricAsync((sel, ct) => _wire.ReadSpindleMaxRpmAsync(sel, ct), cancellationToken);
|
||||
public Task<IReadOnlyList<double?>> GetSpindleMotorSpeedsAsync(CancellationToken cancellationToken) =>
|
||||
ReadSpindleMetricAsync((sel, ct) => _wire.ReadSpindleMotorSpeedAsync(sel, ct), cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
// 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<IReadOnlyList<int>> 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<IReadOnlyList<int>> ReadSpindleMetricAsync(
|
||||
/// <summary>
|
||||
/// One <c>cnc_rdspmeter</c> 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.)
|
||||
/// </summary>
|
||||
private static async Task<IReadOnlyList<double?>> ReadSpindleMetricAsync(
|
||||
Func<short, CancellationToken, Task<FocasResult<IReadOnlyList<WireSpindleMetric>>>> call,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await call(-1, cancellationToken).ConfigureAwait(false);
|
||||
if (!result.IsOk || result.Value is null) return [];
|
||||
var list = new List<int>();
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>Disposes the wire client and releases all resources.</summary>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Turn a decoded macro into the value + quality its tag publishes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>A <b>vacant</b> variable has no value at all → <see cref="FocasStatusMapper.BadNoData"/>,
|
||||
/// never a Good <c>0</c> (its mantissa is 0, indistinguishable from a genuine zero).</para>
|
||||
/// <para>Float tags get <c>mantissa / 10^decimal</c>. <b>Integer</b> tags (Byte / Int16 /
|
||||
/// Int32 / Bit) get the exact whole value, tested with integer arithmetic
|
||||
/// (<c>mantissa % 10^decimal</c>) so no epsilon is involved; a fractional macro on an
|
||||
/// integer tag is <see cref="FocasStatusMapper.BadTypeMismatch"/> — truncating or rounding
|
||||
/// would publish a plausible wrong count at Good quality — and a whole value that does not
|
||||
/// fit the type is <see cref="FocasStatusMapper.BadOutOfRange"/>. Ported from the sister
|
||||
/// ignitionoee driver's <c>macroInteger</c>/<c>macroReal</c> (Focas-017/018).</para>
|
||||
/// </remarks>
|
||||
/// <param name="m">The decoded macro.</param>
|
||||
/// <param name="type">The tag's declared data type.</param>
|
||||
/// <param name="bitIndex">Optional bit index for a <see cref="FocasDataType.Bit"/> tag.</param>
|
||||
/// <returns>The boxed value (null when Bad) and its status.</returns>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Exact <c>mantissa / 10^decimal</c> when it is a whole number; integer arithmetic only.</summary>
|
||||
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)
|
||||
|
||||
+15
@@ -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.
|
||||
|
||||
+6
-3
@@ -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": {
|
||||
|
||||
+156
-32
@@ -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)
|
||||
|
||||
|
||||
+89
-11
@@ -236,14 +236,15 @@ public sealed class WireBackendCoverageTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that per-axis position figures fetched via <c>cnc_getfigure</c> (wire command
|
||||
/// 0x00D3) scale the published <c>AbsolutePosition</c>, and that the auto figure wins over
|
||||
/// the configured <c>PositionDecimalPlaces</c> 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 <c>cnc_rddynamic2</c> position block
|
||||
/// (base 10 / exponent) scales the published <c>AbsolutePosition</c>, and that it wins over
|
||||
/// the configured <c>PositionDecimalPlaces</c> fallback knob. (The mock refuses the old
|
||||
/// <c>cnc_getfigure</c> 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.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// "The control described no value" reads Bad over the real wire, never a Good number:
|
||||
/// a vacant <c>0x0026</c> position block (exponent <c>0xFFFF</c>) Bads only its own axis
|
||||
/// node, an unset macro (vacant <c>dec_val</c>) 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.
|
||||
/// </summary>
|
||||
[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<string, object>
|
||||
{
|
||||
["X"] = new { exponent = 0xFFFF }, // vacant-slot marker
|
||||
["Y"] = 3,
|
||||
},
|
||||
macros = new Dictionary<string, object>
|
||||
{
|
||||
["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<bool> pred, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
|
||||
+13
-9
@@ -210,9 +210,13 @@ public sealed class WireBackendTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that spindle load and max RPM data populate via the wire backend.</summary>
|
||||
/// <summary>
|
||||
/// 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%.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -140,25 +140,18 @@ internal class FakeFocasClient : IFocasClient
|
||||
public virtual Task<IReadOnlyList<FocasServoLoad>> GetServoLoadsAsync(CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<FocasServoLoad>>([.. ServoLoads]);
|
||||
|
||||
/// <summary>Gets the list of spindle loads.</summary>
|
||||
public List<int> SpindleLoads { get; } = [];
|
||||
/// <summary>Gets the list of spindle maximum RPMs.</summary>
|
||||
public List<int> SpindleMaxRpms { get; } = [];
|
||||
/// <summary>Gets the list of spindle load percentages (null = undescribable slot).</summary>
|
||||
public List<double?> SpindleLoads { get; } = [];
|
||||
/// <summary>Gets the list of spindle motor-speed percentages (null = undescribable slot).</summary>
|
||||
public List<double?> SpindleMotorSpeeds { get; } = [];
|
||||
/// <summary>Gets spindle loads asynchronously.</summary>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
public virtual Task<IReadOnlyList<int>> GetSpindleLoadsAsync(CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<int>>([.. SpindleLoads]);
|
||||
/// <summary>Gets spindle maximum RPMs asynchronously.</summary>
|
||||
public virtual Task<IReadOnlyList<double?>> GetSpindleLoadsAsync(CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<double?>>([.. SpindleLoads]);
|
||||
/// <summary>Gets spindle motor-speed percentages asynchronously.</summary>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
public virtual Task<IReadOnlyList<int>> GetSpindleMaxRpmsAsync(CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<int>>([.. SpindleMaxRpms]);
|
||||
|
||||
/// <summary>Gets or sets the per-axis position decimal-place figures returned by <see cref="GetPositionFiguresAsync"/>.</summary>
|
||||
public IReadOnlyList<int> PositionFigures { get; set; } = [];
|
||||
/// <summary>Gets the per-axis position decimal-place figures asynchronously.</summary>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
public virtual Task<IReadOnlyList<int>> GetPositionFiguresAsync(CancellationToken ct) =>
|
||||
Task.FromResult(PositionFigures);
|
||||
public virtual Task<IReadOnlyList<double?>> GetSpindleMotorSpeedsAsync(CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<double?>>([.. SpindleMotorSpeeds]);
|
||||
|
||||
/// <summary>Disposes the client.</summary>
|
||||
public virtual void Dispose()
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+11
-11
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// <c>cnc_rdmacro</c> (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).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The on-wire <c>ODBM</c> payload is 8 bytes: <c>[0..3] i32 mantissa</c>,
|
||||
/// <c>[4..5] i16</c> — a <b>constant 10</b> on every observed response (a length field, NOT the
|
||||
/// decimal), and <c>[6..7] i16 dec_val</c> (<c>0xFFFF</c> = vacant). Reading the decimal at
|
||||
/// offset 4 decodes every macro on every control without ever failing — just wrongly: the
|
||||
/// captured <c>#500 = 1.0</c> (<c>05 f5 e1 00 00 0a 00 08</c>) read as <c>100000000 / 10^10 =
|
||||
/// 0.01</c>. 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.
|
||||
/// </remarks>
|
||||
[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<FocasWireException>(() => 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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Servo (<c>cnc_rdsvmeter</c>, 0x0056) and spindle (<c>cnc_rdspmeter</c>, 0x0040) meter decode.
|
||||
/// Both are arrays of 8-byte records carrying the same self-describing envelope as every other
|
||||
/// value block on this wire — <c>[+0..3] i32 value</c>, <c>[+4..5] u16 base</c> (2 or 10),
|
||||
/// <c>[+6..7] u16 exponent</c> — so <c>scaled = value / base^exponent</c>.
|
||||
/// Ported from the sister ignitionoee FOCAS driver v0.17.2 / v0.17.3 (Focas.Wire-019).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Servo: offset 4 is the BASE, not a decimal count.</b> It holds a steady 10, so the
|
||||
/// old <c>value / 10^decimal</c> 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
|
||||
/// <b>direct</b> percentages (the live capture shows −57..227).</para>
|
||||
/// <para><b>Spindle: a fraction of full scale in 1/16384 units.</b> Settled on the real Z-34184
|
||||
/// by regressing the type-1 meter against the independently read <c>cnc_rddynamic2</c> spindle
|
||||
/// speed over 596–20,000 rpm: <c>rpm = 1.220749 × meter</c>, and <c>16384 × 1.220749 =
|
||||
/// 20000.75</c> — full scale is the spindle's 20,000 rpm maximum. So the type-1 meter
|
||||
/// (<c>cnc_rdspmaxrpm</c> by name) is the <b>motor speed as a % of maximum</b>, 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).</para>
|
||||
/// </remarks>
|
||||
[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<byte>(), 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<byte>(), 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>MotorSpeedPct</c> — not the bootstrap-time
|
||||
/// <c>MaxRpm</c> constant it was mislabelled as.
|
||||
/// </summary>
|
||||
[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<DataValueSnapshot> ReadSettledAsync(FocasDriver drv, string reference, Func<DataValueSnapshot, bool> 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<string> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Reading only 2 bytes collides real names.</b> The live 31i-B capture
|
||||
/// (<c>ref-axisname-0x0089.bin</c>) carries <c>"AA6"</c> and <c>"AA7"</c>; the old 2-byte read
|
||||
/// turned both into <c>"AA"</c>, which is exactly the "one observed Makino returns two axis
|
||||
/// folders both called AA" that Gitea #548 then had to disambiguate as <c>AA_1</c>/<c>AA_2</c>.</para>
|
||||
/// <para><b>Trailing-only trimming keeps buffer residue.</b> A control writes only the bytes the
|
||||
/// name needs; on Makino Z-34184 (2026-08-19) <c>cnc_rdspdlname</c> came back
|
||||
/// <c>53 31 00 4e</c> — <c>"S1"</c> then the <c>'N'</c> of an earlier <c>"CNC"</c>. The name
|
||||
/// ends at the first NUL.</para>
|
||||
/// </remarks>
|
||||
[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"]);
|
||||
}
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 4b — per-axis auto-scale from <c>cnc_getfigure</c> figures. The driver fetches
|
||||
/// per-axis position decimal-place figures at init and applies them at the
|
||||
/// <see cref="FocasDriver"/> publish seam. Precedence: an auto figure for an axis WINS;
|
||||
/// the configured <c>PositionDecimalPlaces</c> 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.
|
||||
/// </summary>
|
||||
[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<int> 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<int> 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<object?> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An auto figure for an axis WINS over the configured manual value: figure 3 ⇒ ÷10^3
|
||||
/// (12345 → 12.345), even though <c>PositionDecimalPlaces: 1</c> would have given ÷10.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When <c>cnc_getfigure</c> reports nothing (empty list — the real wire backend's
|
||||
/// behaviour), the driver falls back to the configured <c>PositionDecimalPlaces</c>:
|
||||
/// manual 2 ⇒ ÷10^2 (12345 → 123.45).
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Figures are applied per-axis independently: figures [3, 1] ⇒ axis 0 ÷10^3 and
|
||||
/// axis 1 ÷10^1.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The legacy manual-only path is unchanged: no auto figures + <c>PositionDecimalPlaces: 0</c>
|
||||
/// ⇒ factor 1.0, so the raw integer is published widened to double (12345 → 12345.0).
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Driver-level publish of self-describing <c>cnc_rddynamic2</c> positions. Each block's own
|
||||
/// <c>base^exponent</c> wins over the configured <c>PositionDecimalPlaces</c> (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 <c>cnc_getfigure</c> (0x00D3) auto-scale
|
||||
/// tests — that command is not a FOCAS wire command; the real 31i-B refuses it with <c>rc=1</c>.
|
||||
/// </summary>
|
||||
[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<DataValueSnapshot> 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// <c>cnc_rddynamic2</c> position blocks (0x0026) are self-describing: <c>[0..3] i32 raw</c>,
|
||||
/// <c>[4..5] u16 base</c> (2 or 10), <c>[6..7] u16 exponent</c> (<c>0xFFFF</c> = vacant slot);
|
||||
/// <c>value = raw / base^exponent</c>. Ported from the sister ignitionoee FOCAS driver v0.17.0
|
||||
/// (which also deleted the fabricated <c>cnc_getfigure</c> 0x00D3 — the real 31i-B refuses it
|
||||
/// with <c>rc=1</c>) and Focas.Wire-014 (absurd exponents are refused, not divided by).
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,11 @@ using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 4 data-type tier — axis-position scaling. <c>cnc_rddynamic2</c> returns
|
||||
/// positions as scaled integers; the driver applies a <c>10^PositionDecimalPlaces</c>
|
||||
/// divide at the <see cref="FocasDriver"/> publish seam so positions surface in
|
||||
/// engineering units on the Float64 axis nodes. The managed wire backend fetches per-axis
|
||||
/// figures live via <c>cnc_getfigure</c>; 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 <c>cnc_rddynamic2</c> position block carries its own <c>base^exponent</c> scale
|
||||
/// (covered by <see cref="FocasPositionBlockScaleTests"/>); a scale-less position — the
|
||||
/// raw integers these tests seed — is divided by <c>10^PositionDecimalPlaces</c> at the
|
||||
/// <see cref="FocasDriver"/> publish seam.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasPositionScalingTests
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// "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 <c>cnc_exeprgname2</c> payload too
|
||||
/// short to carry the O-number published <c>0</c> 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 <c>cnc_rdopmode</c> published Mode <c>0</c> (= MDI, a real
|
||||
/// mode) and a refused <c>cnc_rdblkcount</c> a BlockCount of 0.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasProgramInfoQualityTests
|
||||
{
|
||||
private const string Host = "focas://10.0.0.5:8193";
|
||||
|
||||
private static FocasResult<WireProgramName> 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<int>(1, 0), new FocasResult<short>(1, 0));
|
||||
|
||||
info.BlockCount.ShouldBeNull();
|
||||
info.Mode.ShouldBeNull("Mode 0 is MDI — a real mode, not 'unknown'");
|
||||
|
||||
var nameless = WireFocasClient.MapProgramInfo(new FocasResult<WireProgramName>(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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
/// (<see cref="FocasWireProtocol"/>) — 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 <c>cnc_getfigure</c> figure stride) ran without a
|
||||
/// wire decode surface ran without a
|
||||
/// single byte-level test; every other test drives the <c>IFocasClient</c> seam through the
|
||||
/// Fake, which bypasses all framing. Recorded as Driver.FOCAS-013.
|
||||
/// </summary>
|
||||
@@ -200,29 +200,6 @@ public sealed class FocasWireProtocolTests
|
||||
Should.Throw<FocasWireException>(() => 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<int>();
|
||||
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<byte>.Empty).ShouldBe(string.Empty);
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Loads the raw FOCAS response PDUs under <c>Fixtures/v3/</c> — byte-for-byte captures from a
|
||||
/// real FANUC 31i-B (Makino, series G431) — and splits them into response blocks with the
|
||||
/// production <see cref="FocasWireProtocol.ParseResponseBlocks"/>. The <c>live-*</c> captures
|
||||
/// were taken by the sister ignitionoee FOCAS driver's read-only probes of Z-34184 (2026-08-12)
|
||||
/// and are shared verbatim; the <c>ref-*</c> ones are this repo's own 2026-06-25 capture.
|
||||
/// </summary>
|
||||
internal static class WireFixtures
|
||||
{
|
||||
private const int PduHeaderLength = 10;
|
||||
|
||||
/// <summary>Every response block of the named capture, in wire order.</summary>
|
||||
/// <param name="name">File name under <c>Fixtures/v3/</c>.</param>
|
||||
/// <returns>The parsed response blocks.</returns>
|
||||
public static IReadOnlyList<ResponseBlock> Blocks(string name)
|
||||
{
|
||||
var raw = File.ReadAllBytes(Path.Combine(AppContext.BaseDirectory, "Fixtures", "v3", name));
|
||||
return FocasWireProtocol.ParseResponseBlocks(raw.AsSpan(PduHeaderLength));
|
||||
}
|
||||
|
||||
/// <summary>The payload of the first block carrying <paramref name="command"/>.</summary>
|
||||
/// <param name="name">File name under <c>Fixtures/v3/</c>.</param>
|
||||
/// <param name="command">The FOCAS command id to locate.</param>
|
||||
/// <returns>That block's payload bytes.</returns>
|
||||
public static byte[] Payload(string name, ushort command) =>
|
||||
Blocks(name).First(b => b.Command == command).Payload;
|
||||
}
|
||||
+5
@@ -19,6 +19,11 @@
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Real FANUC 31i-B response captures (raw PDUs); read by WireFixtures. -->
|
||||
<None Include="Fixtures\v3\*.bin" CopyToOutputDirectory="PreserveNewest"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.FOCAS\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
Reference in New Issue
Block a user