# OtOpcUa v2 — Driver Implementation Specifications

> **Status**: DRAFT — reference document for plan.md
>
> **Created**: 2026-04-16

---

## 1. Galaxy (MXAccess) Driver

### Summary

Out-of-process **Tier C** driver bridging AVEVA System Platform (Wonderware) Galaxies. The existing v1 implementation is refactored behind the new driver capability interfaces and hosted in a separate Windows service (.NET 4.8 x86) that communicates with the main OtOpcUa server (.NET 10 x64) via named pipes + MessagePack. Hosted out-of-process for **two reasons**: COM/.NET 4.8 x86 bitness constraint **and** Tier C stability isolation (per `driver-stability.md`). FOCAS is the second Tier C driver, also out-of-process — see §7.

### Library & Dependencies

| Component | Package / Source | Version | Target | Notes |
|-----------|------------------|---------|--------|-------|
| **MXAccess COM** | `ArchestrA.MxAccess` (GAC / `lib/ArchestrA.MxAccess.dll`) | version-neutral late-bound | .NET 4.8 x86 | Pinned via `<Reference Include="ArchestrA.MxAccess">` with `EmbedInteropTypes=false`; interfaces: `LMXProxyServer`, `ILMXProxyServerEvents`, `MXSTATUS_PROXY` |
| **Galaxy DB client** | `System.Data.SqlClient` (BCL) | BCL | .NET 4.8 x86 | Direct SQL for hierarchy/attribute/change-detection queries |
| **Wonderware Historian SDK** | `aahClientManaged`, `aahClientCommon` | Historian-shipped | .NET 4.8 x86 | Optional — loaded only when `Historian.Enabled=true` |
| **MessagePack-CSharp** | `MessagePack` NuGet | 2.x | .NET Standard 2.0 (Shared) | IPC serialization; shared contract between Proxy and Host |
| **Named pipes** | `System.IO.Pipes` (BCL) | BCL | both sides | IPC transport, localhost only |

### Required Components

- **AVEVA System Platform / ArchestrA Platform** deployed on the same machine as `Galaxy.Host` (installs MXAccess COM objects into the GAC)
- A **deployed Galaxy** with at least one $WinPlatform object hosting $AppEngine(s) hosting AutomationObjects
- **SQL Server** reachable from `Galaxy.Host` with the Galaxy repository database (default `ZB`); Windows Auth by default
- **32-bit .NET Framework 4.8** runtime on the Host machine (MXAccess is 32-bit COM, no 64-bit variant)
- **STA thread + Win32 message pump** inside the Host process for all COM calls and event callbacks (see §13)
- **Wonderware Historian** installed on-box or reachable via aah SDK — *only* if HDA is enabled
- **No external firewall ports** — MXAccess is local-machine COM/IPC; pipe is localhost-only. Galaxy DB port (default SQL 1433) if the ZB database is remote.

### Connection Settings (per driver instance, from central config DB)

All settings live under a schemaless `DriverConfig` JSON blob on the `DriverInstance` row. Current v1 equivalents (defaults and source file references in parentheses):

**MXAccess** (`MxAccessConfiguration.cs`):

| Setting | Type | Default | Description |
|---------|------|---------|-------------|
| `ClientName` | string | `"LmxOpcUa"` | Registration name passed to `LMXProxyServer.Register()` |
| `NodeName` | string? | `null` | Optional ArchestrA node override (null = local) |
| `GalaxyName` | string? | `null` | Optional Galaxy name override |
| `ReadTimeoutSeconds` | int | `5` | Per-read timeout |
| `WriteTimeoutSeconds` | int | `5` | Per-write timeout |
| `RequestTimeoutSeconds` | int | `30` | Outer safety timeout around any MXAccess request |
| `MaxConcurrentOperations` | int | `10` | Pool bound on in-flight MXAccess work items |
| `MonitorIntervalSeconds` | int | `5` | Connectivity heartbeat probe interval |
| `AutoReconnect` | bool | `true` | Replay stored subscriptions on COM reconnect |
| `ProbeTag` | string? | `null` | Optional heartbeat tag for health monitoring |
| `ProbeStaleThresholdSeconds` | int | `60` | Mark connection stale if no probe callback within |
| `RuntimeStatusProbesEnabled` | bool | `true` | Auto-subscribe `ScanState` for $WinPlatform / $AppEngine |
| `RuntimeStatusUnknownTimeoutSeconds` | int | `15` | Grace period before an un-probed host is assumed Stopped |

**Galaxy repository** (`GalaxyRepositoryConfiguration.cs`):

| Setting | Type | Default | Description |
|---------|------|---------|-------------|
| `ConnectionString` | string | `Server=localhost;Database=ZB;Integrated Security=true;` | ZB SQL Server connection |
| `ChangeDetectionIntervalSeconds` | int | `30` | Poll interval for `galaxy.time_of_last_deploy` |
| `CommandTimeoutSeconds` | int | `30` | SQL command timeout |
| `ExtendedAttributes` | bool | `false` | Include extended attribute metadata in discovery |
| `Scope` | enum (`Galaxy` \| `LocalPlatform`) | `Galaxy` | Address-space scope filter (commit bc282b6) |
| `PlatformName` | string? | `Environment.MachineName` | Platform to scope to when `Scope=LocalPlatform` |

**IPC** (new for v2):

| Setting | Type | Default | Description |
|---------|------|---------|-------------|
| `PipeName` | string | `otopcua-galaxy-{InstanceId}` | Named pipe name |
| `HostStartupTimeoutMs` | int | `30000` | Proxy wait for Host `Ready` handshake |
| `IpcCallTimeoutMs` | int | `15000` | Per-call RPC timeout |

### Addressing

Galaxy objects carry two names:

- **`contained_name`** — human-readable, scoped to parent; used for OPC UA browse tree
- **`tag_name`** — globally unique system identifier; used for MXAccess runtime references

| Layer | Example |
|-------|---------|
| OPC UA browse path | `TestMachine_001/DelmiaReceiver/DownloadPath` |
| OPC UA NodeId | `ns=<galaxyNs>;s=<tagName>.<AttributeName>` |
| MXAccess reference | `DelmiaReceiver_001.DownloadPath` (passed to `AddItem()`) |

Tag discovery is **dynamic** — driven by the Galaxy repository DB (`gobject`, `dynamic_attribute`, `primitive_instance`, `template_definition`). Optional `Scope=LocalPlatform` filters the hierarchy via the `hosted_by_gobject_id` chain to the subtree rooted at the local $WinPlatform (on a dev Galaxy: 49→3 objects, 4206→386 attributes).

### Data Type Mapping (`MxDataTypeMapper.cs`, `gr/data_type_mapping.md`)

| mx_data_type | Galaxy Type | OPC UA BuiltInType | CLR Type |
|--------------|-------------|--------------------|----------|
| 1 | Boolean | Boolean (i=1) | `bool` |
| 2 | Integer | Int32 (i=6) | `int` |
| 3 | Float | Float (i=10) | `float` |
| 4 | Double | Double (i=11) | `double` |
| 5 | String | String (i=12) | `string` |
| 6 | Time | DateTime (i=13) | `DateTime` |
| 7 | ElapsedTime | Double (i=11) | `double` (seconds) |
| 8 | Reference | String (i=12) | `string` |
| 13 | Enumeration | Int32 (i=6) | `int` |
| 14 / 16 | Custom | String (i=12) | `string` |
| 15 | InternationalizedString | LocalizedText (i=21) | `string` |
| (default) | Unknown | String (i=12) | `string` |

**Arrays**: `is_array=0` → ValueRank `-1` (Scalar); `is_array=1` → ValueRank `1` (OneDimension), ArrayDimensions = `[array_dimension]`.

### Security Classification Mapping (`SecurityClassificationMapper.cs`)

| security_classification | Galaxy Level | OPC UA Write Permission |
|-------------------------|--------------|-------------------------|
| 0 | FreeAccess | `WriteOperate` |
| 1 | Operate | `WriteOperate` |
| 2 | SecuredWrite | — (read-only in v1) |
| 3 | VerifiedWrite | — (read-only in v1) |
| 4 | Tune | `WriteTune` |
| 5 | Configure | `WriteConfigure` |
| 6 | ViewOnly | — (read-only) |

Maps to the OPC UA roles `ReadOnly` / `WriteOperate` / `WriteTune` / `WriteConfigure` defined in the LDAP role provider (see `docs/security.md`).

### Subscription Model — Native MXAccess Advisories

**Galaxy is one of three drivers with native subscriptions (Galaxy, TwinCAT, OPC UA Client).** No polling.

- Mechanism: `LMXProxyServer.AddItem()` → `AdviseSupervisory(handle, itemHandle)`; callbacks delivered through the `ILMXProxyServerEvents.OnDataChange` COM event
- Callback signature: `MxDataChangeHandler(itemHandle, MXSTATUS_PROXY, value, quality, timestamp)`
- Dispatch: STA COM event → dispatch-thread queue → OPC UA `ClearChangeMasks` fan-out (decouples COM thread from UA stack lock — commit c76ab8f)
- **Stored subscriptions** replayed on reconnect via `ReplayStoredSubscriptionsAsync()`
- **Probe tag** + runtime-status probes provide connection-health visibility (see §14)
- **Bad-quality fan-out**: when a host ($WinPlatform or $AppEngine) ScanState transitions to Stopped, every attribute under that host is immediately published as `BadOutOfService` (commits 7310925, c76ab8f)

### Alarm Model

In-process alarm-condition tracking (v1 baseline; extended in v2 to match `IAlarmSource`):

- **Auto-subscribed attributes per alarm-eligible object**: `InAlarm`, `Priority`, `Description` (cached for severity and message)
- **Filtering**: `AlarmFilterConfiguration.ObjectFilters[]` — include/exclude by template chain (empty = all eligible)
- **Transitions**: `InAlarm` change → OPC UA A&C `AlarmConditionState` event (Active / Return to Normal)
- **Severity**: Galaxy `Priority` (1 = highest) mapped to OPC UA 1–1000 severity (higher = more severe)
- **Acknowledgment**: local OPC UA ack forwards to MXAccess write on the `Ack` attribute of the alarm-bearing object

### History Model — Wonderware Historian (optional plugin)

- Loaded **at runtime** from `ZB.MOM.WW.LmxOpcUa.Historian.Aveva.dll` when `Historian.Enabled=true`; compile-time optional
- SDK: `aahClientManaged` / `aahClientCommon`
- Supported OPC UA HDA calls:
  - `HistoryReadRawModified` (raw values with bounds)
  - `HistoryReadProcessed` (Historian aggregates: AVG, MIN, MAX, TIMEAVG, etc. — mapped to OPC UA aggregates)
  - Continuation points for paged reads
- Only attributes flagged `historize=1` in the Galaxy DB expose `AccessLevel.HistoryRead`

### Error Mapping — MXAccess → Quality → OPC UA StatusCode

**Byte quality (OPC DA convention)** — `QualityMapper.cs`:

| OPC DA Quality | Category |
|----------------|----------|
| `>= 192` | Good |
| `64–191` | Uncertain |
| `< 64` | Bad |

**MXAccess error codes → Quality** (`MxErrorCodes.cs`):

| Code | Name | Quality |
|------|------|---------|
| 1008 | `MX_E_InvalidReference` | `BadConfigError` |
| 1012 | `MX_E_WrongDataType` | `BadConfigError` |
| 1013 | `MX_E_NotWritable` | `BadOutOfService` |
| 1014 | `MX_E_RequestTimedOut` | `BadCommFailure` |
| 1015 | `MX_E_CommFailure` | `BadCommFailure` |
| 1016 | `MX_E_NotConnected` | `BadNotConnected` |

**Quality → OPC UA StatusCode** (`QualityMapper.cs`):

| Quality | StatusCode |
|---------|-----------|
| Good | `0x00000000` |
| GoodLocalOverride | `0x00D80000` |
| Uncertain | `0x40000000` |
| Bad (generic) | `0x80000000` |
| BadCommFailure | `0x80050000` |
| BadNotConnected | `0x808A0000` |
| BadOutOfService | `0x808D0000` |

### Change Detection

- `ChangeDetectionService` polls `galaxy.time_of_last_deploy` at `ChangeDetectionIntervalSeconds` (default 30s)
- On timestamp change, `OnGalaxyChanged` fires → Host re-queries hierarchy/attributes → emits `TagSetChanged` over IPC → Proxy implements `IRediscoverable` and rebuilds the affected subtree in the address space
- Platform-scope filter (commit bc282b6) applied during hierarchy load when `Scope=LocalPlatform`

### IPC Contract (Proxy ↔ Host) — `Galaxy.Shared`

.NET Standard 2.0 MessagePack contracts. Every request carries a correlation ID; responses carry the same ID plus success/error.

**Lifecycle / handshake**:

| Message | Direction | Payload |
|---------|-----------|---------|
| `ClientHello` | Proxy → Host | InstanceId, expected protocol version |
| `HostReady` | Host → Proxy | Host version, Galaxy name, capabilities |
| `Shutdown` | Proxy → Host | Graceful stop |

**Tag discovery** (`ITagDiscovery`):

| Message | Direction | Payload |
|---------|-----------|---------|
| `DiscoverHierarchyRequest` | Proxy → Host | `Scope`, `PlatformName` |
| `DiscoverHierarchyResponse` | Host → Proxy | `GalaxyObjectInfo[]` (TagName, ContainedName, ParentTagName, TemplateChain, category) |
| `DiscoverAttributesRequest` | Proxy → Host | `TagName[]` |
| `DiscoverAttributesResponse` | Host → Proxy | `GalaxyAttributeInfo[]` (Name, MxDataType, IsArray, ArrayDim, SecurityClass, Historized, WriteableRuntimeChecked) |
| `TagSetChangedNotification` | Host → Proxy | New deploy timestamp; triggers re-discover |

**Read / Write** (`IReadable`, `IWritable`):

| Message | Direction | Payload |
|---------|-----------|---------|
| `ReadRequest` | Proxy → Host | `TagRef[]` (tag_name + attribute) |
| `ReadResponse` | Host → Proxy | `VtqPayload[]` (value, quality, timestamp, statusCode) |
| `WriteRequest` | Proxy → Host | `(TagRef, Value, ExpectedDataType)[]` |
| `WriteResponse` | Host → Proxy | `(TagRef, StatusCode)[]` |

**Subscription** (`ISubscribable`):

| Message | Direction | Payload |
|---------|-----------|---------|
| `SubscribeRequest` | Proxy → Host | `TagRef[]` + Proxy-generated subscription ID |
| `SubscribeResponse` | Host → Proxy | Per-tag subscribe ack + handle |
| `UnsubscribeRequest` | Proxy → Host | handles |
| `DataChangeNotification` | Host → Proxy (push) | handle, VTQ, sequence number |
| `ProbeHealthNotification` | Host → Proxy (push) | probe tag staleness, `ScanState` transitions, overall connected/disconnected |

**Alarms** (`IAlarmSource`):

| Message | Direction | Payload |
|---------|-----------|---------|
| `AlarmEventNotification` | Host → Proxy (push) | source tag, InAlarm, Priority, Description, severity, transition type |
| `AlarmAckRequest` | Proxy → Host | source tag, user, comment |

**History** (`IHistoryProvider`):

| Message | Direction | Payload |
|---------|-----------|---------|
| `HistoryReadRawRequest` | Proxy → Host | TagRef, start, end, numValues, returnBounds, continuationPoint |
| `HistoryReadRawResponse` | Host → Proxy | values + next continuation point |
| `HistoryReadProcessedRequest` | Proxy → Host | TagRef, aggregateId, start, end, resampleInterval |
| `HistoryReadProcessedResponse` | Host → Proxy | aggregated values |

**Framing**: length-prefixed MessagePack frames over a single `NamedPipeServerStream` in `PipeTransmissionMode.Byte`. Separate outgoing pipe for push notifications or multiplex via message type tag.

### Threading / COM Constraints

- **STA thread** (`StaComThread.cs`) hosts MXAccess: `ApartmentState.STA`, raw Win32 `GetMessage` / `DispatchMessage` loop
- Work items marshaled in via `PostThreadMessage(WM_APP=0x8000)`
- **Per-handle serialization**: LMXProxyServer is not thread-safe — all Read/Write/Subscribe calls on one handle run serially via the STA queue
- **Dispatch thread** (separate from STA thread) drains `_pendingDataChanges` to the OPC UA framework; decouples the STA pump from UA stack locks so a slow subscriber can't back up COM event delivery
- **Reentrancy guards** — event unwiring must precede `Marshal.ReleaseComObject()` on disconnect

### Runtime Status (recent commits bc282b6 / 4b209f6 / 7310925 / c76ab8f / 0003984)

- `GalaxyRuntimeProbeManager` auto-subscribes `<ObjectName>.ScanState` for every $WinPlatform (category 1) and $AppEngine (category 3) in scope
- Per-host state machine: `Unknown → Running | Stopped`; transitions fire `_onHostStopped` / `_onHostRunning` callbacks on the dispatch thread
- **Synthetic OPC UA nodes** expose `ScanState` per host as read-only variables so clients see runtime topology without the dashboard
- **HealthCheck Rule 2e** monitors probe subscription health; a failed probe can no longer leave phantom entries that fan out false `BadOutOfService`
- Generalizes to the driver-agnostic `IHostConnectivityProbe` capability interface in v2 (see `plan.md` §5a)

### Implementation Notes

- **First Tier C out-of-process driver** — uses the `Galaxy.Proxy` / `Galaxy.Host` / `Galaxy.Shared` three-project split. The pattern is reusable; FOCAS is the second adopter (see §7), and any future driver with bitness, licensing, or stability-isolation needs reuses the same template. See `driver-stability.md` for the generalized contract
- `Galaxy.Proxy` (in the main server) implements `IDriver`, `ITagDiscovery`, `IRediscoverable`, `IReadable`, `IWritable`, `ISubscribable`, `IAlarmSource`, `IHistoryProvider`, `IHostConnectivityProbe`
- `Galaxy.Host` owns `MxAccessBridge`, `GalaxyRepository`, alarm tracking, `GalaxyRuntimeProbeManager`, and the Historian plugin — no reference to `Core.Abstractions`
- `Galaxy.Shared` is .NET Standard 2.0, referenced by both sides
- Existing v1 code is the implementation — **refactor in place** (extract capability interfaces first, then move behind IPC — see `plan.md` Decision #55)
- **Parity gate**: v2 driver must pass v1 `IntegrationTests` suite + scripted Client.CLI walkthrough before Phase 3 begins

### Operational Stability Notes

Galaxy has a Tier C deep dive in `driver-stability.md` covering the STA pump, COM object lifetime, subscription replay, recycle policy, and post-mortem contents. Driver-instance specifics:

- **Memory baseline scales with Galaxy size**. Watchdog floor of 200 MB above baseline + 1.5 GB hard ceiling — higher than FOCAS because legitimate Galaxy footprints are larger.
- **Slope tolerance is 5 MB/min** (more permissive than FOCAS) because address-space rebuild on redeploy can transiently allocate large amounts.
- **Known regression-prone failure modes** (closed in commits `c76ab8f` and `7310925`, must remain closed): phantom probe subscription flipping Tick() to Stopped; cross-host quality clear wiping sibling state during recovery; sync-over-async on the OPC UA stack thread; fire-and-forget alarm tasks racing shutdown. Each should have a regression test in the v2 parity suite.
- **STA pump health probe** every 10 s (separate from the proxy↔host heartbeat). A wedged pump is the most likely Tier C failure mode for Galaxy.
- **Recycle preserves cached `time_of_last_deploy` watermark** — the common case (crash unrelated to redeploy) skips full DB rediscovery for faster recovery.

### Namespace Assignment

Galaxy is the canonical **SystemPlatform-kind namespace** driver. It exposes Aveva System Platform / Galaxy objects as OPC UA — these are *processed* values with business meaning attached at Layer 3, not raw equipment signals. Per `plan.md` §4:

- The Galaxy driver's `DriverInstance.NamespaceId` must reference a `Namespace` row with `Kind = 'SystemPlatform'`.
- **UNS naming rules do NOT apply** to the Galaxy hierarchy. Tags belong to `DriverInstanceId + FolderPath` (v1 LmxOpcUa pattern preserved); `Tag.EquipmentId` is NULL.
- The Galaxy hierarchy reflects the gobject parent chain as v1 has always done — no migration to UNS path conventions in v2.
- If a future need arises to expose raw Galaxy gobject data alongside processed (e.g. an Aveva-Wonderware Historian raw signal feed), that becomes a *separate* driver instance assigned to an Equipment-kind namespace, with its own per-equipment mapping.

---

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