FOCAS version-matrix stabilization (PR 1 of #220 split) #168
@@ -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
|
||||
|
||||
145
docs/v2/focas-version-matrix.md
Normal file
145
docs/v2/focas-version-matrix.md
Normal 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).
|
||||
163
docs/v2/implementation/focas-isolation-plan.md
Normal file
163
docs/v2/implementation/focas-isolation-plan.md
Normal 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.
|
||||
139
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasCapabilityMatrix.cs
Normal file
139
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasCapabilityMatrix.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
47
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasCncSeries.cs
Normal file
47
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasCncSeries.cs
Normal 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,
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user