diff --git a/docs/drivers/FOCAS-Test-Fixture.md b/docs/drivers/FOCAS-Test-Fixture.md index 9ee4401..85f69cd 100644 --- a/docs/drivers/FOCAS-Test-Fixture.md +++ b/docs/drivers/FOCAS-Test-Fixture.md @@ -18,6 +18,12 @@ FANUC DLL has known crash modes; tests can't replicate those in-process. - `FocasCapabilityTests` — data-type mapping (PMC bit / word / float, macro variable types, parameter types) +- `FocasCapabilityMatrixTests` — per-CNC-series range validation (macro + / parameter / PMC letter + number) across 16i / 0i-D / 0i-F / + 30i / PowerMotion. See [`docs/v2/focas-version-matrix.md`](../v2/focas-version-matrix.md) + for the authoritative matrix. 46 theory cases lock every documented + range boundary — widening a range without updating the doc fails a + test. - `FocasReadWriteTests` — read + write against the fake, FOCAS native status → OPC UA StatusCode mapping - `FocasScaffoldingTests` — `IDriver` lifecycle + multi-device routing @@ -30,6 +36,13 @@ Capability surfaces whose contract is verified: `IDriver`, `IReadable`, `IWritable`, `ITagDiscovery`, `ISubscribable`, `IHostConnectivityProbe`, `IPerCallHostResolver`. +Pre-flight validation runs in `FocasDriver.InitializeAsync` — configs +referencing out-of-range addresses fail at load time with a diagnostic +message naming the CNC series + documented limit. This closes the +cheap half of the hardware-free stability gap; Tier-C process +isolation (task #220) closes the expensive half — see +[`docs/v2/implementation/focas-isolation-plan.md`](../v2/implementation/focas-isolation-plan.md). + ## What it does NOT cover ### 1. FOCAS wire traffic @@ -90,6 +103,13 @@ implemented. - `tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs` — in-process fake implementing `IFocasClient` +- `tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasCapabilityMatrixTests.cs` + — parameterized theories locking the per-series matrix - `src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs` — ctor takes `IFocasClientFactory` +- `src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasCapabilityMatrix.cs` — + per-CNC-series range validator (the matrix the doc describes) +- `docs/v2/focas-version-matrix.md` — authoritative range reference +- `docs/v2/implementation/focas-isolation-plan.md` — Tier-C isolation + plan (task #220) - `docs/v2/driver-stability.md` — Tier C scope + process-isolation rationale diff --git a/docs/v2/focas-version-matrix.md b/docs/v2/focas-version-matrix.md new file mode 100644 index 0000000..ee21a8e --- /dev/null +++ b/docs/v2/focas-version-matrix.md @@ -0,0 +1,145 @@ +# FOCAS version / capability matrix + +Authoritative source for the per-CNC-series ranges that +[`FocasCapabilityMatrix`](../../src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasCapabilityMatrix.cs) +enforces at driver init time. Every row cites the Fanuc FOCAS Developer +Kit function whose documented input range determines the ceiling. + +**Why this exists** — we have no FOCAS hardware on the bench and no +working simulator. Fwlib32 returns `EW_NUMBER` / `EW_PARAM` when you +hand it an address outside the controller's supported range; the +driver would map that to a per-read `BadOutOfRange` at steady state. +Catching at `InitializeAsync` with this matrix surfaces operator +typos + mismatched series declarations as config errors before any +session is opened, which is the only feedback loop available without +a live CNC to read against. + +**Who declares the series** — `FocasDeviceOptions.Series` in +`appsettings.json`. Defaults to `Unknown`, which is permissive — every +address passes validation. Pre-matrix configs don't break on upgrade. + +--- + +## Series covered + +| Enum value | Controller family | Typical era | +| --- | --- | --- | +| `Unknown` | (legacy / not declared) | permissive fallback | +| `Sixteen_i` | 16i / 18i / 21i | 1997-2008 | +| `Zero_i_D` | 0i-D | 2008-2013 | +| `Zero_i_F` | 0i-F | 2013-present, general-purpose | +| `Zero_i_MF` | 0i-MF | 0i-F lathe variant | +| `Zero_i_TF` | 0i-TF | 0i-F turning variant | +| `Thirty_i` | 30i-A / 30i-B | 2007-present, high-end | +| `ThirtyOne_i` | 31i-A / 31i-B | 30i simpler variant | +| `ThirtyTwo_i` | 32i-A / 32i-B | 30i compact | +| `PowerMotion_i` | Power Motion i-A / i-MODEL A | motion-only controller | + +## Macro variable range (`cnc_rdmacro` / `cnc_wrmacro`) + +Common macros `1-33` + `100-199` + `500-999` are universal across all +series. Extended macros (`#10000+`) exist only on higher-end series. +The numbers below reflect the extended ceiling per series per the +DevKit range tables. + +| Series | Min | Max | Notes | +| --- | ---: | ---: | --- | +| `Sixteen_i` | 0 | 999 | legacy ceiling — no extended | +| `Zero_i_D` | 0 | 999 | 0i-D still at legacy ceiling | +| `Zero_i_F` / `Zero_i_MF` / `Zero_i_TF` | 0 | 9999 | extended added on 0i-F | +| `Thirty_i` / `ThirtyOne_i` / `ThirtyTwo_i` | 0 | 99999 | full extended set | +| `PowerMotion_i` | 0 | 999 | atypical — limited macro coverage | + +## Parameter range (`cnc_rdparam` / `cnc_wrparam`) + +| Series | Min | Max | +| --- | ---: | ---: | +| `Sixteen_i` | 0 | 9999 | +| `Zero_i_D` / `Zero_i_F` / `Zero_i_MF` / `Zero_i_TF` | 0 | 14999 | +| `Thirty_i` / `ThirtyOne_i` / `ThirtyTwo_i` | 0 | 29999 | +| `PowerMotion_i` | 0 | 29999 | + +## PMC letters (`pmc_rdpmcrng` / `pmc_wrpmcrng`) + +Addresses are letter + number (e.g. `R100`, `F50.3`). Legacy +controllers omit the `F`/`G` signal groups that 30i-family ladder +programs use, and only the 30i-family exposes `K` (keep-relay) + +`T` (timer). + +| Letter | 16i | 0i-D | 0i-F family | 30i family | Power Motion-i | +| --- | :-: | :-: | :-: | :-: | :-: | +| `X` | yes | yes | yes | yes | yes | +| `Y` | yes | yes | yes | yes | yes | +| `R` | yes | yes | yes | yes | yes | +| `D` | yes | yes | yes | yes | yes | +| `E` | — | yes | yes | yes | — | +| `A` | — | yes | yes | yes | — | +| `F` | — | — | yes | yes | — | +| `G` | — | — | yes | yes | — | +| `M` | — | — | yes | yes | — | +| `C` | — | — | yes | yes | — | +| `K` | — | — | — | yes | — | +| `T` | — | — | — | yes | — | + +Letter match is case-insensitive. `FocasAddress.PmcLetter` is carried +as a string (not char) so the matrix can do ordinal-ignore-case +comparison. + +## PMC address-number ceiling + +PMC addresses are byte-addressed on read + bit-addressed on write; +`FocasAddress` carries the bit index separately, so these are byte +ceilings. + +| Series | Max byte | Notes | +| --- | ---: | --- | +| `Sixteen_i` | 999 | legacy | +| `Zero_i_D` | 1999 | doubled since 16i | +| `Zero_i_F` family | 9999 | | +| `Thirty_i` family | 59999 | highest density | +| `PowerMotion_i` | 1999 | | + +## Error surface + +When a tag fails validation, `FocasDriver.InitializeAsync` throws +`InvalidOperationException` with a message of the form: + +``` +FOCAS tag '' (
) rejected by capability matrix: +``` + +`` is the verbatim string from `FocasCapabilityMatrix.Validate` +and always names the series + the documented limit so the operator +can either raise the limit (if wrong) or correct the CNC series they +declared (if mismatched). Sample: + +``` +FOCAS tag 'X_axis_macro_ext' (MACRO:50000) rejected by capability +matrix: Macro variable #50000 is outside the documented range +[0, 9999] for Zero_i_F. +``` + +## How this matrix stays honest + +- Every row is covered by a parameterized test in + [`FocasCapabilityMatrixTests.cs`](../../tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasCapabilityMatrixTests.cs) + — 46 cases across macro / parameter / PMC-letter / PMC-number + boundaries + unknown-series permissiveness + rejection-message + content + case-insensitivity. +- Widening or narrowing a range in the matrix without updating this + doc will fail a test, because the theories cite the specific row + they reflect in their `InlineData`. +- The matrix is not comprehensive — it encodes only the subset of + FOCAS surface the driver currently exposes (Macro / Parameter / + PMC). When the driver gains a new capability (e.g. tool management, + alarm history), add its series-specific range tables here + matching + tests at the same time. + +## Follow-up + +This validation closes the cheap half of the FOCAS hardware-free +stability gap — config errors now fail at load instead of per-read. +The expensive half is Tier-C process isolation so that a crashing +`Fwlib32.dll` doesn't take the main OPC UA server down with it. See +[`docs/v2/implementation/focas-isolation-plan.md`](implementation/focas-isolation-plan.md) +for that plan (task #220). diff --git a/docs/v2/implementation/focas-isolation-plan.md b/docs/v2/implementation/focas-isolation-plan.md new file mode 100644 index 0000000..cca41ff --- /dev/null +++ b/docs/v2/implementation/focas-isolation-plan.md @@ -0,0 +1,163 @@ +# FOCAS Tier-C isolation — plan for task #220 + +> **Status**: DRAFT — not yet started. Tracks the multi-PR work to +> move `Fwlib32.dll` behind an out-of-process host, mirroring the +> Galaxy Tier-C split in [`phase-2-galaxy-out-of-process.md`](phase-2-galaxy-out-of-process.md). +> +> **Pre-reqs shipped** (this PR): version matrix + pre-flight +> validation + unit tests. Those close the cheap half of the +> hardware-free stability gap. Tier-C closes the expensive half. + +## Why isolate + +`Fwlib32.dll` is a proprietary Fanuc library with no source, no +symbols, and a documented habit of crashing the hosting process on +network errors, malformed responses, and during handle recycling. +Today the FOCAS driver runs in-process with the OPC UA server — +a crash inside the Fanuc DLL takes every driver down with it, +including ones that have nothing to do with FOCAS. Galaxy has the +same class of problem and solved it with the Tier-C pattern (host +service + proxy driver + named-pipe IPC); FOCAS should follow that +playbook. + +## Topology (target) + +``` ++-------------------------------------+ +--------------------------+ +| OtOpcUa.Server (.NET 10 x64) | | OtOpcUaFocasHost | +| | pipe | (.NET 4.8 x86 Windows | +| ZB.MOM.WW.OtOpcUa.Driver.FOCAS | <-----> | service) | +| - FocasProxyDriver (in-proc) | | | +| - supervisor / respawn / BackPr | | Fwlib32.dll + session | +| | | handles + STA thread | ++-------------------------------------+ +--------------------------+ +``` + +Why .NET 4.8 x86 for the host: `Fwlib32.dll` ships as 32-bit only. +The Galaxy.Host is already .NET 4.8 x86 for the same reason +(MXAccess COM bitness), so the NSSM wrapper pattern transfers +directly. + +## Three new projects + +| Project | TFM | Role | +| --- | --- | --- | +| `ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared` | `netstandard2.0` | MessagePack DTOs — `FocasReadRequest`, `FocasReadResponse`, `FocasSubscribeRequest`, `FocasPmcBitWriteRequest`, etc. Same assembly referenced by .NET 10 + .NET 4.8 so the wire format stays identical. | +| `ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host` | `net48` x86 | Windows service. Owns the Fwlib32 session handles + STA thread + handle-recycling loop. Pipe server + per-call auth (same ACL + caller SID + shared secret pattern as Galaxy.Host). | +| `ZB.MOM.WW.OtOpcUa.Driver.FOCAS` (existing) | `net10.0` | Collapses to a proxy that forwards each `IReadable` / `IWritable` / `ISubscribable` call over the pipe. `FocasCapabilityMatrix` + `FocasAddress` stay here — pre-flight runs before any IPC. | + +## Supervisor responsibilities (in the Proxy) + +Mirrors Galaxy.Proxy 1:1: + +1. Start the Host process on first `InitializeAsync` (NSSM-wrapped + service in production, direct spawn in dev) + heartbeat every + 5s. +2. If heartbeat misses 3× in a row, fan out `BadCommunicationError` + to every subscription and respawn with exponential backoff + (1s / 2s / 4s / max 30s). +3. Crash-loop circuit breaker: 5 respawns in 60s → drop to + `BadDeviceFailure` steady state until operator resets. +4. Post-mortem MMF: on Host exit, Host writes its last-N operations + + session state to an MMF the Proxy reads to log context. + +## IPC surface (approximate) + +Every `FocasDriver` method that today calls into Fwlib32 directly +becomes an `ExecuteAsync` call with a typed request: + +| Today (in-process) | Tier-C (IPC) | +| --- | --- | +| `FocasTagReader.Read(tag)` | `client.Execute(new FocasReadRequest(session, address))` | +| `FocasTagWriter.Write(tag, value)` | `client.Execute(new FocasWriteRequest(...))` | +| `FocasPmcBitRmw.Write(tag, bit, value)` | `client.Execute(new FocasPmcBitWriteRequest(...))` — RMW happens in Host so the critical section stays on one process | +| `FocasConnectivityProbe.ProbeAsync` | `client.Execute(new FocasProbeRequest())` | +| `FocasSubscriber.Subscribe(tags)` | `client.Execute(new FocasSubscribeRequest(tags))` — Host owns the poll loop + streams changes back as `FocasDataChangedNotification` over the pipe | + +Subscription streaming is the non-obvious piece: the Host polls on +its own timer + pushes change notifications so the Proxy doesn't +round-trip per poll. Matches `Driver.Galaxy.Host` subscription +forwarding. + +## PR sequence (proposed) + +1. **PR A — shared contracts** + Create `Driver.FOCAS.Shared` with the MessagePack DTOs. No + behaviour change. ~200 LOC + round-trip tests for each DTO. +2. **PR B — Host project skeleton** + Create `Driver.FOCAS.Host` .NET 4.8 x86 project, NSSM wrapper, + pipe server scaffold with the same ACL + caller-SID + shared + secret plumbing as Galaxy.Host. No Fwlib32 wiring yet — returns + `NotImplemented` for everything. ~400 LOC. +3. **PR C — Move Fwlib32 calls into Host** + Move `FocasNativeSession`, `FocasTagReader`, `FocasTagWriter`, + `FocasPmcBitRmw` + the STA thread into the Host. Proxy forwards + over IPC. This is the biggest PR — probably 800-1500 LOC of + move-with-translation. Existing unit tests keep passing because + `IFocasTagFactory` is the DI seam the tests inject against. +4. **PR D — Supervisor + respawn** + Proxy-side heartbeat + respawn + crash-loop circuit breaker + + BackPressure fan-out on Host death. ~500 LOC + chaos tests. +5. **PR E — Post-mortem MMF + operational glue** + MMF writer in Host, reader in Proxy. Install scripts for the + new `OtOpcUaFocasHost` Windows service. Docs. ~300 LOC. + +Total estimate: 2200-3200 LOC across 5 PRs. Consistent with Galaxy +Tier-C but narrower since FOCAS has no Historian + no alarm +history. + +## Testing without hardware + +Same constraint as today: no CNC, no simulator. The isolation work +itself is verifiable without Fwlib32 actually being called: + +- **Pipe contract**: PR A's MessagePack round-trip tests cover every + DTO. +- **Supervisor**: PR D uses a `FakeFocasHost` stub that can be told + to crash, hang, or miss heartbeats. The supervisor's respawn + + circuit-breaker behaviour is fully testable against the stub. +- **IPC ACL + auth**: reuse the Galaxy.Host's existing test harness + pattern — negative tests attempt to connect as the wrong user and + assert rejection. +- **Fwlib32 integration itself**: still untestable without hardware. + When a real CNC becomes available, the smoke tests already + scaffolded in `tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/` + run against it via `FOCAS_ENDPOINT`. + +## Decisions to confirm before starting + +- **Sharing transport code with Galaxy.Host** — should the pipe + server + ACL + shared-secret + MMF plumbing go into a common + `Core.Hosting.Tier-C` project both hosts reference? Probably yes; + deferred until PR B is drafted because the right abstraction only + becomes visible after two uses. +- **Handle-recycling cadence** — Fwlib32 session handles leak + memory over weeks per the Fanuc-published defect list. Galaxy + recycles MXAccess handles on a 24h timer; FOCAS should mirror but + the trigger point (idle vs scheduled) needs operator input. +- **Per-CNC Host process vs one Host serving N CNCs** — one-per-CNC + isolates blast radius but scales poorly past ~20 machines; shared + Host scales but one bad CNC can wedge the lot. Start with shared + Host + document the blast-radius trade; revisit if operators hit + it. + +## Non-goals + +- Simulator work. `open_focas` + other OSS FOCAS simulators are + untested + not maintained; not worth chasing vs. waiting for real + hardware. +- Changing the public `FocasDriverOptions` shape beyond what + already shipped (the `Series` knob). Operator config continues to + look the same after the split — the Tier-C topology is invisible + from `appsettings.json`. +- Historian / long-term history integration. FOCAS driver doesn't + implement `IHistoryProvider` + there's no plan to add it. + +## References + +- [`docs/v2/implementation/phase-2-galaxy-out-of-process.md`](phase-2-galaxy-out-of-process.md) + — the working Tier-C template this plan follows. +- [`docs/drivers/FOCAS-Test-Fixture.md`](../../drivers/FOCAS-Test-Fixture.md) + — what's covered today + what stays blocked on hardware. +- [`docs/v2/focas-version-matrix.md`](../focas-version-matrix.md) — + the capability matrix that pre-flights configs before IPC runs. diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasCapabilityMatrix.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasCapabilityMatrix.cs new file mode 100644 index 0000000..8007c8c --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasCapabilityMatrix.cs @@ -0,0 +1,139 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS; + +/// +/// Documented-API capability matrix — per CNC series, what ranges each +/// supports. Authoritative source for the driver's +/// pre-flight validation in . +/// +/// +/// Ranges come from the Fanuc FOCAS Developer Kit documentation matrix +/// (see docs/v2/focas-version-matrix.md for the authoritative copy with +/// per-function citations). Numbers chosen to match what the FOCAS library +/// accepts — a read against an address outside the documented range returns +/// EW_NUMBER or EW_PARAM at the wire, which this driver maps to +/// BadOutOfRange. Catching at init time surfaces the mismatch as a config +/// error before any session is opened. +/// is treated permissively: every +/// address passes validation. Pre-matrix configs don't break on upgrade; new +/// deployments are encouraged to declare a series in the device options. +/// +public static class FocasCapabilityMatrix +{ + /// + /// Check whether is accepted by a CNC of + /// . Returns null on pass + a failure reason + /// on reject — the driver surfaces the reason string verbatim when failing + /// InitializeAsync so operators see the specific out-of-range without + /// guessing. + /// + public static string? Validate(FocasCncSeries series, FocasAddress address) + { + if (series == FocasCncSeries.Unknown) return null; + + return address.Kind switch + { + FocasAreaKind.Macro => ValidateMacro(series, address.Number), + FocasAreaKind.Parameter => ValidateParameter(series, address.Number), + FocasAreaKind.Pmc => ValidatePmc(series, address.PmcLetter, address.Number), + _ => null, + }; + } + + /// Macro variable number accepted by a CNC series. Cites + /// cnc_rdmacro/cnc_wrmacro in the Developer Kit. + internal static (int min, int max) MacroRange(FocasCncSeries series) => series switch + { + // Common macros 1-33 + 100-199 + 500-999 universally; extended 10000+ only on + // higher-end series. Using the extended ceiling per series per DevKit notes. + FocasCncSeries.Sixteen_i => (0, 999), + FocasCncSeries.Zero_i_D => (0, 999), + FocasCncSeries.Zero_i_F or + FocasCncSeries.Zero_i_MF or + FocasCncSeries.Zero_i_TF => (0, 9999), + FocasCncSeries.Thirty_i or + FocasCncSeries.ThirtyOne_i or + FocasCncSeries.ThirtyTwo_i => (0, 99999), + FocasCncSeries.PowerMotion_i => (0, 999), + _ => (0, int.MaxValue), + }; + + /// Parameter number accepted; from cnc_rdparam/cnc_wrparam. + /// Ranges reflect the highest-numbered parameter documented per series. + internal static (int min, int max) ParameterRange(FocasCncSeries series) => series switch + { + FocasCncSeries.Sixteen_i => (0, 9999), + FocasCncSeries.Zero_i_D or + FocasCncSeries.Zero_i_F or + FocasCncSeries.Zero_i_MF or + FocasCncSeries.Zero_i_TF => (0, 14999), + FocasCncSeries.Thirty_i or + FocasCncSeries.ThirtyOne_i or + FocasCncSeries.ThirtyTwo_i => (0, 29999), + FocasCncSeries.PowerMotion_i => (0, 29999), + _ => (0, int.MaxValue), + }; + + /// PMC letters accepted per series. Legacy controllers omit F/M/C + /// signal groups that 30i-family ladder programs use. + internal static IReadOnlySet PmcLetters(FocasCncSeries series) => series switch + { + FocasCncSeries.Sixteen_i => new HashSet(StringComparer.OrdinalIgnoreCase) { "X", "Y", "R", "D" }, + FocasCncSeries.Zero_i_D => new HashSet(StringComparer.OrdinalIgnoreCase) { "X", "Y", "R", "D", "E", "A" }, + FocasCncSeries.Zero_i_F or + FocasCncSeries.Zero_i_MF or + FocasCncSeries.Zero_i_TF => new HashSet(StringComparer.OrdinalIgnoreCase) { "X", "Y", "F", "G", "R", "D", "E", "A", "M", "C" }, + FocasCncSeries.Thirty_i or + FocasCncSeries.ThirtyOne_i or + FocasCncSeries.ThirtyTwo_i => new HashSet(StringComparer.OrdinalIgnoreCase) { "X", "Y", "F", "G", "R", "D", "E", "A", "M", "C", "K", "T" }, + FocasCncSeries.PowerMotion_i => new HashSet(StringComparer.OrdinalIgnoreCase) { "X", "Y", "R", "D" }, + _ => new HashSet(StringComparer.OrdinalIgnoreCase), + }; + + /// PMC address-number ceiling per series. Multiplied by 8 to get bit + /// count since PMC addresses are byte-addressed on read + bit-addressed on + /// write — FocasAddress carries the bit separately. + internal static int PmcMaxNumber(FocasCncSeries series) => series switch + { + FocasCncSeries.Sixteen_i => 999, + FocasCncSeries.Zero_i_D => 1999, + FocasCncSeries.Zero_i_F or + FocasCncSeries.Zero_i_MF or + FocasCncSeries.Zero_i_TF => 9999, + FocasCncSeries.Thirty_i or + FocasCncSeries.ThirtyOne_i or + FocasCncSeries.ThirtyTwo_i => 59999, + FocasCncSeries.PowerMotion_i => 1999, + _ => int.MaxValue, + }; + + private static string? ValidateMacro(FocasCncSeries series, int number) + { + var (min, max) = MacroRange(series); + return (number < min || number > max) + ? $"Macro variable #{number} is outside the documented range [{min}, {max}] for {series}." + : null; + } + + private static string? ValidateParameter(FocasCncSeries series, int number) + { + var (min, max) = ParameterRange(series); + return (number < min || number > max) + ? $"Parameter #{number} is outside the documented range [{min}, {max}] for {series}." + : null; + } + + private static string? ValidatePmc(FocasCncSeries series, string? letter, int number) + { + if (string.IsNullOrEmpty(letter)) return "PMC address is missing its letter prefix."; + var letters = PmcLetters(series); + if (!letters.Contains(letter)) + { + var letterList = string.Join(", ", letters); + return $"PMC letter '{letter}' is not supported on {series}. Accepted: {{{letterList}}}."; + } + var max = PmcMaxNumber(series); + return number > max + ? $"PMC address {letter}{number} is outside the documented range [0, {max}] for {series}." + : null; + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasCncSeries.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasCncSeries.cs new file mode 100644 index 0000000..4aaae3d --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasCncSeries.cs @@ -0,0 +1,47 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS; + +/// +/// Fanuc CNC controller series. Used by to +/// gate which FOCAS addresses + value ranges the driver accepts against a given +/// CNC — the FOCAS API surface varies meaningfully between series (macro ranges, +/// PMC address letters, parameter numbers). A tag reference that's valid on a +/// 30i might be out-of-range on an 0i-MF; validating at driver +/// InitializeAsync time surfaces the mismatch as a fast config error +/// instead of a runtime read failure after the server's already running. +/// +/// +/// Values chosen from the Fanuc FOCAS Developer Kit documented series +/// matrix. Add a new entry + a row to when +/// a new controller is targeted — the driver will refuse the device until both +/// sides of the enum are filled in. +/// Defaults to when the operator doesn't specify; +/// the capability matrix treats Unknown as permissive (no range validation, +/// same as pre-matrix behaviour) so old configs don't break on upgrade. +/// +public enum FocasCncSeries +{ + /// No series declared; capability matrix is permissive (legacy behaviour). + Unknown = 0, + + /// Series 0i-D — compact CNC, narrow macro + PMC ranges. + Zero_i_D, + /// Series 0i-F — successor to 0i-D; widened macro range, added Plus variant. + Zero_i_F, + /// Series 0i-MF / 0i-MF Plus — machining-centre variants of 0i-F. + Zero_i_MF, + /// Series 0i-TF / 0i-TF Plus — turning-centre variants of 0i-F. + Zero_i_TF, + + /// Series 16i / 18i / 21i — mid-range legacy; narrow ranges, limited PMC letters. + Sixteen_i, + + /// Series 30i — high-end; widest macro / PMC / parameter ranges. + Thirty_i, + /// Series 31i — subset of 30i (fewer axes, same FOCAS surface). + ThirtyOne_i, + /// Series 32i — compact 30i variant. + ThirtyTwo_i, + + /// Power Motion i — motion-control variant; atypical macro coverage. + PowerMotion_i, +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs index be0e986..b04c422 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs @@ -57,7 +57,24 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, $"FOCAS device has invalid HostAddress '{device.HostAddress}' — expected 'focas://{{ip}}[:{{port}}]'."); _devices[device.HostAddress] = new DeviceState(addr, device); } - foreach (var tag in _options.Tags) _tagsByName[tag.Name] = tag; + // Pre-flight: validate every tag's address against the declared CNC + // series so misconfigured addresses fail at init (clear config error) + // instead of producing BadOutOfRange on every read at runtime. + // Series=Unknown short-circuits the matrix; pre-matrix configs stay permissive. + foreach (var tag in _options.Tags) + { + var parsed = FocasAddress.TryParse(tag.Address) + ?? throw new InvalidOperationException( + $"FOCAS tag '{tag.Name}' has invalid Address '{tag.Address}'. " + + $"Expected forms: R100, R100.3, PARAM:1815/0, MACRO:500."); + if (_devices.TryGetValue(tag.DeviceHostAddress, out var device) + && FocasCapabilityMatrix.Validate(device.Options.Series, parsed) is { } reason) + { + throw new InvalidOperationException( + $"FOCAS tag '{tag.Name}' ({tag.Address}) rejected by capability matrix: {reason}"); + } + _tagsByName[tag.Name] = tag; + } if (_options.Probe.Enabled) { diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverOptions.cs index 579c53c..ce8a042 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverOptions.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverOptions.cs @@ -13,9 +13,15 @@ public sealed class FocasDriverOptions public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2); } +/// +/// One CNC the driver talks to. enables per-series +/// address validation at ; leave as +/// to skip validation (legacy behaviour). +/// public sealed record FocasDeviceOptions( string HostAddress, - string? DeviceName = null); + string? DeviceName = null, + FocasCncSeries Series = FocasCncSeries.Unknown); /// /// One FOCAS-backed OPC UA variable. is the canonical FOCAS diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasCapabilityMatrixTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasCapabilityMatrixTests.cs new file mode 100644 index 0000000..8efff33 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasCapabilityMatrixTests.cs @@ -0,0 +1,156 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; + +/// +/// Version-matrix coverage for . Encodes the +/// documented Fanuc FOCAS Developer Kit support boundaries per CNC series so a +/// config-time change that widens or narrows a range without updating +/// docs/v2/focas-version-matrix.md fails a test. Every assertion cites the +/// specific matrix row it reflects. +/// +[Trait("Category", "Unit")] +public sealed class FocasCapabilityMatrixTests +{ + // ---- Macro ranges ---- + + [Theory] + [InlineData(FocasCncSeries.Sixteen_i, 999, true)] + [InlineData(FocasCncSeries.Sixteen_i, 1000, false)] // above legacy ceiling + [InlineData(FocasCncSeries.Zero_i_D, 999, true)] + [InlineData(FocasCncSeries.Zero_i_D, 9999, false)] // 0i-D is still legacy-ceiling + [InlineData(FocasCncSeries.Zero_i_F, 9999, true)] // widened on 0i-F + [InlineData(FocasCncSeries.Zero_i_F, 10000, false)] + [InlineData(FocasCncSeries.Thirty_i, 99999, true)] // highest-end + [InlineData(FocasCncSeries.Thirty_i, 100000, false)] + [InlineData(FocasCncSeries.PowerMotion_i, 999, true)] + [InlineData(FocasCncSeries.PowerMotion_i, 1000, false)] // atypical coverage + public void Macro_range_matches_series(FocasCncSeries series, int number, bool accepted) + { + var address = new FocasAddress(FocasAreaKind.Macro, null, number, null); + var result = FocasCapabilityMatrix.Validate(series, address); + (result is null).ShouldBe(accepted, + $"Macro #{number} on {series}: expected {(accepted ? "accept" : "reject")}, got {(result ?? "accept")}"); + } + + // ---- Parameter ranges ---- + + [Theory] + [InlineData(FocasCncSeries.Sixteen_i, 9999, true)] + [InlineData(FocasCncSeries.Sixteen_i, 10000, false)] // 16i capped at 9999 + [InlineData(FocasCncSeries.Zero_i_F, 14999, true)] + [InlineData(FocasCncSeries.Zero_i_F, 15000, false)] + [InlineData(FocasCncSeries.Thirty_i, 29999, true)] + [InlineData(FocasCncSeries.Thirty_i, 30000, false)] + public void Parameter_range_matches_series(FocasCncSeries series, int number, bool accepted) + { + var address = new FocasAddress(FocasAreaKind.Parameter, null, number, null); + var result = FocasCapabilityMatrix.Validate(series, address); + (result is null).ShouldBe(accepted); + } + + // ---- PMC letters ---- + + [Theory] + [InlineData(FocasCncSeries.Sixteen_i, "X", true)] + [InlineData(FocasCncSeries.Sixteen_i, "Y", true)] + [InlineData(FocasCncSeries.Sixteen_i, "R", true)] + [InlineData(FocasCncSeries.Sixteen_i, "F", false)] // 16i has no F/G signal groups + [InlineData(FocasCncSeries.Sixteen_i, "G", false)] + [InlineData(FocasCncSeries.Sixteen_i, "K", false)] + [InlineData(FocasCncSeries.Zero_i_D, "E", true)] // widened since 0i-D + [InlineData(FocasCncSeries.Zero_i_D, "F", false)] // still no F on 0i-D + [InlineData(FocasCncSeries.Zero_i_F, "F", true)] // F/G added on 0i-F + [InlineData(FocasCncSeries.Zero_i_F, "K", false)] // K/T still 30i-only + [InlineData(FocasCncSeries.Thirty_i, "K", true)] + [InlineData(FocasCncSeries.Thirty_i, "T", true)] + [InlineData(FocasCncSeries.Thirty_i, "Q", false)] // unsupported even on 30i + public void Pmc_letter_matches_series(FocasCncSeries series, string letter, bool accepted) + { + var address = new FocasAddress(FocasAreaKind.Pmc, letter, 0, null); + var result = FocasCapabilityMatrix.Validate(series, address); + (result is null).ShouldBe(accepted, + $"PMC letter '{letter}' on {series}: expected {(accepted ? "accept" : "reject")}, got {(result ?? "accept")}"); + } + + // ---- PMC number ceiling ---- + + [Theory] + [InlineData(FocasCncSeries.Sixteen_i, "R", 999, true)] + [InlineData(FocasCncSeries.Sixteen_i, "R", 1000, false)] + [InlineData(FocasCncSeries.Zero_i_D, "R", 1999, true)] + [InlineData(FocasCncSeries.Zero_i_D, "R", 2000, false)] + [InlineData(FocasCncSeries.Zero_i_F, "R", 9999, true)] + [InlineData(FocasCncSeries.Zero_i_F, "R", 10000, false)] + [InlineData(FocasCncSeries.Thirty_i, "R", 59999, true)] + [InlineData(FocasCncSeries.Thirty_i, "R", 60000, false)] + public void Pmc_number_ceiling_matches_series(FocasCncSeries series, string letter, int number, bool accepted) + { + var address = new FocasAddress(FocasAreaKind.Pmc, letter, number, null); + var result = FocasCapabilityMatrix.Validate(series, address); + (result is null).ShouldBe(accepted); + } + + // ---- Unknown series is permissive ---- + + [Theory] + [InlineData("Z", 999_999)] // absurd PMC address + [InlineData("Q", 0)] // non-existent letter + public void Unknown_series_accepts_any_PMC(string letter, int number) + { + var address = new FocasAddress(FocasAreaKind.Pmc, letter, number, null); + FocasCapabilityMatrix.Validate(FocasCncSeries.Unknown, address).ShouldBeNull(); + } + + [Fact] + public void Unknown_series_accepts_any_macro_number() + { + var address = new FocasAddress(FocasAreaKind.Macro, null, 999_999, null); + FocasCapabilityMatrix.Validate(FocasCncSeries.Unknown, address).ShouldBeNull(); + } + + [Fact] + public void Unknown_series_accepts_any_parameter_number() + { + var address = new FocasAddress(FocasAreaKind.Parameter, null, 999_999, null); + FocasCapabilityMatrix.Validate(FocasCncSeries.Unknown, address).ShouldBeNull(); + } + + // ---- Reason messages include enough context to diagnose ---- + + [Fact] + public void Rejection_message_names_series_and_limit() + { + var address = new FocasAddress(FocasAreaKind.Macro, null, 100_000, null); + var reason = FocasCapabilityMatrix.Validate(FocasCncSeries.Zero_i_F, address); + reason.ShouldNotBeNull(); + reason.ShouldContain("100000"); + reason.ShouldContain("Zero_i_F"); + reason.ShouldContain("9999"); + } + + [Fact] + public void Pmc_rejection_lists_accepted_letters() + { + var address = new FocasAddress(FocasAreaKind.Pmc, "Q", 0, null); + var reason = FocasCapabilityMatrix.Validate(FocasCncSeries.Thirty_i, address); + reason.ShouldNotBeNull(); + reason.ShouldContain("'Q'"); + reason.ShouldContain("X"); // some accepted letter should appear + reason.ShouldContain("Y"); + } + + // ---- PMC address letter is case-insensitive ---- + + [Theory] + [InlineData("x")] + [InlineData("X")] + [InlineData("f")] + public void Pmc_letter_match_is_case_insensitive_on_30i(string letter) + { + var address = new FocasAddress(FocasAreaKind.Pmc, letter, 0, null); + FocasCapabilityMatrix.Validate(FocasCncSeries.Thirty_i, address).ShouldBeNull(); + } +}