Files
lmxopcua/docs/v2/driver-specs.md
Joseph Doherty a59ad2e0c6 Harden v2 design against the four findings from the 2026-04-17 Codex adversarial review of the db schema and admin UI: (1) DriverInstance.NamespaceId now enforces a same-cluster invariant in three layers (sp_ValidateDraft cross-table check using the new UX_Namespace_Generation_LogicalId_Cluster composite index, server-side namespace-selection API scoping that prevents bypass via crafted requests, and audit-log entries on cross-cluster attempts) so a draft for cluster A can no longer bind to cluster B's namespace and leak its URI into A's endpoint; (2) the Namespace table moves from cluster-level to generation-versioned with append-only logical-ID identity and locked NamespaceUri/Kind across generations so admins can no longer disable a namespace that a published driver depends on outside the publish/diff/rollback flow, the cluster-create workflow opens an initial draft containing the default namespaces instead of writing namespace rows directly, and the Admin UI Namespaces tab becomes hybrid (read-only over published, click-to-edit opens draft) like the UNS Structure tab; (3) ZTag/SAPID fleet-wide uniqueness moves from per-generation indexes (which silently allow rollback or re-enable to reintroduce duplicates) into a new ExternalIdReservation table that sits outside generation versioning, with sp_PublishGeneration reserving atomically via MERGE under transaction lock so a different EquipmentUuid attempting the same active value rolls the whole publish back, an FleetAdmin-only sp_ReleaseExternalIdReservation as the only path to free a value for reuse with audit trail, and a corresponding Release-reservation operator workflow in the Admin UI; (4) Equipment.EquipmentId is now system-generated as 'EQ-' + first 12 hex chars of EquipmentUuid, never operator-supplied or editable, removed from the Equipment CSV import schema entirely (rows match by EquipmentUuid for updates or create new equipment with auto-generated identifiers when no UUID is supplied), with a new Merge-or-Rebind-equipment operator workflow handling the rare case where two UUIDs need to be reconciled — closing the corruption path where typos and bulk-import renames were minting duplicate identities and breaking downstream UUID-keyed lineage. New decisions #122-125 with explicit "supersedes" notes for the earlier #107 (cluster-level namespace) and #116 (operator-set EquipmentId) frames they revise.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 11:08:58 -04:00

1031 lines
60 KiB
Markdown
Raw Blame History

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