FOCAS version-matrix stabilization (PR 1 of #220 split) #168

Merged
dohertj2 merged 1 commits from focas-version-matrix-stabilization into v2 2026-04-20 13:46:55 -04:00
8 changed files with 695 additions and 2 deletions

View File

@@ -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

View File

@@ -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 '<name>' (<address>) rejected by capability matrix: <reason>
```
`<reason>` 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).

View File

@@ -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.

View File

@@ -0,0 +1,139 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
/// <summary>
/// Documented-API capability matrix — per CNC series, what ranges each
/// <see cref="FocasAreaKind"/> supports. Authoritative source for the driver's
/// pre-flight validation in <see cref="FocasDriver.InitializeAsync"/>.
/// </summary>
/// <remarks>
/// <para>Ranges come from the Fanuc FOCAS Developer Kit documentation matrix
/// (see <c>docs/v2/focas-version-matrix.md</c> 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
/// <c>EW_NUMBER</c> or <c>EW_PARAM</c> 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.</para>
/// <para><see cref="FocasCncSeries.Unknown"/> 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.</para>
/// </remarks>
public static class FocasCapabilityMatrix
{
/// <summary>
/// Check whether <paramref name="address"/> is accepted by a CNC of
/// <paramref name="series"/>. Returns <c>null</c> on pass + a failure reason
/// on reject — the driver surfaces the reason string verbatim when failing
/// <c>InitializeAsync</c> so operators see the specific out-of-range without
/// guessing.
/// </summary>
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,
};
}
/// <summary>Macro variable number accepted by a CNC series. Cites
/// <c>cnc_rdmacro</c>/<c>cnc_wrmacro</c> in the Developer Kit.</summary>
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),
};
/// <summary>Parameter number accepted; from <c>cnc_rdparam</c>/<c>cnc_wrparam</c>.
/// Ranges reflect the highest-numbered parameter documented per series.</summary>
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),
};
/// <summary>PMC letters accepted per series. Legacy controllers omit F/M/C
/// signal groups that 30i-family ladder programs use.</summary>
internal static IReadOnlySet<string> PmcLetters(FocasCncSeries series) => series switch
{
FocasCncSeries.Sixteen_i => new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "X", "Y", "R", "D" },
FocasCncSeries.Zero_i_D => new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "X", "Y", "R", "D", "E", "A" },
FocasCncSeries.Zero_i_F or
FocasCncSeries.Zero_i_MF or
FocasCncSeries.Zero_i_TF => new HashSet<string>(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<string>(StringComparer.OrdinalIgnoreCase) { "X", "Y", "F", "G", "R", "D", "E", "A", "M", "C", "K", "T" },
FocasCncSeries.PowerMotion_i => new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "X", "Y", "R", "D" },
_ => new HashSet<string>(StringComparer.OrdinalIgnoreCase),
};
/// <summary>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.</summary>
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;
}
}

View File

@@ -0,0 +1,47 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
/// <summary>
/// Fanuc CNC controller series. Used by <see cref="FocasCapabilityMatrix"/> 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
/// <c>InitializeAsync</c> time surfaces the mismatch as a fast config error
/// instead of a runtime read failure after the server's already running.
/// </summary>
/// <remarks>
/// <para>Values chosen from the Fanuc FOCAS Developer Kit documented series
/// matrix. Add a new entry + a row to <see cref="FocasCapabilityMatrix"/> when
/// a new controller is targeted — the driver will refuse the device until both
/// sides of the enum are filled in.</para>
/// <para>Defaults to <see cref="Unknown"/> 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.</para>
/// </remarks>
public enum FocasCncSeries
{
/// <summary>No series declared; capability matrix is permissive (legacy behaviour).</summary>
Unknown = 0,
/// <summary>Series 0i-D — compact CNC, narrow macro + PMC ranges.</summary>
Zero_i_D,
/// <summary>Series 0i-F — successor to 0i-D; widened macro range, added Plus variant.</summary>
Zero_i_F,
/// <summary>Series 0i-MF / 0i-MF Plus — machining-centre variants of 0i-F.</summary>
Zero_i_MF,
/// <summary>Series 0i-TF / 0i-TF Plus — turning-centre variants of 0i-F.</summary>
Zero_i_TF,
/// <summary>Series 16i / 18i / 21i — mid-range legacy; narrow ranges, limited PMC letters.</summary>
Sixteen_i,
/// <summary>Series 30i — high-end; widest macro / PMC / parameter ranges.</summary>
Thirty_i,
/// <summary>Series 31i — subset of 30i (fewer axes, same FOCAS surface).</summary>
ThirtyOne_i,
/// <summary>Series 32i — compact 30i variant.</summary>
ThirtyTwo_i,
/// <summary>Power Motion i — motion-control variant; atypical macro coverage.</summary>
PowerMotion_i,
}

View File

@@ -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)
{

View File

@@ -13,9 +13,15 @@ public sealed class FocasDriverOptions
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
}
/// <summary>
/// One CNC the driver talks to. <paramref name="Series"/> enables per-series
/// address validation at <see cref="FocasDriver.InitializeAsync"/>; leave as
/// <see cref="FocasCncSeries.Unknown"/> to skip validation (legacy behaviour).
/// </summary>
public sealed record FocasDeviceOptions(
string HostAddress,
string? DeviceName = null);
string? DeviceName = null,
FocasCncSeries Series = FocasCncSeries.Unknown);
/// <summary>
/// One FOCAS-backed OPC UA variable. <paramref name="Address"/> is the canonical FOCAS

View File

@@ -0,0 +1,156 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// Version-matrix coverage for <see cref="FocasCapabilityMatrix"/>. 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
/// <c>docs/v2/focas-version-matrix.md</c> fails a test. Every assertion cites the
/// specific matrix row it reflects.
/// </summary>
[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();
}
}