Files
lmxopcua/docs/v2/test-data-sources.md

500 lines
31 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Test Data Sources — OtOpcUa v2
> **Status**: DRAFT — companion to `plan.md`. Identifies the simulator/emulator/stub each driver will be developed and integration-tested against, so a developer laptop and a CI runner can exercise every driver without physical hardware.
>
> **Branch**: `v2`
> **Created**: 2026-04-17
## Scope
The v2 plan adds eight drivers (Galaxy, Modbus TCP, AB CIP, AB Legacy, S7, TwinCAT, FOCAS, OPC UA Client). Each needs a repeatable, low-friction data source for:
- **Inner-loop development** — a developer running tests on their own machine
- **CI integration tests** — automated runs against a known-good fixture
- **Pre-release fidelity validation** — at least one "golden" rig with the highest-fidelity option available, even if paid/heavy
Two drivers are already covered and are **out of scope** for this document:
| Driver | Existing source | Why no work needed |
|--------|-----------------|---------------------|
| Galaxy | Real System Platform Galaxy on the dev machine | MXAccess requires a deployed ArchestrA Platform anyway; the dev box already has one |
| OPC UA Client | OPC Foundation `ConsoleReferenceServer` from UA-.NETStandard | Reference-grade simulator from the same SDK we depend on; trivial to spin up |
The remaining six drivers are the subject of this document.
## Standard Test Scenario
Each simulator must expose a fixture that lets cross-driver integration tests exercise three orthogonal axes: the **data type matrix**, the **behavior matrix**, and **capability-gated extras**. v1 LMX testing already exercises ~12 Galaxy types plus 1D arrays plus security classifications plus historized attrs — the v2 fixture per driver has to reach at least that bar.
### A. Data type matrix (every driver, scalar and array)
Each simulator exposes one tag per cell where the protocol supports the type natively:
| Type family | Scalar | 1D array (small, ~10) | 1D array (large, ~500) | Notes |
|-------------|:------:|:---------------------:|:----------------------:|-------|
| Bool | ✔ | ✔ | — | Discrete subscription test |
| Int16 (signed) | ✔ | ✔ | ✔ | Where protocol distinguishes from Int32 |
| Int32 (signed) | ✔ | ✔ | ✔ | Universal |
| Int64 | ✔ | ✔ | — | Where protocol supports it |
| UInt16 / UInt32 | ✔ | — | — | Where protocol distinguishes signed/unsigned (Modbus, S7) |
| Float32 | ✔ | ✔ | ✔ | Endianness test |
| Float64 | ✔ | ✔ | — | Where protocol supports it |
| String | ✔ | ✔ (Galaxy/AB/TwinCAT) | — | Include empty, ASCII, UTF-8/Unicode, max-length |
| DateTime | ✔ | — | — | Galaxy, TwinCAT, OPC UA Client only |
Large arrays (~500 elements) catch paged-read, fragmentation, and PDU-batching bugs that small arrays miss.
### B. Behavior matrix (applied to a subset of the type matrix)
| Behavior | Applied to | Validates |
|----------|------------|-----------|
| **Static read** | One tag per type in matrix A | Type mapping, value decoding |
| **Ramp** | Int32, Float32 | Subscription delivery cadence, source timestamps |
| **Write-then-read-back** | Bool, Int32, Float32, String | Round-trip per type family, idempotent-write path |
| **Array element write** | Int32[10] | Partial-write paths (where protocol supports them); whole-array replace where it doesn't |
| **Large-array read** | Int32[500] | Paged reads, PDU batching, no truncation |
| **Bool toggle on cadence** | Bool | Discrete subscription, change detection |
| **Bad-quality on demand** | Any tag | Polly circuit-breaker → quality fan-out |
| **Disconnect / reconnect** | Whole simulator | Reconnect, subscription replay, status dashboard, redundancy failover |
### C. Capability-gated extras (only where the driver supports them)
| Extra | Drivers | Fixture requirement |
|-------|---------|---------------------|
| **Security / access levels** | Galaxy, OPC UA Client | At least one read-only and one read-write tag of the same type |
| **Alarms** | Galaxy, FOCAS, OPC UA Client | One alarm that fires after N seconds; one that the test can acknowledge; one that auto-clears |
| **HistoryRead** | Galaxy, OPC UA Client | One historized tag with a known back-fill of >100 samples spanning >1 hour |
| **String edge cases** | All with String support | Empty string, max-length string, embedded nulls, UTF-8 multi-byte chars |
| **Endianness round-trip** | Modbus, S7 | Float32 written by test, read back, byte-for-byte equality |
Each driver section below maps these axes to concrete addresses/tags in that protocol's namespace. Where the protocol has no native equivalent (e.g. Modbus has no String type), the row is marked **N/A** and the driver-side tests skip it.
---
## 1. Modbus TCP (and DL205)
### Recommendation
**Default**: `oitc/modbus-server` Docker image for CI; in-process `NModbus` slave for xUnit fixtures.
Both speak real Modbus TCP wire protocol. The Docker image is a one-line `docker run` for whole-system tests; the in-proc slave gives per-test deterministic state with no new dependencies (NModbus is already on the driver-side dependency list).
### Options Evaluated
| Option | License | Platform | Notes |
|--------|---------|----------|-------|
| **oitc/modbus-server** ([Docker Hub](https://hub.docker.com/r/oitc/modbus-server), [GitHub](https://github.com/cybcon/modbus-server)) | MIT | Docker | YAML preload of all 4 register areas; `docker run -p 502:502` |
| **NModbus `ModbusTcpSlave`** ([GitHub](https://github.com/NModbus/NModbus)) | MIT | In-proc .NET 10 | ~20 LOC fixture; programmatic register control |
| **diagslave** ([modbusdriver.com](https://www.modbusdriver.com/diagslave.html)) | Free (proprietary) | Win/Linux/QNX | Single binary; free mode times out hourly |
| **EasyModbusTCP** | LGPL | .NET / Java / Python | MSI installer |
| **ModbusPal** ([SourceForge](https://sourceforge.net/projects/modbuspal/)) | BSD | Java | Register automation scripting; needs a JVM |
### DL205 Coverage
DL205 PLCs are accessed via H2-ECOM100, which exposes plain Modbus TCP. The `AddressFormat=DL205` feature is purely an octal-to-decimal **address translation** in the driver — the simulator only needs to expose the underlying Modbus registers. Unit-test the translation by preloading specific Modbus addresses (`HR 1024 = V2000`, `DI 15 = X17`, `Coil 8 = Y10`) and asserting the driver reads them via DL205 notation.
### Native Type Coverage
Modbus has no native String, DateTime, or Int64 — those rows are skipped on this driver. Native primitives are coil/discrete-input (Bool) and 16-bit registers; everything wider is composed from contiguous registers with explicit byte/word ordering.
| Type | Modbus mapping | Supported |
|------|----------------|:---------:|
| Bool | Coil / DI | ✔ |
| Int16 / UInt16 | One HR/IR | ✔ |
| Int32 / UInt32 | Two HR (big-endian word) | ✔ |
| Float32 | Two HR | ✔ |
| Float64 | Four HR | ✔ |
| String | — | N/A |
| DateTime | — | N/A |
### Standard Scenario Mapping
| Axis | Address |
|------|---------|
| Bool scalar / Bool[10] | Coil 1000 / Coils 10101019 |
| Int16 scalar / Int16[10] / Int16[500] | HR 0 / HR 1019 / HR 500999 |
| Int32 scalar / Int32[10] | HR 20002001 / HR 20102029 |
| UInt16 scalar | HR 50 |
| UInt32 scalar | HR 6061 |
| Float32 scalar / Float32[10] / Float32[500] | HR 30003001 / HR 30103029 / HR 40004999 |
| Float64 scalar | HR 50005003 |
| Ramp (Int32) | HR 100101 — 0→1000 @ 1 Hz |
| Ramp (Float32) | HR 110111 — sine wave |
| Write-read-back (Bool / Int32 / Float32) | Coil 1100 / HR 21002101 / HR 31003101 |
| Array element write (Int32[10]) | HR 22002219 |
| Bool toggle on cadence | Coil 0 — toggles @ 2 Hz |
| Endianness round-trip (Float32) | HR 60006001, written then read |
| Bad on demand | Coil 99 — write `1` to make the slave drop the TCP socket |
| Disconnect | restart container / dispose in-proc slave |
### Gotchas
- **Byte order** is simulator-configurable. Pin a default in our test harness (big-endian word, big-endian byte) and document.
- **diagslave free mode** restarts every hour — fine for inner-loop, not CI.
- **Docker image defaults registers to 0** — ship a YAML config in the test repo.
---
## 2. Allen-Bradley CIP (ControlLogix / CompactLogix)
### Recommendation
**Default**: `ab_server` from the libplctag repo. Real CIP-over-EtherNet/IP, written by the same project that owns the libplctag NuGet our driver consumes — every tag shape the simulator handles is one the driver can address.
**Pre-release fidelity tier**: Studio 5000 Logix Emulate on one designated "golden" dev box for cases that need full UDT / Program-scope fidelity. Not a default because of cost (~$1k+ Pro-edition add-on) and toolchain weight.
### Options Evaluated
| Option | License | Platform | Notes |
|--------|---------|----------|-------|
| **ab_server** ([libplctag](https://github.com/libplctag/libplctag), [kyle-github/ab_server](https://github.com/kyle-github/ab_server)) | MIT | Win/Linux/macOS | Build from source; CI-grade fixture |
| **Studio 5000 Logix Emulate** | Rockwell paid (~$1k+) | Windows | 100% firmware fidelity |
| **Factory I/O + PLCSIM** | Paid | Windows | Visual sim, not raw CIP |
### Native Type Coverage
| Type | CIP mapping | Supported by ab_server |
|------|-------------|:----------------------:|
| Bool | BOOL | ✔ |
| Int16 | INT | ✔ |
| Int32 | DINT | ✔ |
| Int64 | LINT | ✔ |
| Float32 | REAL | ✔ |
| Float64 | LREAL | ✔ |
| String | STRING (built-in struct) | ✔ basic only |
| DateTime | — | N/A |
| UDT | user-defined STRUCT | not in ab_server CI scope |
### Standard Scenario Mapping
| Axis | Tag |
|------|-----|
| Bool scalar / Bool[10] | `bTest` / `abTest[10]` |
| Int16 scalar / Int16[10] | `iTest` / `aiTest[10]` |
| Int32 scalar / Int32[10] / Int32[500] | `diTest` / `adiTest[10]` / `adiBig[500]` |
| Int64 scalar | `liTest` |
| Float32 scalar / Float32[10] / Float32[500] | `Motor1_Speed` / `aReal[10]` / `aRealBig[500]` |
| Float64 scalar | `Motor1_Position` (LREAL) |
| String scalar / String[10] | `sIdentity` / `asNames[10]` |
| Ramp (Float32) | `Motor1_Speed` (0→60 @ 0.5 Hz) |
| Ramp (Int32) | `StepCounter` (0→10000 @ 1 Hz) |
| Write-read-back (Bool / Int32 / Float32 / String) | `bWriteTarget` / `StepIndex` / `rSetpoint` / `sLastWrite` |
| Array element write (Int32[10]) | `adiWriteTarget[10]` |
| Bool toggle on cadence | `Flags[0]` toggling @ 2 Hz; `Flags[1..15]` latched |
| Bad on demand | Test harness flag that makes ab_server refuse the next read |
| Disconnect | Stop ab_server process |
### Gotchas
- **ab_server tag-type coverage is finite** (BOOL, DINT, REAL, arrays, basic strings). UDTs and `Program:` scoping are not fully implemented. Document an "ab_server-supported tag set" in the harness and exclude the rest from default CI; UDT coverage moves to the Studio 5000 Emulate golden-box tier.
- CIP has no native subscriptions, so polling behavior matches real hardware.
---
## 3. Allen-Bradley Legacy (SLC 500 / MicroLogix, PCCC)
### Recommendation
**Default**: `ab_server` in PCCC mode, with a small in-repo PCCC stub for any file types ab_server doesn't fully cover (notably Timer/Counter `.ACC`/`.PRE`/`.DN` decomposition).
The same binary covers AB CIP and AB Legacy via a `plc=slc500` (or `micrologix`) flag, so we get one fixture for two drivers. If the timer/counter fidelity is too thin in practice, fall back to a ~200-line `TcpListener` stub answering the specific PCCC function codes the driver issues.
### Options Evaluated
| Option | License | Platform | Notes |
|--------|---------|----------|-------|
| **ab_server PCCC mode** | MIT | cross-platform | Same binary as AB CIP; partial T/C/R structure fidelity |
| **Rockwell RSEmulate 500** | Rockwell legacy paid | Windows | EOL, ages poorly on modern Windows |
| **In-repo PCCC stub** | Own | .NET 10 | Fallback only — covers what we P/Invoke |
### Native Type Coverage
PCCC types are file-based. Int32/Float64/DateTime are not native to SLC/MicroLogix.
| Type | PCCC mapping | Supported |
|------|--------------|:---------:|
| Bool | `B3:n/b` (bit in B file) | ✔ |
| Int16 | `N7:n` | ✔ |
| Int32 | — (decompose in driver from two N words) | partial |
| Float32 | `F8:n` | ✔ |
| String | `ST9:n` | ✔ |
| Timer struct | `T4:n.ACC` / `.PRE` / `/DN` | ✔ |
| Counter struct | `C5:n.ACC` / `.PRE` / `/DN` | ✔ |
### Standard Scenario Mapping
| Axis | Address |
|------|---------|
| Bool scalar / Bool[16] | `B3:0/0` / `B3:0` (treated as bit array) |
| Int16 scalar / Int16[10] / Int16[500] | `N7:0` / `N7:0..9` / `N10:0..499` (separate file) |
| Float32 scalar / Float32[10] | `F8:0` / `F8:0..9` |
| String scalar / String[10] | `ST9:0` / `ST9:0..9` |
| Ramp (Int16) | `N7:1` 0→1000 |
| Ramp (Float32) | `F8:1` sine wave |
| Write-read-back (Bool / Int16 / Float32 / String) | `B3:1/0` / `N7:100` / `F8:100` / `ST9:100` |
| Array element write (Int16[10]) | `N7:200..209` |
| Timer fidelity | `T4:0.ACC`, `T4:0.PRE`, `T4:0/DN` |
| Counter fidelity | `C5:0.ACC`, `C5:0.PRE`, `C5:0/DN` |
| Connection-limit refusal | Driver harness toggle to simulate 4-conn limit |
| Bad on demand | Connection-refused toggle |
### Gotchas
- **Real SLC/MicroLogix enforce 48 connection limits**; ab_server does not. Add a test-only toggle in the driver (or in the stub) to refuse connections so we exercise the queuing path.
- Timer/Counter structures are the most likely place ab_server fidelity falls short — design the test harness so we can drop in a stub for those specific files without rewriting the rest.
---
## 4. Siemens S7 (S7-300/400/1200/1500)
### Recommendation
**Default**: Snap7 Server. Real S7comm over ISO-on-TCP, free, cross-platform, and the same wire protocol the S7netplus driver emits.
**Pre-release fidelity tier**: PLCSIM Advanced on one golden dev box (7-day renewable trial; paid for production). Required for true firmware-level validation and for testing programs that include actual ladder logic.
### Options Evaluated
| Option | License | Platform | Notes |
|--------|---------|----------|-------|
| **Snap7 Server** ([snap7](https://snap7.sourceforge.net/snap7_server.html)) | LGPLv3 | Win/Linux/macOS, 32/64-bit | CP emulator; no PLC logic execution |
| **PLCSIM Advanced** ([Siemens](https://www.siemens.com/en-us/products/simatic/s7-plcsim-advanced/)) | Siemens trial / paid | Windows + VM | Full S7-1500 fidelity, runs TIA programs |
| **S7-PLCSIM (classic)** | Bundled with TIA | Windows | S7-300/400; no external S7comm without PLCSIM Advanced |
### Native Type Coverage
S7 has a rich native type system; Snap7 supports the wire-level read/write of all of them via DB byte access.
| Type | S7 mapping | Notes |
|------|------------|-------|
| Bool | `M0.0`, `DBn.DBXm.b` | ✔ |
| Byte / Word / DWord | `DBn.DBB`, `.DBW`, `.DBD` | unsigned |
| Int (Int16) / DInt (Int32) | `DBn.DBW`, `.DBD` | signed, big-endian |
| LInt (Int64) | `DBn.DBLW` | S7-1500 only |
| Real (Float32) / LReal (Float64) | `.DBD`, `.DBLW` | big-endian IEEE |
| String | `DBn.DBB[]` (length-prefixed: max+actual+chars) | length-prefixed |
| Char / WChar | byte / word with semantic | |
| Date / Time / DT / TOD | structured byte layouts | |
### Standard Scenario Mapping
All in `DB1` unless noted; host script provides ramp behavior since Snap7 has no logic.
| Axis | Address |
|------|---------|
| Bool scalar / Bool[16] | `M0.0` / `DB1.DBX0.0..1.7` |
| Int16 scalar / Int16[10] / Int16[500] | `DB1.DBW10` / `DB1.DBW20..38` / `DB2.DBW0..998` |
| Int32 scalar / Int32[10] | `DB1.DBD100` / `DB1.DBD110..146` |
| Int64 scalar | `DB1.DBLW200` |
| UInt16 / UInt32 | `DB1.DBW300` / `DB1.DBD310` |
| Float32 scalar / Float32[10] / Float32[500] | `DB1.DBD400` / `DB1.DBD410..446` / `DB3.DBD0..1996` |
| Float64 scalar | `DB1.DBLW500` |
| String scalar / String[10] | `DB1.STRING600` (max 254) / `DB1.STRING700..` |
| DateTime scalar | `DB1.DT800` |
| Ramp (Int16) | `DB1.DBW10` 0→1000 @ 1 Hz (host script) |
| Ramp (Float32) | `DB1.DBD400` sine (host script) |
| Write-read-back (Bool / Int16 / Float32 / String) | `M1.0` / `DB1.DBW900` / `DB1.DBD904` / `DB1.STRING908` |
| Array element write (Int16[10]) | `DB1.DBW1000..1018` |
| Endianness round-trip (Float32) | `DB1.DBD1100` |
| Big-endian Int32 check | `DB2.DBD0` |
| PUT/GET disabled simulation | Refuse-connection toggle |
| Bad on demand | Stop Snap7 host process |
| Re-download | Swap DB definitions (exercises symbol-version handling) |
### Gotchas
- **Snap7 is not a SoftPLC** — no logic runs. Ramps must be scripted by the test host writing to a DB on a timer.
- **PUT/GET enforcement** is a property of real S7-1200/1500 (disabled by default in TIA). Snap7 doesn't enforce it. Add a test case that simulates "PUT/GET disabled" via a deliberately refused connection.
- **Snap7 binary bitness**: some distributions are 32-bit only — match the test harness bitness.
- **PLCSIM Advanced in VMs** is officially supported but trips up on nested virtualization and time-sync.
---
## 5. Beckhoff TwinCAT (ADS)
### Recommendation
**Default**: TwinCAT XAR runtime in a dev VM under Beckhoff's 7-day renewable dev/test trial. Required because TwinCAT is the only one of three native-subscription drivers (Galaxy, TwinCAT, OPC UA Client) that doesn't have a separate stub option — exercising native ADS notifications without a real XAR would hide the most important driver bugs.
The OtOpcUa test process talks to the VM over the network using `Beckhoff.TwinCAT.Ads` v6's in-process router (`AmsTcpIpRouter`), so individual dev machines and CI runners don't need the full TwinCAT stack installed locally.
### Options Evaluated
| Option | License | Platform | Notes |
|--------|---------|----------|-------|
| **TwinCAT 3 XAE + XAR** ([Beckhoff](https://www.beckhoff.com/en-us/products/automation/twincat/), [licensing](https://infosys.beckhoff.com/content/1033/tc3_licensing/921947147.html)) | Free dev download; 7-day renewable trial; paid for production | Windows + Hyper-V/VMware | Full ADS fidelity with real PLC runtime |
| **Beckhoff.TwinCAT.Ads.TcpRouter** ([NuGet](https://www.nuget.org/packages/Beckhoff.TwinCAT.Ads.TcpRouter)) | Free, bundled | NuGet, in-proc | Router only — needs a real XAR on the other end |
| **TwinCAT XAR in Docker** ([Beckhoff/TC_XAR_Container_Sample](https://github.com/Beckhoff/TC_XAR_Container_Sample)) | Same trial license; no prebuilt image | **Linux host with PREEMPT_RT** | Evaluated and rejected — see "Why not Docker" below |
| **Roll-our-own ADS stub** | Own | .NET 10 | Would have to fake notifications; significant effort |
### Why not Docker (evaluated 2026-04-17)
Beckhoff publishes an [official sample](https://github.com/Beckhoff/TC_XAR_Container_Sample) for running XAR in a container, but it's not a viable replacement for the VM in our environment. Four blockers:
1. **Linux-only host with PREEMPT_RT.** The container is a Linux container that requires a Beckhoff RT Linux host (or equivalent PREEMPT_RT kernel). Docker Desktop on Windows forces Hyper-V, which [TwinCAT runtime cannot coexist with](https://hellotwincat.dev/disable-hyper-v-vs-twincat-problem-solution/). Our CI and dev boxes are Windows.
2. **ADS-over-MQTT, not classic TCP/48898.** The official sample exposes ADS through a containerized mosquitto broker. Real field deployments use TCP/48898; testing against MQTT reduces the fidelity we're paying for.
3. **XAE-on-Windows still required for project deployment.** No headless `.tsproj` deploy path exists. We don't escape the Windows dependency by going to Docker.
4. **Same trial license either way.** No licensing win — 7-day renewable applies identically to bare-metal XAR and containerized XAR.
Revisit if Beckhoff publishes a prebuilt image with classic TCP ADS exposure, or if our CI fleet gains a Linux RT runner. Until then, Windows VM with XAR + XAE + trial license is the pragmatic answer.
### Native Type Coverage
TwinCAT exposes the full IEC 61131-3 type system; the test PLC project includes one symbol per cell.
| Type | TwinCAT mapping | Supported |
|------|-----------------|:---------:|
| Bool | `BOOL` | ✔ |
| Int16 / UInt16 | `INT` / `UINT` | ✔ |
| Int32 / UInt32 | `DINT` / `UDINT` | ✔ |
| Int64 / UInt64 | `LINT` / `ULINT` | ✔ |
| Float32 / Float64 | `REAL` / `LREAL` | ✔ |
| String | `STRING(255)` | ✔ |
| WString | `WSTRING(255)` | ✔ Unicode coverage |
| DateTime | `DT`, `DATE`, `TOD`, `TIME`, `LTIME` | ✔ |
| STRUCT / ENUM / ALIAS | user-defined | ✔ |
### Standard Scenario Mapping
In a tiny test project — `MAIN` (PLC code) + `GVL` (constants and write targets):
| Axis | Symbol |
|------|--------|
| Bool scalar / Bool[10] | `GVL.bTest` / `GVL.abTest` |
| Int16 / Int32 / Int64 scalars | `GVL.iTest` / `GVL.diTest` / `GVL.liTest` |
| UInt16 / UInt32 scalars | `GVL.uiTest` / `GVL.udiTest` |
| Int32[10] / Int32[500] | `GVL.adiTest` / `GVL.adiBig` |
| Float32 / Float64 scalars | `GVL.rTest` / `GVL.lrTest` |
| Float32[10] / Float32[500] | `GVL.arTest` / `GVL.arBig` |
| String / WString / String[10] | `GVL.sIdentity` / `GVL.wsIdentity` / `GVL.asNames` |
| DateTime (DT) | `GVL.dtTimestamp` |
| STRUCT member access | `GVL.fbMotor.rSpeed` (REAL inside FB) |
| Ramp (DINT) | `MAIN.nRamp` — PLC increments each cycle |
| Ramp (REAL) | `MAIN.rSine` — PLC computes sine |
| Write-read-back (Bool / DINT / REAL / STRING / WSTRING) | `GVL.bWriteTarget` / `GVL.diWriteTarget` / `GVL.rWriteTarget` / `GVL.sWriteTarget` / `GVL.wsWriteTarget` |
| Array element write (DINT[10]) | `GVL.adiWriteTarget` |
| Native ADS notification | every scalar above subscribed via OnDataChange |
| Bad on demand | Stop the runtime — driver gets port-not-found |
| Re-download | Re-deploy the project to exercise symbol-version-changed (`0x0702`) |
### Gotchas
- **AMS route table** — XAR refuses ADS connections from unknown hosts. Test setup must add a backroute for each dev machine and CI runner (scriptable via `AddRoute` on the NuGet API).
- **7-day trial reset** requires a click in the XAE UI; investigate scripting it for unattended CI.
- **Symbol-version-changed** is the hardest path to exercise — needs a PLC re-download mid-test, so structure the integration suite to accommodate that step.
---
## 6. FANUC FOCAS (FOCAS2)
### Recommendation
**No good off-the-shelf simulator exists. Build two test artifacts** that cover different layers of the FOCAS surface:
1. **`Driver.Focas.TestStub`** — a TCP listener mimicking a real CNC over the FOCAS wire protocol. Covers functional behavior (reads, writes, ramps, alarms, network failures).
2. **`Driver.Focas.FaultShim`** — a test-only native DLL that masquerades as `Fwlib64.dll` and injects faults inside the host process (AVs, handle leaks, orphan handles). Covers the stability-recovery paths in `driver-stability.md` that the TCP stub physically cannot exercise.
CNC Guide is the only off-the-shelf FOCAS-capable simulator and gating every dev rig on a FANUC purchase isn't viable. There are no open-source FOCAS server stubs at useful fidelity. The FOCAS SDK license is already secured (decision #61), so we own the API contract — build both artifacts ourselves against captured Wireshark traces from a real CNC.
### Artifact 1 — TCP Stub (functional coverage)
A `TcpListener` on port 8193 that answers only the FOCAS2 functions the driver P/Invokes:
```
cnc_allclibhndl3, cnc_freelibhndl, cnc_sysinfo, cnc_statinfo,
cnc_actf, cnc_acts, cnc_absolute, cnc_machine, cnc_rdaxisname,
cnc_rdspmeter, cnc_rdprgnum, cnc_rdparam, cnc_rdalmmsg,
pmc_rdpmcrng, cnc_rdmacro, cnc_getfigure
```
Capture the wire framing once against a real CNC (or a colleague's CNC Guide seat), then the stub becomes a fixed-point reference. For pre-release validation, run the driver against a real CNC.
**Covers**: read/write/poll behavior, scaled-integer round-trip, alarm fire/clear, network slowness, network hang, network disconnect, FOCAS-error-code → StatusCode mapping. Roughly 80% of real-world FOCAS failure modes.
### Artifact 2 — FaultShim (native fault injection, host-side)
A separate test-only native DLL named `Fwlib64.dll` that exports the same function surface but instead of calling FANUC's library, performs configurable fault behaviors: deliberate AV at a chosen call site, return success but never release allocated buffers (memory leak), accept `cnc_freelibhndl` but keep handle table populated (orphan handle), simulate a wedged native call that doesn't return.
Activated by DLL search-path order in the test fixture only; production builds load FANUC's real `Fwlib64.dll`. The Host code is unchanged — it just experiences different symptoms depending on which DLL is loaded.
**Covers**: supervisor respawn after AV, post-mortem MMF readability after hard crash, watchdog → recycle path on simulated leak, Abandoned-handle path when a wedged native call exceeds recycle grace. The remaining ~20% of failure modes that live below the network layer.
### What neither artifact covers
Vendor-specific Fwlib quirks that depend on the real `Fwlib64.dll` interacting with a real CNC firmware version. These remain hardware/manual-test-only and are validated on the pre-release real-CNC tier, not in CI.
### Options Evaluated
| Option | License | Platform | Notes |
|--------|---------|----------|-------|
| **FANUC CNC Guide** ([FANUC](https://www.fanuc.co.jp/en/product/cnc/f_ncguide.html)) | Paid, dealer-ordered | Windows | High fidelity; FOCAS-over-Ethernet not enabled in all editions |
| **FANUC Roboguide** | Paid | Windows | Robot-focused, not CNC FOCAS |
| **MTConnect agents** | various | — | Different protocol; not a FOCAS source |
| **Public FOCAS stubs** | — | — | None at useful fidelity |
| **In-repo TCP stub + FaultShim DLL** | Own | .NET 10 + native | Recommended path — two artifacts, see above |
### Native Type Coverage
FOCAS does not have a tag system in the conventional sense — it has a fixed set of API calls returning structured CNC data. Tag families the driver exposes:
| Type | FOCAS source | Notes |
|------|--------------|-------|
| Bool | PMC bit | discrete inputs/outputs |
| Int16 / Int32 | PMC R/D word & dword, status fields | |
| Int64 | composed from PMC | rare |
| Float32 / Float64 | macros (`cnc_rdmacro`), some params | |
| Scaled integer | position values + `cnc_getfigure()` decimal places | THE FOCAS-specific bug surface |
| String | alarm messages, program names | length-bounded |
| Array | PMC ranges (`pmc_rdpmcrng`), per-axis arrays | |
### Standard Scenario Mapping
| Axis | Element |
|------|---------|
| Static identity (struct) | `cnc_sysinfo` — series, version, axis count |
| Bool scalar / Bool[16] | PMC `G0.0` / PMC `G0` (bits 015) |
| Int16 / Int32 PMC scalars | PMC `R200` / PMC `D300` |
| Int32 PMC array (small / large) | PMC `R1000..R1019` / PMC `R5000..R5499` |
| Float64 macro variable | macro `#100` |
| Macro array | macro `#500..#509` |
| String | active program name; alarm message text |
| Scaled integer round-trip | X-axis position (decimal-place conversion via `cnc_getfigure`) |
| State machine | `RunState` cycling Stop → Running → Hold |
| Ramp (scaled int) | X-axis position 0→100.000 mm |
| Step (Int32) | `ActualFeedRate` stepping on `cnc_actf` |
| Write-read-back (PMC Int32) | PMC `R100` 32-bit scratch register |
| PMC array element write | PMC `R200..R209` |
| Alarms | one alarm appears after N seconds; one is acknowledgeable; one auto-clears |
| Bad on demand | Stub closes the socket on a marker request |
### Gotchas
- **FOCAS wire framing is proprietary** — stub fidelity depends entirely on Wireshark captures from a real CNC. Plan to do that capture early.
- **Fwlib is thread-unsafe per handle** — the stub must serialize so we don't accidentally test threading behavior the driver can't rely on in production.
- **Scaled-integer position values** require the stub to return a credible `cnc_getfigure()` so the driver's decimal-place conversion is exercised.
---
## Summary
| Driver | Primary | License | Fallback / fidelity tier |
|--------|---------|---------|---------------------------|
| Galaxy | Real Galaxy on dev box | — | (n/a — already covered) |
| Modbus TCP / DL205 | `oitc/modbus-server` + NModbus in-proc | MIT | diagslave for wire-inspection |
| AB CIP | libplctag `ab_server` | MIT | Studio 5000 Logix Emulate (golden box) |
| AB Legacy | `ab_server` PCCC mode + in-repo PCCC stub | MIT | Real SLC/MicroLogix on lab rig |
| S7 | Snap7 Server | LGPLv3 | PLCSIM Advanced (golden box) |
| TwinCAT | TwinCAT XAR in dev VM | Free trial | — |
| FOCAS | **In-repo `Driver.Focas.TestStub` (TCP)** + `Driver.Focas.FaultShim` (native DLL) | Own code | CNC Guide / real CNC pre-release |
| OPC UA Client | OPC Foundation `ConsoleReferenceServer` | OPC Foundation | — |
Six of eight drivers have a free, scriptable, cross-platform test source we can check into CI. TwinCAT requires a VM but no recurring cost. FOCAS is the one case with no public answer — we own the stub. The driver specs in `driver-specs.md` enumerate every API call we make, which scopes the FOCAS stub.
## Resolved Defaults
The questions raised by the initial draft are resolved as planning defaults below. Each carries an operational dependency that needs site/team confirmation before Phase 1 work depends on it; flagged inline so the dependency stays visible.
- **CI tiering: PR-CI uses only in-process simulators; nightly/integration CI runs on a dedicated host with Docker + TwinCAT VM.** PR builds need to be fast and need to run on minimal Windows/Linux build agents; standardizing on the in-process subset (`NModbus` server fixture for Modbus, OPC Foundation `ConsoleReferenceServer` in-process for OPC UA Client, and the FOCAS TCP stub from the test project) covers ~70% of cross-driver behavior with no infrastructure dependency. Anything needing Docker (`oitc/modbus-server`), the TwinCAT XAR VM, the libplctag `ab_server` binary, or the Snap7 Server runs on a single dedicated integration host that runs the full suite nightly and on demand. **Operational dependency**: stand up one Windows host with Docker Desktop + Hyper-V before Phase 3 (Modbus driver) — without it, integration tests for Modbus/AB CIP/AB Legacy/S7/TwinCAT all queue behind the same scarcity.
- **Studio 5000 Logix Emulate: not assumed in CI; pre-release validation only.** Don't gate any phase on procuring a license. If an existing org license can be earmarked, designate one Windows machine as the AB CIP golden box and run a quarterly UDT/Program-scope fidelity pass against it. If no license is available, the AB CIP driver ships validated against `ab_server` only, with a documented limitation that UDTs and `Program:` scoping are exercised at customer sites during UAT, not in our CI.
- **FANUC CNC Wireshark captures: scheduled as a Phase 5 prerequisite.** During Phase 4 (PLC drivers), the team identifies a target CNC — production machine accessible during a maintenance window, a colleague's CNC Guide seat, or a customer who'll allow a one-day on-site capture. Capture the wire framing for every FOCAS function in the call list (per `driver-stability.md` §FOCAS) plus a 30-min poll trace, before Phase 5 starts. If no target is identified by Phase 4 mid-point, escalate to procurement: a CNC Guide license seat (1-time cost) or a small dev-rig CNC purchase becomes a Phase 5 dependency.