From 9b51dbca6073c8b13d08b33b2f142b6698c359bf Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 7 Oct 2026 14:12:06 -0400 Subject: [PATCH] test(focas): bring the vendored focas-mock onto the real 31i-B wire shapes; integration tests follow The mock had served the shapes the driver once agreed with it on, so it validated the very defects just fixed: it ANSWERED the non-existent 0x00D3 cnc_getfigure, wrote base/exponent 0/0 into position blocks, put the macro decimal at offset 4, and served spindle 'max rpm' as a raw constant. Ported from the sister ignitionoee mock (local divergence recorded in VENDORED.md; the LE timer and alarm cap are kept): - 0x00D3 refused with EW_FUNC, as the real control does. - 0x0026 blocks carry base 10 / position_figures exponent; signed arg2, arg2 = 0 or past the axis count refused EW_ATTRIB. - cnc_rdmacro is the 8-byte ODBM shape, 9-digit mantissa, unset = vacant. - servo/spindle meter records carry the envelope; spindle meters are 1/16384-of-full-scale counts (motor speed = speed / max_rpm). Integration tests: spindle test reads Load/MotorSpeedPct percentages; the position-scale test now proves the block's own scale wins; new Vacant_positions_and_macros_read_bad_over_the_wire. 12/12 pass against the mock run locally (python -m focas_mock.cli serve, OTOPCUA_FOCAS_SIM_ENDPOINT). --- .../Docker/focas-mock/VENDORED.md | 15 ++ .../focas-mock/src/focas_mock/defaults.py | 9 +- .../focas-mock/src/focas_mock/server.py | 188 +++++++++++++++--- .../Series/WireBackendCoverageTests.cs | 100 +++++++++- .../Series/WireBackendTests.cs | 22 +- 5 files changed, 279 insertions(+), 55 deletions(-) diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/focas-mock/VENDORED.md b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/focas-mock/VENDORED.md index 701c4cc6..35907315 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/focas-mock/VENDORED.md +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/focas-mock/VENDORED.md @@ -43,3 +43,18 @@ Copy-Item "$src\Dockerfile" "$dest\" Update the snapshot date at the top of this file afterward. No other files belong here — the Docker build context is just the Python package and its metadata. + +## Local divergence (do not lose on refresh) + +`src/focas_mock/server.py` and `defaults.py` carry OtOpcUa-local edits that make the wire +shapes match a real FANUC 31i-B (Makino Z-34184) instead of shapes the driver and the mock had +agreed on between themselves. A refresh that overwrites `src/` must re-apply them: + +- the little-endian `cnc_rdtimer` minute/msec encoding (2026-06-25, live-validated); +- the alarm count cap on `cnc_rdalmmsg2`; +- (2026-10-07, ported from the sister ignitionoee FOCAS mock) `0x00D3` ("cnc_getfigure", not a + real FOCAS wire command) refused with `EW_FUNC`; `0x0026` position blocks carrying their own + `base/exponent` (`position_figures` is now that exponent) and a signed `arg2`; + `cnc_rdmacro` as the 8-byte ODBM shape (`dec_val` at `[6..7]`, 9-digit mantissa, unset + macro = vacant); servo/spindle meter records with the `base/exponent` envelope, spindle + meters in 1/16384-of-full-scale counts. diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/focas-mock/src/focas_mock/defaults.py b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/focas-mock/src/focas_mock/defaults.py index 45857ac1..4a2430d0 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/focas-mock/src/focas_mock/defaults.py +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/focas-mock/src/focas_mock/defaults.py @@ -78,9 +78,12 @@ def make_default_state(profile: Mapping[str, Any]) -> dict[str, Any]: "acts": 3200, "acts2": [3200] + [0] * (max_spindles - 1), "axis_names": axis_names[:max_axis], - # Per-axis position decimal places served by cnc_getfigure (command 0x00d3). - # Default every configured axis to 0 ⇒ 10^0 = 1.0 scale, leaving existing - # position reads unchanged. mock_patch can override individual axes. + # Per-axis position scale, served INSIDE each cnc_rddynamic2 (0x0026) block as + # base 10 / this exponent — NOT by a separate command (0x00d3 "cnc_getfigure" was + # never real; the mock refuses it with EW_FUNC like the control does). Default 0 ⇒ + # 10^0 = 1.0, leaving existing position reads unchanged. mock_patch can override an + # axis with an int exponent, or a dict {"base": .., "exponent": ..} — e.g. + # {"exponent": 65535} for the vacant marker. "position_figures": {name: 0 for name in axis_names[:max_axis]}, "spindle_names": spindle_names, "parameters": { diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/focas-mock/src/focas_mock/server.py b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/focas-mock/src/focas_mock/server.py index 827355ea..52e4ff05 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/focas-mock/src/focas_mock/server.py +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/focas-mock/src/focas_mock/server.py @@ -7,6 +7,7 @@ from typing import Any, Awaitable, Callable from .constants import ( ADMIN_METHODS, + EW_ATTRIB, EW_FUNC, EW_HANDLE, EW_NUMBER, @@ -24,7 +25,52 @@ from .profiles import load_profile Handler = Callable[[dict[str, Any]], Awaitable[tuple[int, dict[str, Any] | None, str | None]]] +class WireRefusal(Exception): + """One response block refuses with this FOCAS ``rc``. + + Raised from a payload builder when the COMMAND exists but its ARGUMENTS name nothing the + control would answer (``cnc_rddynamic2`` with ``arg2 = 0``, for instance). Command-level + refusals live in ``FocasMockServer._WIRE_REFUSED_COMMANDS``. Ported from the sister + ignitionoee mock (Focas.Wire-020). + """ + + def __init__(self, rc: int) -> None: + super().__init__(f"refused with rc={rc}") + self.rc = rc + + class FocasMockServer: + # OtOpcUa-local changes to this vendored snapshot (2026-10-07), ported from the sister + # ignitionoee FOCAS mock so the wire shapes match a real FANUC 31i-B (Makino Z-34184) rather + # than shapes the driver and the mock once agreed on between themselves: + # - 0x00D3 ("cnc_getfigure") is refused with EW_FUNC, as the real control does; + # - 0x0026 position blocks carry their own base/exponent; + # - cnc_rdmacro is the 8-byte ODBM shape with dec_val at [6..7]; + # - servo/spindle meter records carry the base/exponent envelope, spindle meters in + # 1/16384-of-full-scale counts. + # The little-endian cnc_rdtimer encoding and the alarm count cap stay as they were. + + # cnc_rdmacro (ODBM) encoding constants — see _wire_rdmacro / _normalize_macro. + _MACRO_ODBM_LENGTH = 10 + _MACRO_VACANT_DEC = -1 + _MACRO_SIGNIFICANT_DIGITS = 9 + + # Commands this mock REFUSES, and with which rc — the real control's answer. 0x00D3 does not + # exist in the FOCAS wire protocol; serving it let a driver validate a command the real + # 31i-B refuses with rc=1. + _WIRE_REFUSED_COMMANDS = {0xD3: EW_FUNC} + + # Default base served inside a 0x0026 position block (every block of the live 31i-B + # capture reports base 10). + _POSITION_DEFAULT_BASE = 10 + + # Servo/spindle meter records carry the same base/exponent envelope (live: base 10, exp 0). + _METER_BASE = 10 + + # SPINDLE meters (0x0040) are a fraction of full scale in 1/16384 units: 16384 = 100% of + # rated load (metric 0) or of maximum spindle speed (metric 1). SERVO meters are direct %. + _METER_FULL_SCALE = 16384 + def __init__( self, host: str, @@ -208,10 +254,22 @@ class FocasMockServer: def _wire_response_block(self, request_block: bytes) -> bytes: command = int.from_bytes(request_block[6:8], "big") if len(request_block) >= 8 else 0 - payload = self._wire_payload(command, request_block) + refused_rc = self._WIRE_REFUSED_COMMANDS.get(command) + if refused_rc is not None: + return self._make_wire_response_block(command, b"", request_block, rc=refused_rc) + try: + payload = self._wire_payload(command, request_block) + except WireRefusal as refusal: + return self._make_wire_response_block(command, b"", request_block, rc=refusal.rc) return self._make_wire_response_block(command, payload, request_block) - def _make_wire_response_block(self, command: int, payload: bytes, request_block: bytes | None = None) -> bytes: + def _make_wire_response_block( + self, + command: int, + payload: bytes, + request_block: bytes | None = None, + rc: int = EW_OK, + ) -> bytes: block_len = 0x10 + len(payload) block = bytearray(block_len) block[0:2] = block_len.to_bytes(2, "big") @@ -219,6 +277,7 @@ class FocasMockServer: block[2:8] = request_block[2:8] else: block[6:8] = command.to_bytes(2, "big") + block[8:10] = int(rc).to_bytes(2, "big", signed=True) block[14:16] = len(payload).to_bytes(2, "big") block[16:] = payload return bytes(block) @@ -256,8 +315,6 @@ class FocasMockServer: return self._wire_spindle_metric(request_block) if command == 0x56: return self._wire_servo_meter() - if command == 0xD3: - return self._wire_position_figures() if command == 0x57: mode = self.store.snapshot()["operation_mode"] return self._u16(int(mode.get("mode", 0) if isinstance(mode, dict) else mode)) @@ -322,23 +379,54 @@ class FocasMockServer: return self._u32(value) def _wire_dynamic_axis(self, request_block: bytes) -> bytes: + """Serves `cnc_rddynamic2` (0x0026) positions, honouring a SIGNED `arg2` the way the real + 31i-B does (probed 2026-08-12 by the sister ignitionoee project, Focas.Wire-017/020): + `arg2 = 1..n` -> exactly one 8-byte block for that axis; `arg2 = -1` -> every slot in one + block; `arg2 = 0` or past the axis count -> refused (EW_ATTRIB).""" selector = self._block_u32(request_block, 8) - axis = self._block_u32(request_block, 12) + axis = self._block_i32(request_block, 12) field = {4: "absolute", 1: "machine", 6: "relative", 7: "distance"}.get(selector, "absolute") state = self.store.snapshot() names = list(state["axis_names"]) dynamic_axes = state["dynamic"]["axes"] + if axis == 0 or axis > len(names): + raise WireRefusal(EW_ATTRIB) + if axis > 0: + name = names[axis - 1] + return self._wire_position_block(state, name, dynamic_axes.get(name, {}).get(field, 0)) payload = bytearray() for name in names: - position = dynamic_axes.get(name, {}) - payload += self._u32(int(position.get(field, 0))) - payload += self._u32(0) - if axis > 0 and axis <= len(names): - name = names[axis - 1] - position = dynamic_axes.get(name, {}) - return self._u32(int(position.get(field, 0))) + self._u32(0) + payload += self._wire_position_block(state, name, dynamic_axes.get(name, {}).get(field, 0)) return bytes(payload) + def _wire_position_block(self, state: dict[str, Any], name: str, raw: int) -> bytes: + """Encodes one 8-byte `cnc_rddynamic2` position block: value, then its OWN scale. + + [0..3] i32 raw value + [4..5] u16 base (2 or 10) + [6..7] u16 exponent (0xFFFF = the slot holds no valid position) + + `position_figures[name]` is the exponent (base 10); a dict value + `{"base": .., "exponent": ..}` overrides the base too, so a profile can serve a + deliberately undecodable block and prove the client Bads that one axis node. + """ + base, exponent = self._position_scale(state, name) + return ( + self._u32(int(raw)) + + (base & 0xFFFF).to_bytes(2, "big") + + (exponent & 0xFFFF).to_bytes(2, "big") + ) + + def _position_scale(self, state: dict[str, Any], name: str) -> tuple[int, int]: + """Returns the (base, exponent) pair `position_figures` configures for one axis.""" + configured = state.get("position_figures", {}).get(name, 0) + if isinstance(configured, dict): + return ( + int(configured.get("base", self._POSITION_DEFAULT_BASE)), + int(configured.get("exponent", 0)), + ) + return self._POSITION_DEFAULT_BASE, int(configured) + def _wire_wrparam(self, request_block: bytes) -> None: datano = self._block_u32(request_block, 8) extra = request_block[28:] @@ -346,11 +434,38 @@ class FocasMockServer: self.store.merge_patch({"parameters": {str(datano): {"type": "long", "value": value, "decimal": 0}}}) def _wire_rdmacro(self, request_block: bytes) -> bytes: + """Encodes an ODBM `cnc_rdmacro` (0x0015) response payload, as a real 31i-B sends it + (golden capture `ref-macro-500-0x0015.bin`: `05 f5 e1 00 00 0a 00 08` = 1.0): + + [0..3] i32 mantissa (normalised to 9 significant digits) + [4..5] i16 length field — a CONSTANT 10 + [6..7] i16 dec_val — decimal places; 0xFFFF (-1) = VACANT + + A macro the profile does not mock is emitted as vacant, as a real control answers an + undefined variable. + """ number = self._block_u32(request_block, 8) - macro = self.store.snapshot()["macros"].get(str(number), {}) - value = int(macro.get("value", 0)) if isinstance(macro, dict) else 0 - decimal = int(macro.get("decimal", 0)) if isinstance(macro, dict) else 0 - return self._u32(value) + self._u16(decimal) + self._u16(0) + macro = self.store.snapshot()["macros"].get(str(number)) + if isinstance(macro, dict): + mantissa, dec_val = self._normalize_macro( + int(macro.get("value", 0)), int(macro.get("decimal", 0)) + ) + else: + mantissa, dec_val = 0, self._MACRO_VACANT_DEC + return self._u32(mantissa) + self._u16(self._MACRO_ODBM_LENGTH) + self._u16(dec_val) + + def _normalize_macro(self, value: int, decimal: int) -> tuple[int, int]: + """Turns a configured fixed-point `(value, decimal)` into the `(mantissa, dec_val)` a CNC + returns: the mantissa padded to 9 significant digits, `dec_val` raised to match + (live: `#502 = 270` -> `270000000/10^6`). The real value is unchanged.""" + if decimal < 0: + return 0, self._MACRO_VACANT_DEC + if value == 0: + return 0, decimal + pad = self._MACRO_SIGNIFICANT_DIGITS - len(str(abs(value))) + if pad <= 0: + return value, decimal + return value * (10**pad), decimal + pad def _wire_wrmacro(self, request_block: bytes) -> None: number = self._block_u32(request_block, 8) @@ -377,31 +492,34 @@ class FocasMockServer: payload = bytearray() for name in state["axis_names"]: entry = configured.get(name, {"name": name, "value": 0}) + # 8-byte record (live 31i-B): value, then the base/exponent envelope. No axis name — + # the client pairs names positionally from the companion 0x0089 block. payload += self._u32(int(entry.get("value", 0))) - payload += self._u16(0) - payload += self._u16(1) - payload += self._name_record(name, 4) - return bytes(payload) - - def _wire_position_figures(self) -> bytes: - state = self.store.snapshot() - figures = state.get("position_figures", {}) - payload = bytearray() - for name in state["axis_names"]: - payload += self._u16(int(figures.get(name, 0))) + payload += self._u16(self._METER_BASE) + payload += self._u16(int(entry.get("exponent", 0))) return bytes(payload) def _wire_spindle_metric(self, request_block: bytes) -> bytes: + # Both metrics are 1/16384-of-full-scale COUNTS on the wire, not the human units the + # config holds. Metric 0: load % of rated (16384 = 100%). Metric 1: motor speed as a + # fraction of the spindle's maximum (`speed / max_rpm`) — NOT the maximum itself. metric = self._block_u32(request_block, 8) spindle = self.store.snapshot()["spindle"] - if metric == 1: - values = [int(value) for value in spindle.get("max_rpm", [])] - else: - values = [int(entry.get("load", 0)) for entry in spindle.get("load", [])] + maxima = [int(value) for value in spindle.get("max_rpm", [])] + entries = spindle.get("load", []) + values = [] + for index, entry in enumerate(entries): + if metric == 1: + maximum = maxima[index] if index < len(maxima) else 0 + speed = int(entry.get("speed", 0)) + values.append(round(speed * self._METER_FULL_SCALE / maximum) if maximum else 0) + else: + values.append(round(int(entry.get("load", 0)) * self._METER_FULL_SCALE / 100)) payload = bytearray() for value in values[:4]: payload += self._u32(value) - payload += self._u32(0) + payload += self._u16(self._METER_BASE) + payload += self._u16(0) return bytes(payload) def _wire_program_name(self) -> bytes: @@ -480,6 +598,12 @@ class FocasMockServer: return 0 return int.from_bytes(block[offset : offset + 4], "big") + def _block_i32(self, block: bytes, offset: int) -> int: + """A SIGNED request argument — `-1` is a real selector (all axes / all spindles).""" + if offset + 4 > len(block): + return 0 + return int.from_bytes(block[offset : offset + 4], "big", signed=True) + def _u16(self, value: int) -> bytes: return int(value).to_bytes(2, "big", signed=True) diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendCoverageTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendCoverageTests.cs index f09d7e39..cc37571c 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendCoverageTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendCoverageTests.cs @@ -236,14 +236,15 @@ public sealed class WireBackendCoverageTests } /// - /// Verifies that per-axis position figures fetched via cnc_getfigure (wire command - /// 0x00D3) scale the published AbsolutePosition, and that the auto figure wins over - /// the configured PositionDecimalPlaces fallback knob. - /// Seed: X axis absolute = 12345, figure = 3 → published value = 12.345 (÷ 10^3). - /// Config knob = 1 → fallback would give 1234.5. 12.345 uniquely proves the wire path. + /// Verifies that the scale carried INSIDE each cnc_rddynamic2 position block + /// (base 10 / exponent) scales the published AbsolutePosition, and that it wins over + /// the configured PositionDecimalPlaces fallback knob. (The mock refuses the old + /// cnc_getfigure 0x00D3 with EW_FUNC, as a real 31i-B does.) + /// Seed: X axis absolute = 12345, exponent = 3 → published value = 12.345 (÷ 10^3). + /// Config knob = 1 → fallback would give 1234.5. 12.345 uniquely proves the block scale. /// [Fact] - public async Task Position_figures_scale_axis_via_wire_backend() + public async Task Position_block_scale_scales_axis_via_wire_backend() { if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason); var ct = TestContext.Current.CancellationToken; @@ -261,13 +262,13 @@ public sealed class WireBackendCoverageTests X = new { absolute = 12345, machine = 12345, relative = 0, distance = 0 }, }, }, - // Per-axis decimal-place figures for cnc_getfigure (command 0x00D3). - // The mock's _wire_position_figures() reads state["position_figures"][axisName]. + // Per-axis exponent served inside each 0x0026 position block (base 10). + // The mock's _position_scale() reads state["position_figures"][axisName]. position_figures = new { X = 3 }, }, ct); - // PositionDecimalPlaces = 1 is intentionally different from the auto figure (3) - // so the assertion 12.345 uniquely proves the cnc_getfigure path won. + // PositionDecimalPlaces = 1 is intentionally different from the block exponent (3) + // so the assertion 12.345 uniquely proves the block's own scale won. var driver = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions(DeviceHost, PositionDecimalPlaces: 1)], @@ -295,13 +296,90 @@ public sealed class WireBackendCoverageTests state.ShouldNotBeNull(); var published = state.LastFixedSnapshots[$"{DeviceHost}/Axes/X/AbsolutePosition"]; - // 12345 ÷ 10^3 = 12.345 → auto figure (3) won. + // 12345 ÷ 10^3 = 12.345 → the block's own scale (3) won. // 12345 ÷ 10^1 = 1234.5 → would mean config knob (1) was used instead. // 12345 ÷ 10^0 = 12345.0 → would mean no scaling at all. published.ShouldBe(12.345, tolerance: 0.0001); } } + /// + /// "The control described no value" reads Bad over the real wire, never a Good number: + /// a vacant 0x0026 position block (exponent 0xFFFF) Bads only its own axis + /// node, an unset macro (vacant dec_val) Bads its tag, and a fractional macro on an + /// integer tag is refused rather than truncated — while a normalised 9-digit macro + /// mantissa de-scales to its real value. + /// + [Fact] + public async Task Vacant_positions_and_macros_read_bad_over_the_wire() + { + if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason); + var ct = TestContext.Current.CancellationToken; + + await _fx.LoadProfileAsync("FWLIB64", ct); + await _fx.PatchStateAsync(new + { + axis_names = new[] { "X", "Y" }, + dynamic = new + { + alarm = 0, prgnum = 1, prgmnum = 1, seqnum = 1, actf = 0, acts = 0, + axes = new + { + X = new { absolute = 1, machine = 1, relative = 0, distance = 0 }, + Y = new { absolute = 370177, machine = 370177, relative = 0, distance = 0 }, + }, + }, + position_figures = new Dictionary + { + ["X"] = new { exponent = 0xFFFF }, // vacant-slot marker + ["Y"] = 3, + }, + macros = new Dictionary + { + ["502"] = new { value = 270, @decimal = 0 }, // served as 270000000 / 10^6 + ["508"] = new { value = 1485, @decimal = 1 }, // 148.5 + }, + }, ct); + + var drv = new FocasDriver(new FocasDriverOptions + { + Devices = [new FocasDeviceOptions(DeviceHost)], + RawTags = FocasRawTags.Entries( + new FocasTagDefinition("Count502", DeviceHost, "MACRO:502", FocasDataType.Int32, Writable: false), + new FocasTagDefinition("Frac508", DeviceHost, "MACRO:508", FocasDataType.Int32, Writable: false), + new FocasTagDefinition("Unset611", DeviceHost, "MACRO:611", FocasDataType.Float64, Writable: false)), + Probe = new FocasProbeOptions { Enabled = false }, + FixedTree = new FocasFixedTreeOptions + { + Enabled = true, + PollInterval = TimeSpan.FromMilliseconds(100), + ProgramPollInterval = TimeSpan.Zero, + TimerPollInterval = TimeSpan.Zero, + }, + }, driverInstanceId: "wire-vacant", clientFactory: new WireFocasClientFactory()); + + await using (drv) + { + await drv.InitializeAsync("{}", ct); + await WaitFor(() => + drv.GetDeviceState(DeviceHost)?.LastFixedSnapshots + .ContainsKey($"{DeviceHost}/Axes/Y/AbsolutePosition") == true, + TimeSpan.FromSeconds(5)); + + var snaps = await drv.ReadAsync( + [$"{DeviceHost}/Axes/X/AbsolutePosition", $"{DeviceHost}/Axes/Y/AbsolutePosition", + "Count502", "Frac508", "Unset611"], ct); + + snaps[0].StatusCode.ShouldBe(FocasStatusMapper.BadNoData); + snaps[1].StatusCode.ShouldBe(FocasStatusMapper.Good); + Convert.ToDouble(snaps[1].Value).ShouldBe(370.177, tolerance: 1e-9); + snaps[2].StatusCode.ShouldBe(FocasStatusMapper.Good); + snaps[2].Value.ShouldBe(270); + snaps[3].StatusCode.ShouldBe(FocasStatusMapper.BadTypeMismatch); + snaps[4].StatusCode.ShouldBe(FocasStatusMapper.BadNoData); + } + } + private static async Task WaitFor(Func pred, TimeSpan timeout) { var deadline = DateTime.UtcNow + timeout; diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendTests.cs index 6eff4b42..19ef82d0 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendTests.cs @@ -210,9 +210,13 @@ public sealed class WireBackendTests } } - /// Verifies that spindle load and max RPM data populate via the wire backend. + /// + /// Verifies that the spindle load and motor-speed meters populate via the wire backend as + /// percentages of full scale (the mock serves 1/16384-of-full-scale counts, as a real + /// 31i-B does): S1 load 56% / speed 3200 of 6000 rpm = 53.3%, S2 load 12% / 1800 of 4500 = 40%. + /// [Fact] - public async Task Spindle_load_and_max_rpm_populate_via_wire_backend() + public async Task Spindle_load_and_motor_speed_populate_via_wire_backend() { if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason); var ct = TestContext.Current.CancellationToken; @@ -260,21 +264,21 @@ public sealed class WireBackendTests await WaitFor(() => { var state = driver.GetDeviceState(DeviceHost); - return state?.FixedTreeCache is { Capabilities.SpindleLoad: true, Capabilities.SpindleMaxRpm: true } + return state?.FixedTreeCache is { Capabilities.SpindleLoad: true, Capabilities.SpindleMotorSpeed: true } && state.LastSpindleLoads.Count >= 2; }, TimeSpan.FromSeconds(5)); var snapshots = await driver.ReadAsync( [$"{DeviceHost}/Spindle/S1/Load", - $"{DeviceHost}/Spindle/S1/MaxRpm", + $"{DeviceHost}/Spindle/S1/MotorSpeedPct", $"{DeviceHost}/Spindle/S2/Load", - $"{DeviceHost}/Spindle/S2/MaxRpm"], ct); + $"{DeviceHost}/Spindle/S2/MotorSpeedPct"], ct); snapshots.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.Good); - Convert.ToInt32(snapshots[0].Value).ShouldBe(56); - Convert.ToInt32(snapshots[1].Value).ShouldBe(6000); - Convert.ToInt32(snapshots[2].Value).ShouldBe(12); - Convert.ToInt32(snapshots[3].Value).ShouldBe(4500); + Convert.ToDouble(snapshots[0].Value).ShouldBe(56.0, tolerance: 0.01); + Convert.ToDouble(snapshots[1].Value).ShouldBe(3200 * 100.0 / 6000, tolerance: 0.01); + Convert.ToDouble(snapshots[2].Value).ShouldBe(12.0, tolerance: 0.01); + Convert.ToDouble(snapshots[3].Value).ShouldBe(40.0, tolerance: 0.01); } }