# 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/Drivers/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/Drivers/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) | 512–1023 | 512 + OctalToDecimal(addr) |
| `T0`–`T377` | Coil (FC 01) | 2048–2303 | 2048 + OctalToDecimal(addr) (status bits) |
| `CT0`–`CT177` | Coil (FC 01) | 2560–2687 | 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 (V0–V377 for timers, V1000–V1177 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:

- **4–8 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`, `DB1.DBB8` |
| Merkers | `M{B\|W\|D}{offset}` or `M{offset}.{bit}` | 0x83 | `M0.0`, `MW0`, `MD4`, `MB4` |
| Inputs | `I{B\|W\|D}{offset}` or `I{offset}.{bit}` | 0x81 | `I0.0`, `IW0`, `ID0`, `IB0` |
| Outputs | `Q{B\|W\|D}{offset}` or `Q{offset}.{bit}` | 0x82 | `Q0.0`, `QW0`, `QD0`, `QB0` |
| Timers | `T{n}` | 0x1D | `T0`, `T15` |
| Counters | `C{n}` | 0x1C | `C0`, `C10` |

### Supported Data Types

| DataType | S7 Type | Width | Read | Write | Notes |
|----------|---------|-------|------|-------|-------|
| `Bool` | BOOL | 1 bit | yes | yes | |
| `Byte` | BYTE | 1 byte | yes | yes | |
| `Int16` | INT | 2 bytes | yes | yes | |
| `UInt16` | WORD | 2 bytes | yes | yes | |
| `Int32` | DINT | 4 bytes | yes | yes | |
| `UInt32` | DWORD | 4 bytes | yes | yes | |
| `Float32` | REAL | 4 bytes | yes | yes | |
| `Int64` | LINT | 8 bytes | yes | yes | Byte-anchored — see below |
| `UInt64` | ULINT/LWORD | 8 bytes | yes | yes | Byte-anchored — see below |
| `Float64` | LREAL | 8 bytes | yes | yes | Byte-anchored — see below |
| `String` | STRING | `StringLength + 2` bytes | yes | yes | Byte-anchored; default `StringLength`=254 |
| `DateTime` | DATE_AND_TIME | 8 bytes | yes | yes | Byte-anchored — see below |
| Timer | TIME | — | yes | **no** | `T{n}` address only; DataType must be `Float64`; value = seconds |
| Counter | COUNTER | — | yes | **no** | `C{n}` address only; DataType must be `Int32`; value = raw word |

### Wide / Structured Types — Byte-Anchored Addressing

Wide types (`Int64`, `UInt64`, `Float64`/LReal, `String`, `DateTime`) are **byte-anchored**: the
address must use the `B` suffix to specify the start byte; the driver reads the
correct number of bytes based on the DataType.

| DataType | Required address form | Example |
|----------|-----------------------|---------|
| `Int64` | `DB{n}.DBB{offset}` / `MB{offset}` / `IB{offset}` / `QB{offset}` | `DB1.DBB8` → LINT at bytes 8–15 |
| `UInt64` | same | `DB2.DBB16` → ULINT at bytes 16–23 |
| `Float64` | same | `DB1.DBB8` → LREAL at bytes 8–15 |
| `DateTime` | same | `DB3.DBB0` → DATE_AND_TIME at bytes 0–7 |
| `String` | same | `DB4.DBB0` DataType=String, StringLength=40 → reads 42 bytes |

Using a `W` (word) or `D` (dword) suffix with a wide DataType produces an address parse
error at `InitializeAsync`.

### Timer / Counter Read

**Timer (`T{n}`)** — reads the timer's current value as elapsed **seconds** (double). The OPC UA
node DataType is `Float64`. Timers are **read-only** this phase; writes return `BadNotWritable`.

**Counter (`C{n}`)** — reads the counter current value as a raw count (`int`). The OPC UA node
DataType is `Int32`. Counters are **read-only** this phase; writes return `BadNotWritable`.

> **Counter known-limitation**: `S7.Net`'s `Counter.FromByteArray` returns the raw big-endian
> word without BCD decode. On **classic S7-300/400** the C-area word is BCD-encoded (0–999),
> so the surfaced value can differ from the actual count on that hardware. S7-1200/1500 use
> IEC/DB counters (plain integers) where the raw word is correct. BCD reinterpretation for
> legacy C-area is a live-hardware-gated follow-up.

### Deferrals (not yet implemented)

The following are explicitly deferred and will return `BadNotSupported` or a config error if attempted:

- **Wide-type arrays** — array tags (`isArray: true`) with element types `Int64`, `UInt64`,
  `Float64`, `String`, or `DateTime`.
- **`S7WString`** (2-byte characters; distinct from `STRING`).
- **`DTL` / `DateTimeLong`** (12-byte Siemens date-and-time-long).
- **Timer / Counter writes** — surfaced as `BadNotWritable` until full write support lands.

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