# 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 `` 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=;s=.` | | 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 `.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.