Audit (three parallel agent passes) found 43 markdown files carrying stale references to the deleted Galaxy.Host/Proxy/Shared projects after the v2-mxgw merge. This commit lands the prioritized fixes. Track 1 — high-traffic in-place rewrites (3 files, ~454 lines deleted) - README.md (202 → 91 lines): drops .NET 4.8 / x86 / TopShelf install text; leads with the multi-driver .NET 10 server identity and points at scripts/install/Install-Services.ps1 and the parity rig. - docs/v2/driver-specs.md §1 Galaxy (~289 → ~66 lines): replaces the Tier-C out-of-process spec with a Tier-A in-process description matching the current GalaxyDriver code, with the four-section GalaxyDriverOptions JSON shape pulled verbatim from Config/GalaxyDriverOptions.cs. - docs/drivers/Galaxy.md (211 → 92 lines): full rewrite around the current Browse/Runtime/Health/Config sub-folders. Track 2 — historical banners (5 files) - lmx_mxgw.md, lmx_mxgw_impl.md, lmx_backend.md, docs/v2/Galaxy.ParityMatrix.md, docs/v2/implementation/phase-2-galaxy-out-of-process.md each get a "✅ Completed 2026-04-30 — historical record" banner block. lmx_mxgw.md also fixes two dead links (`docs/Galaxy.Driver.md` and `docs/v2/Galaxy.Driver.md`) → `docs/drivers/Galaxy.md`. Track 3 — v1 archive sweep (10 git mv + 1 new index + 2 in-place scrubs) - Moved 10 v1 docs under docs/v1/ preserving subpath structure: AlarmTracking, Configuration, DataTypeMapping, HistoricalDataAccess, Subscriptions (top-level); drivers/Galaxy-Repository, drivers/Galaxy-Test-Fixture; reqs/GalaxyRepositoryReqs, reqs/MxAccessClientReqs, reqs/ServiceHostReqs. - New docs/v1/README.md is the shared archive banner + per-file table. - docs/README.md repointed to the v1 paths and updated to reflect the v2 two-process deploy shape (Server + Admin + optional OtOpcUaWonderwareHistorian). - docs/v2/Galaxy.ParityRig.md got a historical banner + four inline scrubs marking the OtOpcUaGalaxyHost service / Driver.Galaxy.Host EXE / Driver.Galaxy.ParityTests project as deleted-in-PR-7.2. The repo's live-reading surface (README + CLAUDE.md + docs/v2/) now describes only the post-PR-7.2 architecture. v1 docs are preserved as a labelled archive under docs/v1/. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
45 KiB
OtOpcUa v2 — Driver Implementation Specifications
Status: DRAFT — reference document for plan.md
Created: 2026-04-16
1. Galaxy (MXAccess) Driver
Summary
Galaxy (MXAccess) is a Tier-A in-process driver that runs in the OtOpcUa server's .NET 10 AnyCPU process and speaks gRPC to a separately installed mxaccessgw (sibling repo at c:\Users\dohertj2\Desktop\mxaccessgw\). The gateway owns the MXAccess COM apartment, the STA pump, and the Galaxy Repository / Historian SDK on its own host; the driver itself is platform-agnostic and carries no COM or x86 bitness constraint. Project lives at src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/.
Capability Surface
GalaxyDriver (in GalaxyDriver.cs) implements IDriver, IDisposable, plus six driver capabilities — eight interfaces total.
| Capability | Source files |
|---|---|
ITagDiscovery |
Browse/GalaxyDiscoverer.cs, Browse/GatewayGalaxyHierarchySource.cs, Browse/DataTypeMap.cs, Browse/SecurityMap.cs, Browse/AlarmRefBuilder.cs |
IRediscoverable |
Browse/DeployWatcher.cs, Browse/GatewayGalaxyDeployWatchSource.cs |
IReadable |
Runtime/GalaxyMxSession.cs, Runtime/MxValueDecoder.cs, Runtime/StatusCodeMap.cs |
IWritable |
Runtime/GatewayGalaxyDataWriter.cs (+ TracedGalaxyDataWriter.cs), Runtime/MxValueEncoder.cs |
ISubscribable |
Runtime/GatewayGalaxySubscriber.cs (+ TracedGalaxySubscriber.cs), Runtime/EventPump.cs, Runtime/SubscriptionRegistry.cs, Runtime/ReconnectSupervisor.cs |
IHostConnectivityProbe |
Health/HostStatusAggregator.cs, Health/HostConnectivityForwarder.cs, Health/PerPlatformProbeWatcher.cs |
History reads + alarm condition tracking now live in the server-layer IHistoryRouter and AlarmConditionService (PR 7.2). Galaxy no longer carries IHistoryProvider or IAlarmSource of its own.
DriverConfig JSON shape
Per src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Config/GalaxyDriverOptions.cs:
{
"Gateway": {
"Endpoint": "http://localhost:5120",
"ApiKeySecretRef": "secret:galaxy-gw-api-key",
"UseTls": true,
"CaCertificatePath": null,
"ConnectTimeoutSeconds": 10,
"DefaultCallTimeoutSeconds": 30,
"StreamTimeoutSeconds": 0
},
"MxAccess": {
"ClientName": "OtOpcUa",
"PublishingIntervalMs": 1000,
"WriteUserId": 0,
"EventPumpChannelCapacity": 50000
},
"Repository": {
"DiscoverPageSize": 5000,
"WatchDeployEvents": true
},
"Reconnect": {
"InitialBackoffMs": 500,
"MaxBackoffMs": 30000,
"ReplayOnSessionLost": true
}
}
Gateway.ApiKeySecretRef resolves through the server-side secret store (DPAPI in production, env override in dev) — the API key never appears in cleartext config. MxAccess.ClientName MUST be unique per OtOpcUa instance; redundancy pairs enforce uniqueness at install time. StreamTimeoutSeconds = 0 keeps the StreamEvents RPC alive for the lifetime of the driver.
Performance, tracing, soak
See Galaxy.Performance.md for the OpenTelemetry trace map, the per-RPC metric set (galaxy.events.dropped, channel headroom, reconnect backoff distribution), and the soak-run profile.
Parity rig + gateway setup
See Galaxy.ParityRig.md and the mxaccessgw repo for the gateway worker layout and the dev-rig recipe.
2. Modbus TCP Driver
Summary
In-process polled driver for Modbus TCP devices. Flat register-based addressing with config-driven tag definitions. Supports multiple devices per driver instance.
Library & Dependencies
| Component | Package | Version | Target |
|---|---|---|---|
| NModbus | NModbus NuGet |
3.0.x | .NET Standard 2.0 |
No external runtime dependencies. Pure managed .NET.
Required Components
- None beyond network access to Modbus TCP devices (port 502)
Connection Settings (per device, from central config DB)
| Setting | Type | Default | Description |
|---|---|---|---|
Host |
string | — | Device IP address or hostname |
Port |
int | 502 | Modbus TCP port |
UnitId |
byte | 1 | Slave/unit ID (0-247) |
ConnectTimeoutMs |
int | 3000 | TCP connect timeout |
ResponseTimeoutMs |
int | 2000 | Modbus response timeout |
RetryCount |
int | 2 | Retries before marking bad |
ReconnectDelayMs |
int | 5000 | Delay before reconnect attempt |
ByteOrder |
enum | BigEndian | BigEndian, LittleEndian, MidBigEndian, MidLittleEndian |
AddressFormat |
enum | Standard | Standard (0-based decimal), DL205 (octal V/X/Y/C/T/CT notation) |
Tag Definition (from central config DB)
| Field | Type | Description |
|---|---|---|
Name |
string | Tag name (OPC UA browse name) |
FolderPath |
string | Address space hierarchy path |
RegisterArea |
enum | Coil, DiscreteInput, HoldingRegister, InputRegister |
Address |
int | 0-based register/coil address |
DataType |
enum | Boolean, Int16, UInt16, Int32, UInt32, Float32, Float64, Int64, UInt64, String |
StringLength |
int? | Character count for string tags |
AccessLevel |
enum | Read, ReadWrite |
PollGroup |
string | Poll group name for scan rate assignment |
ByteOrder |
enum? | Per-tag override (defaults to device setting) |
BitIndex |
int? | For bit-within-register access (0-15) |
ScaleGain |
double? | Linear scaling: displayed = raw * gain + offset |
ScaleOffset |
double? | Linear scaling offset |
Addressing Model
| Area | Classic Address | Protocol Address | Function Code | Size |
|---|---|---|---|---|
| Coils | 00001-09999 | 0x0000-0xFFFF | FC 01 (read), FC 05/15 (write) | 1 bit |
| Discrete Inputs | 10001-19999 | 0x0000-0xFFFF | FC 02 (read) | 1 bit |
| Input Registers | 30001-39999 | 0x0000-0xFFFF | FC 04 (read) | 16 bit |
| Holding Registers | 40001-49999 | 0x0000-0xFFFF | FC 03 (read), FC 06/16 (write) | 16 bit |
Multi-register data types (32-bit float, 64-bit double) span consecutive registers. Byte order is configurable per-device or per-tag — critical since there is no standard.
Poll Architecture
- Poll groups with configurable intervals (e.g. Fast=250ms, Medium=1000ms, Slow=5000ms)
- Block read optimization: coalesce contiguous/nearby registers into single FC 03/04 requests (max 125 registers per request)
- Gap tolerance: configurable — read through gaps of N unused registers to reduce request count
- Demand-based: only poll registers with active OPC UA MonitoredItems
Error Mapping
| Modbus Exception | OPC UA StatusCode |
|---|---|
| 0x01 Illegal Function | BadNotSupported |
| 0x02 Illegal Data Address | BadNodeIdUnknown |
| 0x03 Illegal Data Value | BadOutOfRange |
| 0x04 Slave Device Failure | BadInternalError |
| 0x0A Gateway Path Unavailable | BadNoCommunication |
| 0x0B Gateway Target Failed | BadTimeout |
| TCP timeout/disconnect | BadNoCommunication |
DL205 Compatibility (AutomationDirect)
The AutomationDirect DL205 PLC supports Modbus TCP via the H2-ECOM100 Ethernet module (port 502). Rather than a separate driver, the Modbus TCP driver supports DL205 natively via the AddressFormat = DL205 setting.
DL205 uses octal addressing. When AddressFormat is DL205, users configure tags using native DL205 notation and the driver translates to Modbus addresses:
| DL205 Address | Modbus Area | Modbus Address | Formula |
|---|---|---|---|
V2000 |
Holding Register (FC 03) | 1024 | OctalToDecimal(2000) = 1024 |
V2400 |
Holding Register (FC 03) | 1280 | OctalToDecimal(2400) = 1280 |
V7777 |
Holding Register (FC 03) | 4095 | OctalToDecimal(7777) = 4095 |
X0 |
Discrete Input (FC 02) | 0 | OctalToDecimal(0) = 0 |
X17 |
Discrete Input (FC 02) | 15 | OctalToDecimal(17) = 15 |
Y0 |
Coil (FC 01) | 0 | OctalToDecimal(0) = 0 |
Y10 |
Coil (FC 01) | 8 | OctalToDecimal(10) = 8 |
C0–C777 |
Coil (FC 01) | 512–1023 | 512 + OctalToDecimal(addr) |
T0–T377 |
Coil (FC 01) | 2048–2303 | 2048 + OctalToDecimal(addr) (status bits) |
CT0–CT177 |
Coil (FC 01) | 2560–2687 | 2560 + OctalToDecimal(addr) |
DL205-specific notes:
- 32-bit values (Float32, Int32) use low word at lower address (little-endian word order) — set
ByteOrder = LittleEndianfor 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
SafeHandlewith finalizer callingplc_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 viaGC.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
BadNotSupportedand surface as a configuration alert in the dashboard, not a transient failure. - Single connection per PLC with
SemaphoreSlimserialization 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
AmsRouterclass) - 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:
- Use
SymbolLoaderFactory.Create(client, SymbolLoaderSettings.Default)to get anISymbolLoader - Enumerate all symbols with full type information
- Build OPC UA address space from discovered symbols
- 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
0xF080index 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 catch0x0702, 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 aIRediscoverableinvocation, not as a connection error. UniversalIDriver.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) |
| Named pipes | System.IO.Pipes (BCL) |
BCL | both sides |
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.Proxyruns in the main OtOpcUa server (.NET 10 x64) and implementsIDriver+ capability interfaces, forwarding every call over named-pipe IPC to theFocas.HostWindows service.Focas.Hostis the only process that loadsFwlib64.dll. Seedriver-stability.mdfor 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_freelibhndlon 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
- Local OPC UA client subscribes to a node → gateway creates
MonitoredItemon remote server - Remote server pushes DataChange → gateway updates local variable +
ClearChangeMasks() - Reference counting: only one remote MonitoredItem per unique tag, regardless of local subscriber count
- Use
SessionReconnectHandlerfor 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.EventNotifieronObjectIds.Server) - Maintain local
AlarmConditionStateper remote alarm source - Forward Acknowledge/Confirm calls from local clients to remote server
History Forwarding
- Override
HistoryReadRawModifiedetc. 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.UtcNowmasks staleness. - Browse cache memory:
BrowseStrategy=Fullagainst a large remote server can cache tens of thousands of node descriptions. Per-instance budget should bound this; on breach, switch toLazystrategy 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
Equipmentrow 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.FolderPathmirroring 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.