Files
lmxopcua/docs/v2/driver-specs.md
Joseph Doherty 006af51768 docs: post-PR-7.2 cleanup — audit + three-track scrub
Audit (three parallel agent passes) found 43 markdown files carrying
stale references to the deleted Galaxy.Host/Proxy/Shared projects
after the v2-mxgw merge. This commit lands the prioritized fixes.

Track 1 — high-traffic in-place rewrites (3 files, ~454 lines deleted)
- README.md (202 → 91 lines): drops .NET 4.8 / x86 / TopShelf install
  text; leads with the multi-driver .NET 10 server identity and points
  at scripts/install/Install-Services.ps1 and the parity rig.
- docs/v2/driver-specs.md §1 Galaxy (~289 → ~66 lines): replaces the
  Tier-C out-of-process spec with a Tier-A in-process description
  matching the current GalaxyDriver code, with the four-section
  GalaxyDriverOptions JSON shape pulled verbatim from
  Config/GalaxyDriverOptions.cs.
- docs/drivers/Galaxy.md (211 → 92 lines): full rewrite around the
  current Browse/Runtime/Health/Config sub-folders.

Track 2 — historical banners (5 files)
- lmx_mxgw.md, lmx_mxgw_impl.md, lmx_backend.md,
  docs/v2/Galaxy.ParityMatrix.md,
  docs/v2/implementation/phase-2-galaxy-out-of-process.md each get a
  " Completed 2026-04-30 — historical record" banner block. lmx_mxgw.md
  also fixes two dead links (`docs/Galaxy.Driver.md` and
  `docs/v2/Galaxy.Driver.md`) → `docs/drivers/Galaxy.md`.

Track 3 — v1 archive sweep (10 git mv + 1 new index + 2 in-place scrubs)
- Moved 10 v1 docs under docs/v1/ preserving subpath structure:
  AlarmTracking, Configuration, DataTypeMapping, HistoricalDataAccess,
  Subscriptions (top-level); drivers/Galaxy-Repository,
  drivers/Galaxy-Test-Fixture; reqs/GalaxyRepositoryReqs,
  reqs/MxAccessClientReqs, reqs/ServiceHostReqs.
- New docs/v1/README.md is the shared archive banner + per-file table.
- docs/README.md repointed to the v1 paths and updated to reflect the
  v2 two-process deploy shape (Server + Admin + optional
  OtOpcUaWonderwareHistorian).
- docs/v2/Galaxy.ParityRig.md got a historical banner + four inline
  scrubs marking the OtOpcUaGalaxyHost service / Driver.Galaxy.Host
  EXE / Driver.Galaxy.ParityTests project as deleted-in-PR-7.2.

The repo's live-reading surface (README + CLAUDE.md + docs/v2/) now
describes only the post-PR-7.2 architecture. v1 docs are preserved as
a labelled archive under docs/v1/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 08:59:59 -04:00

807 lines
45 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.
# OtOpcUa v2 — Driver Implementation Specifications
> **Status**: DRAFT — reference document for plan.md
>
> **Created**: 2026-04-16
---
## 1. Galaxy (MXAccess) Driver
### Summary
Galaxy (MXAccess) is a **Tier-A in-process driver** that runs in the OtOpcUa server's .NET 10 AnyCPU process and speaks gRPC to a separately installed `mxaccessgw` (sibling repo at `c:\Users\dohertj2\Desktop\mxaccessgw\`). The gateway owns the MXAccess COM apartment, the STA pump, and the Galaxy Repository / Historian SDK on its own host; the driver itself is platform-agnostic and carries no COM or x86 bitness constraint. Project lives at `src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/`.
### Capability Surface
`GalaxyDriver` (in `GalaxyDriver.cs`) implements `IDriver`, `IDisposable`, plus six driver capabilities — eight interfaces total.
| Capability | Source files |
|------------|--------------|
| `ITagDiscovery` | `Browse/GalaxyDiscoverer.cs`, `Browse/GatewayGalaxyHierarchySource.cs`, `Browse/DataTypeMap.cs`, `Browse/SecurityMap.cs`, `Browse/AlarmRefBuilder.cs` |
| `IRediscoverable` | `Browse/DeployWatcher.cs`, `Browse/GatewayGalaxyDeployWatchSource.cs` |
| `IReadable` | `Runtime/GalaxyMxSession.cs`, `Runtime/MxValueDecoder.cs`, `Runtime/StatusCodeMap.cs` |
| `IWritable` | `Runtime/GatewayGalaxyDataWriter.cs` (+ `TracedGalaxyDataWriter.cs`), `Runtime/MxValueEncoder.cs` |
| `ISubscribable` | `Runtime/GatewayGalaxySubscriber.cs` (+ `TracedGalaxySubscriber.cs`), `Runtime/EventPump.cs`, `Runtime/SubscriptionRegistry.cs`, `Runtime/ReconnectSupervisor.cs` |
| `IHostConnectivityProbe` | `Health/HostStatusAggregator.cs`, `Health/HostConnectivityForwarder.cs`, `Health/PerPlatformProbeWatcher.cs` |
History reads + alarm condition tracking now live in the server-layer `IHistoryRouter` and `AlarmConditionService` (PR 7.2). Galaxy no longer carries `IHistoryProvider` or `IAlarmSource` of its own.
### DriverConfig JSON shape
Per `src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Config/GalaxyDriverOptions.cs`:
```jsonc
{
"Gateway": {
"Endpoint": "http://localhost:5120",
"ApiKeySecretRef": "secret:galaxy-gw-api-key",
"UseTls": true,
"CaCertificatePath": null,
"ConnectTimeoutSeconds": 10,
"DefaultCallTimeoutSeconds": 30,
"StreamTimeoutSeconds": 0
},
"MxAccess": {
"ClientName": "OtOpcUa",
"PublishingIntervalMs": 1000,
"WriteUserId": 0,
"EventPumpChannelCapacity": 50000
},
"Repository": {
"DiscoverPageSize": 5000,
"WatchDeployEvents": true
},
"Reconnect": {
"InitialBackoffMs": 500,
"MaxBackoffMs": 30000,
"ReplayOnSessionLost": true
}
}
```
`Gateway.ApiKeySecretRef` resolves through the server-side secret store (DPAPI in production, env override in dev) — the API key never appears in cleartext config. `MxAccess.ClientName` MUST be unique per OtOpcUa instance; redundancy pairs enforce uniqueness at install time. `StreamTimeoutSeconds = 0` keeps the `StreamEvents` RPC alive for the lifetime of the driver.
### Performance, tracing, soak
See [Galaxy.Performance.md](Galaxy.Performance.md) for the OpenTelemetry trace map, the per-RPC metric set (`galaxy.events.dropped`, channel headroom, reconnect backoff distribution), and the soak-run profile.
### Parity rig + gateway setup
See [Galaxy.ParityRig.md](Galaxy.ParityRig.md) and the `mxaccessgw` repo for the gateway worker layout and the dev-rig recipe.
---
## 2. Modbus TCP Driver
### Summary
In-process polled driver for Modbus TCP devices. Flat register-based addressing with config-driven tag definitions. Supports multiple devices per driver instance.
### Library & Dependencies
| Component | Package | Version | Target |
|-----------|---------|---------|--------|
| **NModbus** | `NModbus` NuGet | 3.0.x | .NET Standard 2.0 |
No external runtime dependencies. Pure managed .NET.
### Required Components
- None beyond network access to Modbus TCP devices (port 502)
### Connection Settings (per device, from central config DB)
| Setting | Type | Default | Description |
|---------|------|---------|-------------|
| `Host` | string | — | Device IP address or hostname |
| `Port` | int | 502 | Modbus TCP port |
| `UnitId` | byte | 1 | Slave/unit ID (0-247) |
| `ConnectTimeoutMs` | int | 3000 | TCP connect timeout |
| `ResponseTimeoutMs` | int | 2000 | Modbus response timeout |
| `RetryCount` | int | 2 | Retries before marking bad |
| `ReconnectDelayMs` | int | 5000 | Delay before reconnect attempt |
| `ByteOrder` | enum | BigEndian | `BigEndian`, `LittleEndian`, `MidBigEndian`, `MidLittleEndian` |
| `AddressFormat` | enum | Standard | `Standard` (0-based decimal), `DL205` (octal V/X/Y/C/T/CT notation) |
### Tag Definition (from central config DB)
| Field | Type | Description |
|-------|------|-------------|
| `Name` | string | Tag name (OPC UA browse name) |
| `FolderPath` | string | Address space hierarchy path |
| `RegisterArea` | enum | `Coil`, `DiscreteInput`, `HoldingRegister`, `InputRegister` |
| `Address` | int | 0-based register/coil address |
| `DataType` | enum | `Boolean`, `Int16`, `UInt16`, `Int32`, `UInt32`, `Float32`, `Float64`, `Int64`, `UInt64`, `String` |
| `StringLength` | int? | Character count for string tags |
| `AccessLevel` | enum | `Read`, `ReadWrite` |
| `PollGroup` | string | Poll group name for scan rate assignment |
| `ByteOrder` | enum? | Per-tag override (defaults to device setting) |
| `BitIndex` | int? | For bit-within-register access (0-15) |
| `ScaleGain` | double? | Linear scaling: `displayed = raw * gain + offset` |
| `ScaleOffset` | double? | Linear scaling offset |
### Addressing Model
| Area | Classic Address | Protocol Address | Function Code | Size |
|------|----------------|-----------------|---------------|------|
| Coils | 00001-09999 | 0x0000-0xFFFF | FC 01 (read), FC 05/15 (write) | 1 bit |
| Discrete Inputs | 10001-19999 | 0x0000-0xFFFF | FC 02 (read) | 1 bit |
| Input Registers | 30001-39999 | 0x0000-0xFFFF | FC 04 (read) | 16 bit |
| Holding Registers | 40001-49999 | 0x0000-0xFFFF | FC 03 (read), FC 06/16 (write) | 16 bit |
Multi-register data types (32-bit float, 64-bit double) span consecutive registers. Byte order is configurable per-device or per-tag — **critical** since there is no standard.
### Poll Architecture
- **Poll groups** with configurable intervals (e.g. Fast=250ms, Medium=1000ms, Slow=5000ms)
- **Block read optimization**: coalesce contiguous/nearby registers into single FC 03/04 requests (max 125 registers per request)
- **Gap tolerance**: configurable — read through gaps of N unused registers to reduce request count
- **Demand-based**: only poll registers with active OPC UA MonitoredItems
### Error Mapping
| Modbus Exception | OPC UA StatusCode |
|-----------------|-------------------|
| 0x01 Illegal Function | `BadNotSupported` |
| 0x02 Illegal Data Address | `BadNodeIdUnknown` |
| 0x03 Illegal Data Value | `BadOutOfRange` |
| 0x04 Slave Device Failure | `BadInternalError` |
| 0x0A Gateway Path Unavailable | `BadNoCommunication` |
| 0x0B Gateway Target Failed | `BadTimeout` |
| TCP timeout/disconnect | `BadNoCommunication` |
### DL205 Compatibility (AutomationDirect)
The AutomationDirect DL205 PLC supports Modbus TCP via the **H2-ECOM100** Ethernet module (port 502). Rather than a separate driver, the Modbus TCP driver supports DL205 natively via the `AddressFormat = DL205` setting.
**DL205 uses octal addressing.** When `AddressFormat` is `DL205`, users configure tags using native DL205 notation and the driver translates to Modbus addresses:
| DL205 Address | Modbus Area | Modbus Address | Formula |
|---------------|-------------|----------------|---------|
| `V2000` | Holding Register (FC 03) | 1024 | OctalToDecimal(2000) = 1024 |
| `V2400` | Holding Register (FC 03) | 1280 | OctalToDecimal(2400) = 1280 |
| `V7777` | Holding Register (FC 03) | 4095 | OctalToDecimal(7777) = 4095 |
| `X0` | Discrete Input (FC 02) | 0 | OctalToDecimal(0) = 0 |
| `X17` | Discrete Input (FC 02) | 15 | OctalToDecimal(17) = 15 |
| `Y0` | Coil (FC 01) | 0 | OctalToDecimal(0) = 0 |
| `Y10` | Coil (FC 01) | 8 | OctalToDecimal(10) = 8 |
| `C0``C777` | Coil (FC 01) | 5121023 | 512 + OctalToDecimal(addr) |
| `T0``T377` | Coil (FC 01) | 20482303 | 2048 + OctalToDecimal(addr) (status bits) |
| `CT0``CT177` | Coil (FC 01) | 25602687 | 2560 + OctalToDecimal(addr) |
**DL205-specific notes:**
- 32-bit values (Float32, Int32) use **low word at lower address** (little-endian word order) — set `ByteOrder = LittleEndian` for the device
- H2-ECOM100 supports **8 simultaneous TCP connections** — use a single connection
- Float32 requires DL250-1 or DL260 CPU (DL240 has no float support)
- Timers/counters: current values are in V-memory (V0V377 for timers, V1000V1177 for counters); status bits are in the coil area
- DL205 is still manufactured but considered legacy — BRX series is the successor
### Operational Stability Notes
Tier A (pure managed). Universal protections (SafeHandle on socket handles, bounded per-device queue, crash-loop breaker, `IDriver.Reinitialize()`) are sufficient. No driver-specific watchdog. NModbus is mature and has no known leak surfaces. The only operational concern worth calling out: **byte/word-order misconfiguration is the most common production bug** — not a stability issue but a correctness one. Surface byte-order mismatches as data validation alerts in the dashboard rather than as quality codes, since a wrong-endian reading is silently plausible.
---
## 3. Allen-Bradley CIP Driver (ControlLogix / CompactLogix)
### Summary
In-process polled driver for modern AB Logix PLCs. Symbolic tag-based addressing. Supports controller-scoped and program-scoped tags.
### Library & Dependencies
| Component | Package | Version | Target |
|-----------|---------|---------|--------|
| **libplctag** | `libplctag` NuGet | 1.6.x | .NET Standard 2.0 |
The NuGet package bundles native C library for Windows x64 (also Linux, macOS). No separate install needed.
### Required Components
- None beyond network access to PLC (port 44818)
- PLC firmware 20+ recommended for request packing
### Connection Settings (per device, from central config DB)
| Setting | Type | Default | Description |
|---------|------|---------|-------------|
| `Host` | string | — | PLC IP address |
| `Path` | string | `1,0` | CIP routing path (backplane, slot) |
| `PlcType` | enum | ControlLogix | `ControlLogix`, `CompactLogix`, `Micro800` |
| `TimeoutMs` | int | 5000 | Read/write timeout |
| `AllowPacking` | bool | true | CIP request packing (firmware 20+) |
| `ConnectionSize` | int | 4002 | Max CIP packet size |
### Tag Definition (from central config DB)
| Field | Type | Description |
|-------|------|-------------|
| `Name` | string | OPC UA browse name |
| `FolderPath` | string | Address space hierarchy |
| `TagPath` | string | CIP tag path (e.g. `Motor1_Speed`, `Program:MainProgram.StepIndex`) |
| `DataType` | enum | `BOOL`, `SINT`, `INT`, `DINT`, `LINT`, `REAL`, `LREAL`, `STRING` |
| `ArraySize` | int? | Element count for array tags |
| `AccessLevel` | enum | `Read`, `ReadWrite` |
| `PollGroup` | string | Poll group name |
### Addressing Examples
```
Motor1_Speed — controller-scoped REAL
MyArray[5] — array element
Program:MainProgram.StepIndex — program-scoped DINT
MyUDT.Member1 — UDT member
MyDINT.5 — bit 5 of a DINT
MyTimer.ACC — Timer accumulated value
```
### Implementation Notes
- libplctag automatically pools CIP connections per gateway+path — no manual connection pooling needed
- Thread-safe — multiple tags can be read concurrently
- No native subscriptions — polled only
- CIP has no tag discovery API that libplctag exposes well; tags are config-driven from central DB
### Operational Stability Notes
Tier B (libplctag is a C library accessed via P/Invoke). Mature and widely deployed; manages its own threads and memory pool internally.
- **Wrap every libplctag handle in a `SafeHandle`** with finalizer calling `plc_tag_destroy` — leaked tags accumulate in libplctag's internal table and eventually exhaust resources.
- **Watch for libplctag-internal memory growth**: per-instance allocation tracking (per Tier A/B contract in `driver-stability.md`) cannot see libplctag's native heap. Monitor process RSS attributable to this driver via `GC.GetAllocatedBytesForCurrentThread()` proxies for sanity, and rely on the universal slope-detection over the whole-server RSS to flag growth that correlates with this driver's reload cycles.
- **Tier promotion trigger**: if production telemetry shows libplctag-attributable native growth that can't be bounded by `IDriver.Reinitialize()` (which destroys + recreates the tag pool), promote to Tier C. Older libplctag versions had known leaks; 1.6.x has not shown them in field reports but we have no internal SLA.
- **No native subscriptions** means no callback-pump concerns. The driver-internal poll loop is fully managed C# and has standard cancellation behavior.
---
## 4. Allen-Bradley Legacy Driver (SLC 500 / MicroLogix)
### Summary
In-process polled driver for legacy AB PLCs using PCCC over EtherNet/IP. File-based addressing (not symbolic).
### Library & Dependencies
| Component | Package | Version | Target |
|-----------|---------|---------|--------|
| **libplctag** | `libplctag` NuGet | 1.6.x | .NET Standard 2.0 |
Same library as AB CIP — libplctag handles both protocols via the `plc` attribute.
### Required Components
- Network access to PLC/adapter (port 44818)
- SLC 5/05, MicroLogix 1100/1400 for built-in Ethernet
- SLC 5/03/04 requires external Ethernet adapter module (1761-NET-ENI or 1747-AENTR)
### Connection Settings (per device, from central config DB)
| Setting | Type | Default | Description |
|---------|------|---------|-------------|
| `Host` | string | — | PLC or Ethernet adapter IP |
| `Path` | string | `1,0` | Routing path (backplane, slot) |
| `PlcType` | enum | SLC500 | `SLC500`, `MicroLogix` |
| `TimeoutMs` | int | 5000 | Response timeout |
### Tag Definition (from central config DB)
| Field | Type | Description |
|-------|------|-------------|
| `Name` | string | OPC UA browse name |
| `FolderPath` | string | Address space hierarchy |
| `FileAddress` | string | PLC file address (e.g. `N7:0`, `F8:5`, `T4:0.ACC`, `B3:0/5`) |
| `DataType` | enum | `Boolean`, `Int16`, `Float32`, `Int32`, `String`, `TimerStruct`, `CounterStruct` |
| `ElementCount` | int | Number of consecutive elements (for block reads) |
| `AccessLevel` | enum | `Read`, `ReadWrite` |
| `PollGroup` | string | Poll group name |
### File-Based Addressing Reference
| File Type | Default # | Element Size | Data Type | Examples |
|-----------|-----------|-------------|-----------|----------|
| O (Output) | 0 | 2 bytes | INT16/bit | `O:0`, `O:0/0` |
| I (Input) | 1 | 2 bytes | INT16/bit | `I:0`, `I:0/3` |
| S (Status) | 2 | 2 bytes | INT16/bit | `S:0`, `S:1/0` |
| B (Binary) | 3 | 2 bytes | INT16/bit | `B3:0`, `B3:0/5` |
| T (Timer) | 4 | 6 bytes | Struct | `T4:0.ACC`, `T4:0.PRE`, `T4:0/DN` |
| C (Counter) | 5 | 6 bytes | Struct | `C5:0.ACC`, `C5:0/DN`, `C5:0/CU` |
| R (Control) | 6 | 6 bytes | Struct | `R6:0.LEN`, `R6:0.POS`, `R6:0/DN` |
| N (Integer) | 7 | 2 bytes | INT16 | `N7:0`, `N7:0/5`, `N10:50` |
| F (Float) | 8 | 4 bytes | FLOAT32 | `F8:0`, `F20:10` |
| ST (String) | 9 | 84 bytes | STRING | `ST9:0` (82 char max) |
| L (Long) | — | 4 bytes | INT32 | `L10:0` (user-created, 5/03+) |
### Connection Limits — CRITICAL
| PLC Model | Max Connections |
|-----------|----------------|
| SLC 5/05 | **4** |
| SLC 5/04 + AENTR | 4-8 |
| SLC 5/03 + NET-ENI | **1-4** |
| MicroLogix 1100 | **4** |
| MicroLogix 1400 | **8** |
**Driver MUST use a single TCP connection per PLC and serialize all requests.**
### Implementation Notes
- No tag discovery — all addresses must be configured up front
- Timer/Counter/Control structures should be decomposed into child OPC UA nodes (ACC, PRE, DN, EN, etc.)
- Max PCCC data payload: **244 bytes** per request (limits batch read size)
- String/Long support requires SLC 5/03+ or MicroLogix 1100+
### Operational Stability Notes
Tier B (same libplctag library as AB CIP). Same SafeHandle/leak-watch protections apply. Two PCCC-specific concerns beyond CIP:
- **48 connection limit per PLC is enforced by the device, not the library**. Connection-refused errors must be handled distinctly from network-down — bound the in-flight request count per device to 1, and queue the rest. Universal bounded queue (default 1000) is generous here; consider lowering to 50 per device for SLC/MicroLogix to fail fast when the device is overcommitted.
- **PCCC reconnect is more expensive than CIP** (no connection multiplexing on legacy PLCs). Polly retry should use longer backoff for AB Legacy than for AB CIP — recommend doubled intervals.
---
## 5. Siemens S7 Driver
### Summary
In-process polled driver for Siemens S7 PLCs. Area-based addressing (DB, M, I, Q). PDU-size-aware request batching for optimal read performance.
### Library & Dependencies
| Component | Package | Version | Target |
|-----------|---------|---------|--------|
| **S7.Net** | `S7netplus` NuGet | latest | .NET Standard 2.0 |
Pure managed .NET, MIT license. No native dependencies.
### Required Components
- Network access to PLC (port 102, ISO-on-TCP)
- **S7-1200/1500**: PUT/GET communication must be **enabled in TIA Portal** (Hardware Config > Protection & Security > "Permit access with PUT/GET communication") — disabled by default
- **S7-1500 access level**: Must be set to level 1 (no protection) for S7comm access
### Connection Settings (per device, from central config DB)
| Setting | Type | Default | Description |
|---------|------|---------|-------------|
| `Host` | string | — | PLC IP address |
| `Rack` | int | 0 | Hardware rack number |
| `Slot` | int | 0 | CPU slot (S7-300=2, S7-1200/1500=0) |
| `CpuType` | enum | S71500 | `S7300`, `S7400`, `S71200`, `S71500` |
| `TimeoutMs` | int | 5000 | Read/write timeout |
| `MaxConcurrentConnections` | int | 1 | Connections to this PLC (usually 1) |
### Typical Rack/Slot by PLC Family
| PLC | Rack | Slot |
|-----|------|------|
| S7-300 | 0 | 2 |
| S7-400 | 0 | 2 or 3 |
| S7-1200 | 0 | 0 |
| S7-1500 | 0 | 0 |
### Tag Definition (from central config DB)
| Field | Type | Description |
|-------|------|-------------|
| `Name` | string | OPC UA browse name |
| `FolderPath` | string | Address space hierarchy |
| `S7Address` | string | S7 address (e.g. `DB1.DBW0`, `MW10`, `I0.0`, `Q0.0`) |
| `DataType` | enum | `Bool`, `Byte`, `Int16`, `UInt16`, `Int32`, `UInt32`, `Float32`, `Float64`, `Int64`, `String`, `DateTime` |
| `StringLength` | int? | For S7 String type (default 254) |
| `AccessLevel` | enum | `Read`, `ReadWrite` |
| `PollGroup` | string | Poll group name |
### Addressing Reference
| Area | Address Syntax | Area Code | Examples |
|------|---------------|-----------|----------|
| Data Block | `DB{n}.DB{X\|B\|W\|D}{offset}[.bit]` | 0x84 | `DB1.DBX0.0`, `DB1.DBW0`, `DB1.DBD4` |
| Merkers | `M{B\|W\|D}{offset}` or `M{offset}.{bit}` | 0x83 | `M0.0`, `MW0`, `MD4` |
| Inputs | `I{B\|W\|D}{offset}` or `I{offset}.{bit}` | 0x81 | `I0.0`, `IW0`, `ID0` |
| Outputs | `Q{B\|W\|D}{offset}` or `Q{offset}.{bit}` | 0x82 | `Q0.0`, `QW0`, `QD0` |
| Timers | `T{n}` | 0x1D | `T0`, `T15` |
| Counters | `C{n}` | 0x1C | `C0`, `C10` |
### PDU Size & Optimization
| PLC | PDU Size | Max Data per Read |
|-----|----------|-------------------|
| S7-300 | 240 bytes | ~218 bytes |
| S7-400 | 480 bytes | ~458 bytes |
| S7-1200 | 240 bytes | ~218 bytes |
| S7-1500 | 480-960 bytes | ~458-938 bytes |
**Optimization**: Group reads by DB/area, merge contiguous addresses, pack into PDU-sized batches (max 20 items per multi-read PDU).
### Connection Limits
| PLC | Max Connections | Available for S7 Basic |
|-----|----------------|----------------------|
| S7-300 | 8-32 | 6-30 |
| S7-1200 | 8-16 | 3-8 |
| S7-1500 | 32-64 | 16-32 |
**Driver should use 1 connection per PLC**, serialize with `SemaphoreSlim`.
### Byte Order
S7 is **big-endian**. All multi-byte values need byte-swap on .NET (little-endian x64). Use `BinaryPrimitives.ReadXxxBigEndian()`.
### Operational Stability Notes
Tier B (S7netplus is mostly managed; small native bits in the ISO-on-TCP transport). Stable in the field. Universal protections cover it. Two notes:
- **PUT/GET disabled on S7-1200/1500 manifests as a hard "function not allowed" error from the PLC**, not a connection failure. The driver must distinguish these — universal Polly retry on a PUT/GET-disabled error is wasted effort and noise. Map PUT/GET-disabled to `BadNotSupported` and surface as a configuration alert in the dashboard, not a transient failure.
- **Single connection per PLC with `SemaphoreSlim` serialization** is the documented S7netplus pattern. Don't try to parallelize against one PLC; you'll just queue at the wire level with worse latency.
---
## 6. Beckhoff TwinCAT (ADS) Driver
### Summary
In-process driver with **native subscription support** via ADS device notifications. Symbol-based addressing with automatic tag discovery via symbol upload.
### Library & Dependencies
| Component | Package | Version | Target |
|-----------|---------|---------|--------|
| **Beckhoff.TwinCAT.Ads** | NuGet | 6.x | .NET Standard 2.0 / .NET 6+ |
| **Beckhoff.TwinCAT.Ads.Reactive** | NuGet | 6.x | Optional — Rx wrappers for notifications |
### Required Components
- **No TwinCAT installation required on the OtOpcUa server machine** (v6+ supports in-process ADS router via `AmsRouter` class)
- Network access to TwinCAT device (TCP port **48898** — fixed ADS-over-TCP port)
- AMS route must be configured on the target TwinCAT device (route back to the OtOpcUa server)
- Target PLC runtime must be in **Run** state
### Connection Settings (per device, from central config DB)
| Setting | Type | Default | Description |
|---------|------|---------|-------------|
| `Host` | string | — | TwinCAT device IP address |
| `AmsNetId` | string | — | Target AMS Net ID (e.g. `192.168.1.50.1.1`) |
| `AmsPort` | int | 851 | Target AMS port (851=TC3 PLC Runtime 1) |
| `TimeoutMs` | int | 5000 | Read/write timeout |
| `NotificationCycleTimeMs` | int | 100 | Default ADS notification check interval |
| `NotificationMaxDelayMs` | int | 0 | Notification batching delay (0=immediate) |
### AMS Port Reference
| Port | Subsystem |
|------|-----------|
| 851 | TwinCAT 3 PLC Runtime 1 |
| 852 | TwinCAT 3 PLC Runtime 2 |
| 853 | TwinCAT 3 PLC Runtime 3 |
| 854 | TwinCAT 3 PLC Runtime 4 |
| 500 | NC PTP |
| 300 | TwinCAT 2 PLC Runtime 1 |
### Tag Discovery
TwinCAT supports **automatic symbol upload** — the driver can enumerate all PLC variables with their types, sizes, and comments at connect time:
1. Use `SymbolLoaderFactory.Create(client, SymbolLoaderSettings.Default)` to get an `ISymbolLoader`
2. Enumerate all symbols with full type information
3. Build OPC UA address space from discovered symbols
4. **Re-upload on symbol version change** (error `0x0702`) — detects PLC program re-download
Tags can also be user-configured in the central config DB (to expose a subset or apply custom naming).
### Addressing Examples
```
MAIN.nCounter — local variable in MAIN program
GVL.Motor1.Speed — Global Variable List struct member
MAIN.aValues[3] — array element
MAIN.fbPID.fOutput — function block output
```
Symbol paths are case-insensitive.
### Subscription Model — Native ADS Notifications
**TwinCAT is one of only three drivers (along with Galaxy and OPC UA Client) that supports native subscriptions.**
- `AdsTransMode.OnChange` — notification only when value changes (checked at cycle time interval)
- `AdsTransMode.ServerOnChange` — more efficient, evaluated in PLC task cycle
- Map OPC UA MonitoredItem → ADS notification 1:1
- Dynamic subscribe/unsubscribe as OPC UA clients add/remove MonitoredItems
- **Limit**: ~500 notifications per ADS connection (configurable in TwinCAT runtime)
- For high-tag-count scenarios, fall back to sum-up (batch) reads with `0xF080` index group
### Error Handling
| Error | Code | Meaning | Recovery |
|-------|------|---------|----------|
| Target port not found | 0x0006 | PLC runtime not running | Retry with backoff |
| Target machine not found | 0x0007 | AMS route missing or network error | Check route config |
| Timeout | 0x000A | Device not responding | Retry, then reconnect |
| Symbol not found | 0x0701 | Variable name doesn't exist | Validate config |
| Symbol version changed | 0x0702 | PLC program re-downloaded | Re-upload symbols, rebuild address space |
| Not ready | 0x0710 | PLC in Config mode | Wait for Run state |
### ADS State Monitoring
Subscribe to PLC runtime state transitions (Invalid→Init→Run→Stop→Config→Error) for health reporting to the status dashboard.
### Operational Stability Notes
Tier B with the most native involvement of any in-process driver. `Beckhoff.TwinCAT.Ads` v6 is mostly managed but the AMS router and the ADS-notification callback pump have native components. Three driver-specific concerns warrant explicit handling:
- **Symbol-version-changed (`0x0702`) is the unique TwinCAT failure mode**. A PLC re-download invalidates every symbol handle and notification handle the driver holds. The driver must catch `0x0702`, mark its symbol cache invalid, re-upload symbols, rebuild the address space subtree, and re-establish all notifications — without losing the OPC UA subscriptions they back. **Treat this as a `IRediscoverable` invocation**, not as a connection error. Universal `IDriver.Reinitialize()` is the right entry point; the driver implementation honors it by walking the symbol-handle table.
- **Native ADS notification callbacks run on the AMS router thread**, not the driver's polling thread. Callbacks must marshal to a managed work queue immediately (no driver logic on the router thread) — blocking the router thread blocks every ADS notification across the process. This is a common bug class for ADS drivers and worth a code-review checklist item.
- **AMS route table failures are silent**: if the route is misconfigured, the driver gets `0x0007` (target machine not found) but the underlying cause is configuration, not network. Surface as a configuration alert, not a transient failure; don't waste Polly retry budget on it.
- **Memory growth signal**: cached symbol info is the largest in-driver allocation. Per-instance budget should bound it; on breach, flush the symbol cache (forcing the next operation to re-upload). If symbol cache flush doesn't bound growth, this is a Tier C promotion candidate.
---
## 7. FANUC FOCAS Driver
### Summary
**Tier C out-of-process** polled driver for FANUC CNC machines, hosted via the `Focas.Proxy` / `Focas.Host` / `Focas.Shared` three-project split (same pattern as Galaxy — see `driver-stability.md`). **Fundamentally different from PLC drivers** — data is accessed via discrete API functions, not tag-based addressing. The driver exposes a **pre-defined set of OPC UA nodes** that map to specific FOCAS2 API calls.
Hosted out-of-process because `Fwlib64.dll` is a black-box vendor DLL with no public stability SLA; an `AccessViolationException` from native code is uncatchable in modern .NET and would tear down the entire OtOpcUa server with all other drivers and sessions if FOCAS were in-process. The `Focas.Host` Windows service runs on .NET 10 x64 (no bitness constraint) and is recycled, watchdogged, and supervised independently of the main server.
### Library & Dependencies
| Component | Source | Version | Target |
|-----------|--------|---------|--------|
| **Fwlib64.dll** | FANUC FOCAS2 SDK | FOCAS2 | x64 native DLL (P/Invoke) — loaded **only by Focas.Host**, never by the main server |
| **Focas2.cs** | FANUC SDK | — | P/Invoke declarations + struct definitions, in `Focas.Host` |
| **MessagePack-CSharp** | `MessagePack` NuGet | 2.x | .NET Standard 2.0 (Shared) | IPC serialization between Proxy and Host |
| **Named pipes** | `System.IO.Pipes` (BCL) | BCL | both sides | IPC transport with mandatory pipe ACL (server service SID only) |
### Required Components
- **FANUC FOCAS2 SDK** — requires license agreement from FANUC or authorized distributor
- **Fwlib64.dll** (64-bit) redistributed with the **Focas.Host** Windows service installer (not the main server)
- CNC must have **Ethernet function** option enabled
- CNC network accessible (TCP port **8193**) **from the Focas.Host machine** (typically the same machine as the OtOpcUa server)
- **Very limited connections**: 5-10 max per CNC — driver MUST use exactly one connection
- **Focas.Host installed as a separate Windows service** alongside the OtOpcUa server (same machine; localhost-only IPC)
### Connection Settings (per device, from central config DB)
| Setting | Type | Default | Description |
|---------|------|---------|-------------|
| `Host` | string | — | CNC IP address |
| `Port` | int | 8193 | FOCAS TCP port |
| `TimeoutSeconds` | int | 10 | Connection timeout (FOCAS uses seconds) |
| `CncSeries` | string? | — | Optional hint (e.g. `0i-F`, `30i-B`) — for capability filtering |
### Pre-Defined Tag Set — FOCAS API Mapping
Unlike PLC drivers where tags are user-defined, the FOCAS driver exposes a **fixed hierarchy of nodes** that are populated by specific API calls. Users can enable/disable categories, but cannot define arbitrary tags.
#### CNC Identity (read once at connect)
| OPC UA Node | FOCAS API | Return Type | FOCAS2 Min Version | Notes |
|-------------|-----------|-------------|-------------------|-------|
| `Identity/SeriesNumber` | `cnc_sysinfo()``ODBSYS.series` | String | FOCAS1 | e.g. "0i-F" |
| `Identity/Version` | `cnc_sysinfo()``ODBSYS.version` | String | FOCAS1 | CNC software version |
| `Identity/MaxAxes` | `cnc_sysinfo()``ODBSYS.max_axis` | Int32 | FOCAS1 | |
| `Identity/CncType` | `cnc_sysinfo()``ODBSYS.cnc_type` | String | FOCAS1 | "M" (mill) or "T" (lathe) |
| `Identity/MtType` | `cnc_sysinfo()``ODBSYS.mt_type` | String | FOCAS1 | Machine tool type |
| `Identity/AxisCount` | `cnc_rdaxisname()` → count | Int32 | FOCAS2 | Dynamic axis count |
#### CNC Status (polled fast — 250-500ms)
| OPC UA Node | FOCAS API | Return Type | FOCAS2 Min Version | Notes |
|-------------|-----------|-------------|-------------------|-------|
| `Status/RunState` | `cnc_statinfo()``ODBST.run` | Int32 | FOCAS1 | 0=STOP, 1=HOLD, 2=START, 3=MSTR |
| `Status/RunStateText` | (derived from RunState) | String | — | "Stop", "Hold", "Running" |
| `Status/Mode` | `cnc_statinfo()``ODBST.aut` | Int32 | FOCAS1 | 0=MDI, 1=AUTO, 3=EDIT, 4=HANDLE, 5=JOG, 7=REF |
| `Status/ModeText` | (derived from Mode) | String | — | "MDI", "Auto", "Edit", "Jog" |
| `Status/MotionState` | `cnc_statinfo()``ODBST.motion` | Int32 | FOCAS1 | 0=idle, 1=MOTION, 2=DWELL, 3=WAIT |
| `Status/EmergencyStop` | `cnc_statinfo()``ODBST.emergency` | Boolean | FOCAS1 | |
| `Status/AlarmActive` | `cnc_statinfo()``ODBST.alarm` | Int32 | FOCAS1 | Bitmask of alarm categories |
#### Axis Data (polled fast — 100-250ms, dynamically created per axis)
| OPC UA Node | FOCAS API | Return Type | FOCAS2 Min Version | Notes |
|-------------|-----------|-------------|-------------------|-------|
| `Axes/{name}/AbsolutePosition` | `cnc_absolute(handle, axis, ...)` | Double | FOCAS1 | Scaled integer → double via `cnc_getfigure()` |
| `Axes/{name}/MachinePosition` | `cnc_machine(handle, axis, ...)` | Double | FOCAS1 | |
| `Axes/{name}/RelativePosition` | `cnc_relative(handle, axis, ...)` | Double | FOCAS1 | |
| `Axes/{name}/DistanceToGo` | `cnc_distance(handle, axis, ...)` | Double | FOCAS1 | |
| `Axes/{name}/ServoLoad` | `cnc_rdsvmeter()` | Double | FOCAS2 | Percentage |
| `Axes/FeedRate/Actual` | `cnc_actf()``ODBACT.data` | Double | FOCAS1 | mm/min or inch/min |
| `Axes/FeedRate/Commanded` | `cnc_rdspeed()` | Double | FOCAS2 | |
Axis names (`X`, `Y`, `Z`, `A`, `B`, `C`, etc.) are discovered dynamically via `cnc_rdaxisname()` at connect time.
**Position value conversion**: Values are scaled integers. Use `cnc_getfigure()` to get decimal places per axis, then: `double position = rawValue / Math.Pow(10, decimalPlaces)`.
#### Spindle Data (polled medium — 250-500ms)
| OPC UA Node | FOCAS API | Return Type | FOCAS2 Min Version | Notes |
|-------------|-----------|-------------|-------------------|-------|
| `Spindle/{n}/ActualSpeed` | `cnc_acts()` or `cnc_acts2(handle, sp_no, ...)` | Double | FOCAS1 / FOCAS2 | RPM |
| `Spindle/{n}/Load` | `cnc_rdspmeter()` | Double | FOCAS2 | Percentage |
| `Spindle/{n}/Override` | `cnc_rdspeed()` type=1 | Double | FOCAS2 | Percentage |
#### Program Info (polled slow — 1000ms)
| OPC UA Node | FOCAS API | Return Type | FOCAS2 Min Version | Notes |
|-------------|-----------|-------------|-------------------|-------|
| `Program/MainProgramNumber` | `cnc_rdprgnum()``ODBPRO.mdata` | Int32 | FOCAS1 | |
| `Program/RunningProgramNumber` | `cnc_rdprgnum()``ODBPRO.data` | Int32 | FOCAS1 | |
| `Program/RunningProgramName` | `cnc_exeprgname()` | String | FOCAS2 | Full path/name |
| `Program/SequenceNumber` | `cnc_rdseqnum()``ODBSEQ.data` | Int32 | FOCAS1 | Current N-number |
| `Program/BlockCount` | `cnc_rdblkcount()` | Int64 | FOCAS2 | |
| `Program/PartsCount` | `cnc_rdparam(6711/6712)` | Int64 | FOCAS1 | Parameter-based |
| `Program/CycleTime` | `cnc_rdtimer(handle, 0/1/2, ...)` | Double | FOCAS1 | Seconds |
| `Program/OperatingTime` | `cnc_rdtimer(handle, 0, ...)` | Double | FOCAS1 | Hours |
#### Tool Data (polled slow — 1000-2000ms)
| OPC UA Node | FOCAS API | Return Type | FOCAS2 Min Version | Notes |
|-------------|-----------|-------------|-------------------|-------|
| `Tool/CurrentToolNumber` | `cnc_rdtofs()` | Int32 | FOCAS1 | Active tool offset # |
| `Tool/Offsets/{n}/GeometryX` | `cnc_rdtofsr()` | Double | FOCAS1 | Tool offset values |
| `Tool/Offsets/{n}/GeometryZ` | `cnc_rdtofsr()` | Double | FOCAS1 | |
| `Tool/Offsets/{n}/WearX` | `cnc_rdtofsr()` | Double | FOCAS1 | |
| `Tool/Offsets/{n}/WearZ` | `cnc_rdtofsr()` | Double | FOCAS1 | |
#### Alarms (polled — 500-1000ms)
| OPC UA Node | FOCAS API | Return Type | FOCAS2 Min Version | Notes |
|-------------|-----------|-------------|-------------------|-------|
| `Alarms/ActiveAlarmCount` | `cnc_rdalmmsg()` → count | Int32 | FOCAS2 | |
| `Alarms/Active/{n}/Number` | `cnc_rdalmmsg()``ODBALMMSG.alm_no` | Int32 | FOCAS2 | |
| `Alarms/Active/{n}/Message` | `cnc_rdalmmsg()``ODBALMMSG.alm_msg` | String | FOCAS2 | |
| `Alarms/Active/{n}/Type` | `cnc_rdalmmsg()``ODBALMMSG.type` | String | FOCAS2 | P/S, OT, SV, etc. |
| `Alarms/StatusBitmask` | `cnc_alarm()``ODBALM.data` | Int32 | FOCAS1 | Quick alarm check |
#### PMC Data (user-configured addresses, polled 100-500ms)
| OPC UA Node | FOCAS API | Return Type | FOCAS2 Min Version | Notes |
|-------------|-----------|-------------|-------------------|-------|
| `PMC/{type}/{address}` | `pmc_rdpmcrng(handle, adr_type, data_type, start, end, ...)` | varies | FOCAS1 | Batch-readable |
PMC address types: R (5), D (9), E (11), T (4), C (3), K (6), A (7), G (0), F (1), X, Y.
Data types: 0=byte, 1=word, 2=long, 4=float, 5=double.
**PMC addresses are user-configured** in the central config DB (address type, start, end, data type, friendly name). The driver does NOT auto-discover PMC layout.
#### Macro Variables (user-configured ranges, polled 500-1000ms)
| OPC UA Node | FOCAS API | Return Type | FOCAS2 Min Version | Notes |
|-------------|-----------|-------------|-------------------|-------|
| `Macro/Common/Var{n}` | `cnc_rdmacro(handle, n, ...)` | Double | FOCAS1 | #100-#199 (volatile) |
| `Macro/Persistent/Var{n}` | `cnc_rdmacro(handle, n, ...)` | Double | FOCAS1 | #500-#999 (retained) |
| `Macro/System/Var{n}` | `cnc_rdmacro(handle, n, ...)` | Double | FOCAS1 | #1000+ (system vars) |
| (batch) | `cnc_rdmacror(handle, start, end, ...)` | Double[] | FOCAS1 | Range read |
Macro variable conversion: `ODBM.mcr_val` / 10^`ODBM.dec_val` = actual double value.
### FOCAS Error Codes
| Code | Constant | Meaning | OPC UA StatusCode |
|------|----------|---------|-------------------|
| 0 | `EW_OK` | Success | `Good` |
| -1 | `EW_SOCKET` | Socket error (CNC off/network) | `BadNoCommunication` |
| -7 | `EW_HANDLE` | Invalid handle | `BadNoCommunication` |
| -17 | `EW_BUSY` | CNC busy (retry) | `BadResourceUnavailable` |
| 1 | `EW_FUNC` | Function not supported | `BadNotSupported` |
| 3 | `EW_NUMBER` | Invalid number | `BadNodeIdUnknown` |
| 6 | `EW_NOOPT` | CNC option not installed | `BadNotSupported` |
| 7 | `EW_PROT` | Write-protected | `BadNotWritable` |
| 10 | `EW_REJECT` | Request rejected | `BadInvalidState` |
### Implementation Notes
- **Tier C hosting**: `Focas.Proxy` runs in the main OtOpcUa server (.NET 10 x64) and implements `IDriver` + capability interfaces, forwarding every call over named-pipe IPC to the `Focas.Host` Windows service. `Focas.Host` is the only process that loads `Fwlib64.dll`. See `driver-stability.md` for the full Tier C contract (memory watchdog, scheduled recycle, crash-loop circuit breaker, post-mortem MMF, IPC ACL).
- **Wedged native calls escalate to hard exit, never handle-free-during-call** — per the recycle policy, an in-flight Fwlib call that exceeds the recycle grace window leaves its handle Abandoned and the host hard-exits. The supervisor respawns. Calling `cnc_freelibhndl` on a handle with an active native call is undefined behavior and is the AV path the isolation is designed to prevent.
- FOCAS calls are **not thread-safe per handle** — serialize all calls on a single handle (handle-affinity worker thread per handle in `Focas.Host`)
- Use `cnc_statinfo()` as a connection heartbeat (CNC-level — distinct from the proxy↔host heartbeat)
- Build a **capability discovery layer** — test key functions on first connect, expose only supported nodes
- Position values are scaled integers — must read increment system via `cnc_getfigure()` and convert
- Consider OPC UA Companion Spec **OPC 40502 (CNC Systems)** for standard node type definitions
### Operational Stability Notes
FOCAS has the canonical Tier C deep dive in `driver-stability.md` — handle pool design, per-handle thread serialization, watchdog thresholds (1.5×/2× baseline + 75 MB floor + 300 MB hard ceiling), recycle policy with hard-exit-on-wedged-call escalation, post-mortem MMF contents, and the two-artifact test approach (TCP stub + native FaultShim). Refer to that section rather than duplicating here.
---
## 8. OPC UA Client (Gateway) Driver
### Summary
In-process driver that connects to a remote OPC UA server as a client and re-exposes its address space through the local OtOpcUa server. Supports full OPC UA capability proxying: subscriptions, alarms, and history.
### Library & Dependencies
| Component | Package | Version | Target |
|-----------|---------|---------|--------|
| **OPC Foundation Client** | `OPCFoundation.NetStandard.Opc.Ua.Client` NuGet | 1.5.x | .NET 10 |
Already used by Client.Shared in the existing codebase.
### Required Components
- Network access to remote OPC UA server
- Certificate trust (or auto-accept for dev)
### Connection Settings (per remote server, from central config DB)
| Setting | Type | Default | Description |
|---------|------|---------|-------------|
| `EndpointUrl` | string | — | Remote server endpoint (e.g. `opc.tcp://host:4840`) |
| `SecurityPolicy` | string | None | `None`, `Basic256Sha256`, `Aes128_Sha256_RsaOaep` |
| `SecurityMode` | enum | None | `None`, `Sign`, `SignAndEncrypt` |
| `AuthType` | enum | Anonymous | `Anonymous`, `Username`, `Certificate` |
| `Username` | string? | — | For Username auth |
| `Password` | string? | — | For Username auth |
| `SessionTimeoutMs` | int | 120000 | Server-negotiated session timeout |
| `KeepAliveIntervalMs` | int | 5000 | Session keep-alive interval |
| `ReconnectPeriodMs` | int | 5000 | Initial reconnect delay |
| `BrowseStrategy` | enum | Full | `Full` (browse all at startup), `Lazy`, `Hybrid` |
| `BrowseRoot` | string? | — | Optional root NodeId to mirror (default: ObjectsFolder) |
| `AutoAcceptCertificates` | bool | false | Accept untrusted server certificates |
### Tag Discovery
Tags are discovered by **browsing the remote server's address space** — no central config DB tag definitions needed (though an optional filter/subset config could restrict what's exposed).
### Subscription Proxying
1. Local OPC UA client subscribes to a node → gateway creates `MonitoredItem` on remote server
2. Remote server pushes DataChange → gateway updates local variable + `ClearChangeMasks()`
3. Reference counting: only one remote MonitoredItem per unique tag, regardless of local subscriber count
4. Use `SessionReconnectHandler` for automatic reconnection + subscription transfer
### Namespace Remapping
Remote namespace indices must be remapped to local indices using namespace URI lookup. Build a bidirectional map at connect time from `session.NamespaceUris`.
### Alarm Forwarding
- Subscribe to events on remote server (`Attributes.EventNotifier` on `ObjectIds.Server`)
- Maintain local `AlarmConditionState` per remote alarm source
- Forward Acknowledge/Confirm calls from local clients to remote server
### History Forwarding
- Override `HistoryReadRawModified` etc. in the gateway NodeManager
- Forward to remote server via `session.HistoryRead()`
- Pass-through — no local storage needed
### Operational Stability Notes
Tier A (pure managed, OPC Foundation reference SDK). Universal protections cover it. Three operational concerns specific to a gateway driver:
- **Subscription drift on remote-server reconnect**: when the upstream server restarts and the session reconnects, the SDK by default creates fresh subscriptions, leaving the local NodeManager's monitored-item handles dangling. The driver must track upstream subscription IDs and reissue `TransferSubscriptions` (or rebuild) on reconnect. This is the largest gateway-specific bug surface.
- **Cascading quality**: when the upstream server reports Bad on a node, fan it out locally with the **same** StatusCode (don't translate to a generic Bad) so downstream clients can distinguish "remote source down" from "local driver failure." Preserve upstream timestamps too — overwriting them with `DateTime.UtcNow` masks staleness.
- **Browse cache memory**: `BrowseStrategy=Full` against a large remote server can cache tens of thousands of node descriptions. Per-instance budget should bound this; on breach, switch to `Lazy` strategy and let cache pressure drive eviction. No process action.
### Namespace Assignment
OPC UA Client is the only driver that supports **either** namespace kind, decided per driver instance via `DriverConfig.TargetNamespaceKind`:
- **Equipment**: when gatewaying a remote OPC UA server that exposes raw equipment data (e.g. another vendor's OPC UA-native PLC stack). The driver must remap remote browse paths to UNS via a config-driven mapping table — remote nodes don't conform to UNS by default. Each remote node group → an `Equipment` row with its own UNS Area/Line/Name and stable UUID.
- **SystemPlatform**: when gatewaying a remote OPC UA server that exposes processed/derived data (e.g. another System Platform endpoint). Hierarchy is preserved via `Tag.FolderPath` mirroring the remote browse path; no UNS conversion.
The driver enforces the namespace choice at startup — a misconfigured remote (raw signals routed to SystemPlatform, or processed data routed to Equipment without a UNS mapping) fails draft validation, not runtime.
---
## Driver Comparison Summary
| Driver | Library | License | Stability Tier | Namespace Kind | .NET Target | Native Subs | Tag Discovery | Connection Limit | Required Infrastructure |
|--------|---------|---------|----------------|----------------|-------------|-------------|---------------|-----------------|------------------------|
| Galaxy | MXAccess COM + MessagePack IPC | Proprietary | **C — out-of-process** | **SystemPlatform** | .NET 4.8 x86 (Host) + .NET 10 x64 (Proxy) | **Yes (MXAccess advisory)** | Galaxy DB query + `IRediscoverable` on deploy | Per-Galaxy (one Host per machine) | ArchestrA Platform, SQL Server (ZB DB), Historian (optional) |
| Modbus TCP | NModbus 3.x | MIT | A — in-process | Equipment | .NET 10 x64 | No (polled) | Config DB | 2-8 per device | None (also covers DL205 via octal address translation) |
| AB CIP | libplctag 1.6.x | LGPL/MIT | B — in-process with guards | Equipment | .NET 10 x64 | No (polled) | Config DB | 32-128 per PLC | None |
| AB Legacy | libplctag 1.6.x | LGPL/MIT | B — in-process with guards | Equipment | .NET 10 x64 | No (polled) | Config DB | **4-8 per PLC** | Ethernet adapter for some models |
| Siemens S7 | S7netplus | MIT | B — in-process with guards | Equipment | .NET 10 x64 | No (polled) | Config DB | 3-30 per PLC | PUT/GET enabled (S7-1200/1500) |
| TwinCAT | Beckhoff.TwinCAT.Ads 6.x | Proprietary | B — in-process with guards | Equipment | .NET 10 x64 | **Yes (ADS)** | Symbol upload | 64-128 | AMS route configured |
| FOCAS | Fwlib64.dll (P/Invoke) + MessagePack IPC | FANUC SDK license | **C — out-of-process** | Equipment | .NET 10 x64 (Host + Proxy) | No (polled) | Built-in + Config DB | **5-10 per CNC** | FANUC SDK license, CNC Ethernet option, Focas.Host Windows service |
| OPC UA Client | OPC Foundation 1.5.x | GPL/RCL | A — in-process | Equipment OR SystemPlatform (per-instance) | .NET 10 x64 | **Yes (native)** | Browse remote | Varies | Certificate trust |
Tier definitions and per-tier protections: see `driver-stability.md`. Namespace model and UNS naming rules: see `plan.md` §4 and `config-db-schema.md` (Equipment table). Equipment-namespace drivers populate `Equipment` rows whose UNS path comes from `ServerCluster.Enterprise/Site` + `Equipment.Area/Line/Name`; SystemPlatform-namespace drivers (Galaxy) preserve their own hierarchy via `Tag.FolderPath` as v1 LmxOpcUa expressed it.