# Omron PLC Driver — Executable Implementation Design **Status:** Build-ready design, 2026-07-15. Turns the research report [`docs/research/drivers/omron.md`](../research/drivers/omron.md) into a concrete implementation plan. **Kind:** Standard **Equipment-kind driver** (same shape as Modbus / S7 / AbCip / TwinCAT / FOCAS). Points are ordinary equipment `Tag`s bound to the driver via `TagConfig.FullName`, authored on the `/uns` Tags tab. No alias machinery, no bespoke namespace kind. > **Primary reuse target:** `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/` (+ `.AbCip.Contracts/`). > Omron NJ/NX speak CIP, so the CIP path reuses ~70–80% of AbCip's libplctag body. Read this doc > alongside `AbCipDriver.cs`, `AbCipDriverFactoryExtensions.cs`, `AbCipEquipmentTagParser.cs`, and > `IModbusTransport.cs` (the hand-rolled-transport template for FINS). --- ## 1. Motivation + transport decision Omron exposes **two entirely different Ethernet protocols** depending on controller generation, and they cover **disjoint hardware** — neither subsumes the other. We support **both, CIP-first.** | Transport | Port / framing | Controller families | Addressing model | Online-browsable? | |---|---|---|---|---| | **EtherNet/IP CIP** | TCP/UDP **44818**, CIP explicit messaging | **NJ / NX / NY (Sysmac)** | **Named variables** (symbolic tags), like Rockwell Logix | **No** (libplctag `@tags` returns `ErrorUnsupported` — §4) | | **FINS** | FINS/TCP + FINS/UDP on **9600** | **CJ / CS / CP / CV**, NSJ | **Memory areas** (CIO/WR/HR/AR/DM/EM) + word/bit offset | **No** (flat memory, no symbol table) | **Decision — ship CIP first, FINS second:** 1. CIP targets the modern/current Omron line (NJ/NX/NY) most likely in new installs. 2. The CIP path **reuses the already-hardened AbCip stack** (libplctag, `PollGroupEngine`, `EquipmentTagRefResolver`, resilience seam, status mapping) — fastest path to a working read/write driver. Omron NJ/NX "variables" are CIP symbolic tags reached exactly like ControlLogix, which is why libplctag already speaks it via `plc=omron-njnx`. 3. FINS is simpler as a protocol but is a **net-new transport codec** with zero reuse, targeting legacy hardware. Full protocol background + sources: [`docs/research/drivers/omron.md`](../research/drivers/omron.md) §1. --- ## 2. Project layout Two projects, mirroring the AbCip split (`Driver` + zero-dependency `Contracts` leaf). **Both transports live in one driver assembly** behind a single `DriverType = "Omron"`, discriminated by a `transport` field in config — the two transports share the `IDriver` shell, poll wiring, resilience seam, and `EquipmentTagRefResolver`, and only diverge at the wire codec. ``` src/Drivers/ ZB.MOM.WW.OtOpcUa.Driver.Omron.Contracts/ # options, enums, equipment-tag parser (NO package refs) OmronTransport.cs # enum { Cip, Fins } OmronDriverOptions.cs # OmronDriverOptions + OmronDeviceOptions + OmronTagDefinition + OmronProbeOptions OmronDataType.cs # enum (Bool/SInt/Int/DInt/LInt/USInt/UInt/UDInt/ULInt/Real/LReal/String/Dt) OmronMemoryArea.cs # enum { Cio, Wr, Hr, Ar, Dm, Em } (FINS) OmronEquipmentTagParser.cs # TagConfig-JSON → OmronTagDefinition (mirror AbCipEquipmentTagParser) ZB.MOM.WW.OtOpcUa.Driver.Omron/ # driver body OmronDriver.cs # IDriver + capability interfaces; dispatches to a transport OmronDriverFactoryExtensions.cs # Register() + ParseOptions() + config DTOs OmronDriverProbe.cs # IDriverProbe (Test Connect) — transport-aware OmronStatusMapper.cs # wire status → OPC UA StatusCode (shared) OmronHostAddress.cs # omron://gw/path and fins://ip:port/net.node.unit parsers Cip/ IOmronCipRuntime.cs # tag-runtime seam (mirror IAbCipTagRuntime) — the CI test seam LibplctagOmronRuntime.cs # libplctag.NET impl, plc=omron-njnx OmronCipTransport.cs # per-device tag-handle cache, read/write/probe Fins/ IFinsTransport.cs # framed request/response seam (mirror IModbusTransport) FinsTcpTransport.cs # hand-rolled FINS/TCP + node-address handshake FinsUdpTransport.cs # hand-rolled FINS/UDP (no handshake) FinsFrame.cs # header build/parse, 0x0101 read / 0x0102 write, area-code table FinsAreaCodeTable.cs # (OmronMemoryArea, bit?) → area code, CPU-family-parameterised ``` ### Library + license | Path | Library | License | Notes | |---|---|---|---| | **CIP** | **`libplctag.NET`** (``), attribute string `plc=omron-njnx` | native core dual MPL-2.0 / LGPL-2.1; .NET wrapper **MPL-2.0** | **Zero new license review** — the identical dependency AbCip already ships. | | **FINS** | **Hand-rolled** (no third-party dep) | our own code | FINS framing is ~200 lines, documented (W227 + Wireshark dissector) but research-sourced — trusted only after the P2 validation gate (§10); mirrors `ModbusTcpTransport`. | **Avoid `HslCommunication`.** The maintained builds are **commercial-paid**; the "MIT" claim in casual sources refers to a stale, abandoned community fork ([`HslCommunication-Community`](https://github.com/HslCommunication-Community/HslCommunication-Community)), not current releases. Use it only as a *protocol reference* to cross-check the hand-rolled FINS framing — never as a dependency. `Driver.Omron.csproj` mirrors `Driver.AbCip.csproj`: `net10.0`, `TreatWarningsAsErrors`, `ProjectReference` to `Driver.Omron.Contracts`, `Core.Abstractions`, `Core`; a single ``; `InternalsVisibleTo` the test project. `Driver.Omron.Contracts.csproj` has **NO package references** — `ProjectReference` only to the zero-dependency `Core.Abstractions` leaf (for the shared `TagConfigJson` readers), exactly like `AbCip.Contracts`. ### Shared CIP core — defer The research flags ~70–80% AbCip reuse and asks whether to extract a shared `Cip.Core`. **Decision: keep Omron self-contained in v1** (copy AbCip's tag-runtime/poll/status patterns into `Driver.Omron/Cip/` and specialise). Rationale: (a) a premature shared-core extraction couples two drivers before we know Omron's real wire deltas (string encoding, array quirks, Network-Publish); (b) Omron's tag-list enumerator is *not* shareable with Rockwell anyway (§4). **Revisit a shared `ZB.MOM.WW.OtOpcUa.Driver.Cip.Core` extraction only if/when a third CIP driver lands or the Omron CIP body proves byte-identical to AbCip's after the live gate** — track as a follow-up, not a v1 gate. The deltas from AbCip are small and localized: the `plc=omron-njnx` attribute string, Omron's data-type code set (§3), string encoding, and the browse story. --- ## 3. Capability mapping `OmronDriver` implements the same capability set as `AbCipDriver`: ```csharp public sealed class OmronDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver, IDisposable, IAsyncDisposable ``` (No `IAlarmSource` in v1 — Omron has no analog to AbCip's ALMD projection; alarms can be authored as scripted alarms on the equipment page like any flat-address driver.) `InitializeAsync` reads the parsed `OmronDriverOptions`, and **per device** selects a transport implementation by `device.Transport`: - `Cip` → `OmronCipTransport` (libplctag tag-handle cache). - `Fins` → `FinsTcpTransport` / `FinsUdpTransport` (framed socket codec). Both transports expose the same internal seam the driver body calls (`ReadAsync` / `WriteAsync` / `ProbeReadAsync`), so `OmronDriver`'s `IReadable`/`IWritable`/poll code is transport-agnostic and routes by the resolved tag's `Transport`. | Capability | CIP (NJ/NX) | FINS (CJ/CS/CP) | |---|---|---| | **`InitializeAsync`** (connect) | libplctag Forward-Open per device; attribute `protocol=ab-eip&gateway=&path=&plc=omron-njnx&name=` — via the .NET wrapper this is typed `Tag` properties (`Protocol.ab_eip` + `PlcType.Omron`), and AbCip's `LibplctagTagRuntime.MapPlcType` **already maps** `"omron-njnx" → PlcType.Omron` on the shipped libplctag 1.5.2 | open TCP `:9600` + FINS/TCP node-address handshake (cache assigned client node); UDP variant skips the handshake | | **`IReadable`** | reuse AbCip per-tag runtime: create handle → `ReadAsync` → `GetStatus` → decode | build `0x0101` Memory Area Read (area code + 3-byte address + word count), parse 2-byte end-code + payload; multi-word reads for 32/64-bit | | **`IWritable`** | full r/w; BOOL-in-word + array via AbCip's `EncodeValue`/bit-RMW paths | `0x0102` Memory Area Write; bit writes use bit-access area codes (no RMW needed — bit codes address bits directly) | | **`ISubscribable`** | **poll-based** via shared `PollGroupEngine` (no native push) | **poll-based** via `PollGroupEngine` (FINS has no subscription) | | **`ITagDiscovery`** | **authored-only** — emit pre-declared tags; `SupportsOnlineDiscovery=false` (§4). Sysmac tag-export importer feeds these tags offline (§4) | **authored-only**; `SupportsOnlineDiscovery=false` | | **`IHostConnectivityProbe`** | reuse AbCip probe-loop: cheap tag read at interval → `HostState` transitions | cheap DM-word read at interval → `HostState` transitions | The poll/subscribe wiring copies AbCip verbatim: ```csharp _poll = new PollGroupEngine( reader: ReadAsync, onChange: (h, r, snap) => OnDataChange?.Invoke(this, new DataChangeEventArgs(h, r, snap)), onError: HandlePollError, backoffCap: TimeSpan.FromSeconds(30)); // 05/STAB-8 fleet-wide cap ``` `ResolveHost` routes each reference to its device host via `EquipmentTagRefResolver` (per-host resilience isolation — a broken device B must not trip device A's breaker), copied from `AbCipDriver.ResolveHost`. ### 3.1 Data-type mapping Omron CIP (NJ/NX) uses IEC 61131 types that align with the existing `OmronDataType` (modelled on `AbCipDataType`); FINS is raw words the TagConfig must type explicitly. | `OmronDataType` | CIP (NJ/NX) native | FINS (word interpretation) | `DriverDataType` / OPC UA | |---|---|---|---| | `Bool` | BOOL | bit-area read, or bit N of a word | Boolean | | `SInt` / `USInt` | 1-byte | low byte of a word | SByte / Byte | | `Int` / `UInt` | 2-byte | 1 word | Int16 / UInt16 | | `DInt` / `UDInt` | 4-byte | 2 words (word-order matters) | Int32 / UInt32 | | `LInt` / `ULInt` | 8-byte | 4 words | Int64 / UInt64 | | `Real` | IEEE-754 32 | 2 words | Float | | `LReal` | IEEE-754 64 | 4 words | Double | | `String` | CIP string | word-packed ASCII, fixed length | String | | `Dt` | Sysmac DATE_AND_TIME/TIME | banked words | DateTime (best-effort) | Arrays: `ValueRank=1`, `ArrayDim` from `arrayLength`, mirroring AbCip's explicit `IsArray` flag (a 1-element array is still an array — `ElementCount` alone can't carry the signal). **UDT/structure scoping (CIP, v1):** structure *members* are addressed leaf-wise via a dotted `tagName` (e.g. `Conveyor.Speed` — a Sysmac structure-member path resolved symbolically by libplctag), each authored as its own typed tag. **Whole-structure reads are out of scope in v1** — `OmronDataType` deliberately has no `Structure` marker (unlike `AbCipDataType`), because AbCip's whole-UDT path rides on the Logix Template Object decode (`AbCipTemplateCache` / `CipTemplateObjectDecoder`), which is Rockwell-specific and not known to apply to Omron's structure metadata. Whether dotted member access holds for all NJ/NX structure shapes is a live-gate item (§9). **Word/byte-order caveat (FINS only):** Omron stores multi-word values with a specific word order that differs from naïve concatenation. The FINS codec exposes a **per-tag `wordSwap`** option (same class of concern S7/Modbus already handle). CIP via libplctag handles endianness internally, so no `wordSwap` on the CIP path. **Network Publish gate (CIP):** On NJ/NX a variable is only reachable over the network if the programmer set its **Network Publish** attribute (`Publish Only`) in **Sysmac Studio**. Tags without it are invisible to any external client — including our reads. Document this loudly in `docs/drivers/Omron.md`: a CIP read of an unpublished variable fails at the wire, not a config bug. **Operator documentation task (`docs/drivers/Omron.md`, P1):** alongside the Network-Publish note above, the driver doc must carry an explicit operator-facing warning that **`writable` defaults to `true` when omitted** from a tag's config (the convention inherited from AbCip's parser, §5.4). On a write-capable PLC driver, forgetting the field does not fail safe — it silently authors a writable node. Operators must set `"writable": false` explicitly on every read-only point. --- ## 4. Browse story — reconciliation with the universal browser This is the single most important finding, and it must be stated honestly against the universal discovery-browser design (`docs/plans/2026-07-15-universal-discovery-browser-design.md`). ### 4.1 Both transports set `SupportsOnlineDiscovery = false` The universal `DiscoveryDriverBrowser` (Tier 1) works by running a driver's `DiscoverAsync` against a `CapturingAddressSpaceBuilder` and re-presenting the captured tree. It is gated on `ITagDiscovery.SupportsOnlineDiscovery` — a default-`false` interface member that does **not exist yet**; the Wave-0 universal browser adds it (today `ITagDiscovery` carries only the `RediscoverPolicy` default member). That gate exactly encodes "does `DiscoverAsync` enumerate from the *device*, or merely replay pre-declared tags?" **For Omron, `DiscoverAsync` can only replay pre-declared/authored tags on BOTH transports:** - **CIP (NJ/NX):** libplctag's `@tags` walker (CIP Symbol Object class `0x6B`) — the exact mechanism AbCip uses for controller-tag enumeration — returns **`ErrorUnsupported` / `PLCTAG_ERR_NOT_FOUND` on Omron NJ/NX**. Omron exposes its published-variable list through a *different* CIP object/service than Rockwell, which libplctag has not implemented (upstream [libplctag #466](https://github.com/libplctag/libplctag/issues/466), [libplctag.NET #371](https://github.com/libplctag/libplctag.NET/issues/371)). So online CIP enumeration is **not available** the way it is on Rockwell. - **FINS:** flat memory banks, no symbol/metadata table on the wire — nothing to enumerate, ever. Therefore Omron **does not** opt into `SupportsOnlineDiscovery`. Concretely, `OmronDriver` inherits the default: ```csharp // OmronDriver: no override of SupportsOnlineDiscovery → stays false (authored replay). public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.Once; ``` Consequence per the universal-browser design §5/§7: Omron sits in the **"No — not browsable"** row alongside Modbus/S7/MELSEC/AbLegacy. The universal browser's `CanBrowse` returns false, the AdminUI renders **manual entry** (no Browse button), and there is **no bespoke `IBrowseSession` in v1.** This is the honest reconciliation: the universal browser does **not** help Omron, because Omron cannot device-enumerate. ### 4.2 The v1 browse experience — offline Sysmac tag-export importer (CIP) The lowest-risk, highest-value browse story for CIP is **offline import**, not a live browser: - NJ/NX projects export the published global-variable table from **Sysmac Studio** (CSV / tag file). - Build an **AdminUI importer** that parses this export into candidate `OmronTagDefinition`s (name + data type + array shape) and authors them as equipment tags (or fills a driver-config `Tags` list). - This needs **no online CIP enumeration** and dodges the entire libplctag-can't-list-NJ/NX problem. Placement: a small importer under the AdminUI Tags/driver surface (parse → preview → author), NOT an `IDriverBrowser`. Scoped as **Phase 3** (below). FINS has no equivalent — **manual entry only** (memory-area dropdown + word/bit offset in the typed editor). ### 4.3 Future live CIP browse (explicitly out of scope for v1) If libplctag adds Omron NJ/NX variable listing, *or* we hand-write the Omron CIP tag-list service (Rockwell-different, research-grade), a bespoke `Driver.Omron.Browser` implementing `IDriverBrowser` (`DriverType="Omron"`, CIP config only) could be added later — it would **override** the universal fallback per the two-tier resolution. It would reuse the `BrowseNode`/`IBrowseSession` plumbing but **not** the wire enumerator (Omron's tag-list service differs from Rockwell's). Not built in v1. Even then, only Network-Published variables would be visible. **The driver is fully usable without any browser** — pre-declared/imported tags always work, and unmapped types fall back to the raw-JSON TagConfig editor. Browse is convenience, not correctness. --- ## 5. TagConfig + driver-config JSON Follows the AbCip equipment-tag convention (`AbCipEquipmentTagParser`): a leading `{` marks a TagConfig blob; camelCase property names; strict enum reads via `TagConfigJson.TryReadEnumStrict` (a typo'd enum **rejects** the tag → `BadNodeIdUnknown`, never a silently-wrong Good). A `transport` discriminator distinguishes the two shapes. ### 5.1 Driver config (bound to `DriverConfig` at `DriverHost.RegisterAsync`) ```jsonc { "timeoutMs": 2000, "devices": [ { "transport": "Cip", "hostAddress": "omron://10.100.0.40/1,0", "deviceName": "NJ501-Line1" }, { "transport": "Fins", "hostAddress": "fins://10.100.0.41:9600/0.1.0", "cpuFamily": "CJ2", "deviceName": "CJ2M-Legacy" } ], "probe": { "enabled": true, "intervalMs": 5000, "timeoutMs": 2000, "probeReference": "Conveyor.Heartbeat" }, "tags": [ /* optional pre-declared tags; equipment tags are authored per-tag instead */ ] } ``` - `OmronDeviceOptions`: `Transport`, `HostAddress`, optional `DeviceName`, `CpuFamily` (FINS area-code table selector). Per-device routing keyed on `HostAddress` (via `IPerCallHostResolver`), exactly like `AbCipDeviceOptions`. - `OmronHostAddress.TryParse` accepts **both** forms: `omron://gateway[:port]/cip-path` (CIP) and `fins://ip[:port]/net.node.unit` (FINS). Malformed → device fails init (never silently connects to nothing), matching AbCip. ### 5.2 Per-tag TagConfig — CIP (NJ/NX) ```json { "transport": "Cip", "deviceHostAddress": "omron://10.100.0.40/1,0", "tagName": "Conveyor.Speed", "dataType": "Real", "isArray": false, "arrayLength": 1, "writable": true } ``` `tagName` is the Sysmac global-variable name (near-identical to AbCip's `tagPath`; CIP path reuses AbCip's `isArray`/`arrayLength`/`writable`/`dataType` semantics verbatim). ### 5.3 Per-tag TagConfig — FINS ```json { "transport": "Fins", "deviceHostAddress": "fins://10.100.0.41:9600/0.1.0", "memoryArea": "DM", "address": 100, "bit": null, "dataType": "Int", "wordSwap": false, "isArray": false, "arrayLength": 1, "writable": true } ``` - `memoryArea` ∈ `{Cio, Wr, Hr, Ar, Dm, Em}` (+ optional `emBank` for extended memory). The codec maps `(memoryArea, bit==null ? word : bit)` to the correct FINS area code via `FinsAreaCodeTable` — a **CPU-family-parameterised** table (word vs bit codes are distinct; codes vary slightly per CPU family, so the table is keyed on `device.CpuFamily`). Representative word/bit codes: CIO `0xB0`/`0x30`, WR `0xB1`/`0x31`, HR `0xB2`/`0x32`, AR `0xB3`/`0x33`, DM `0x82`/`0x02`, EM `0xA0+bank`/`0x20+bank`. - `address` = word offset; `bit` = 0–15 for bit access (`null` ⇒ word access). - `deviceHostAddress`'s `net.node.unit` triple is the FINS destination. - `wordSwap` handles Omron multi-word ordering for 32/64-bit types. ### 5.4 `DriverAttributeInfo.FullName` per transport `FullName` is the value the picker/importer commits as `TagConfig.FullName` and the driver resolves via `EquipmentTagRefResolver`. For an **equipment tag**, `FullName` **is the raw TagConfig JSON blob** (exactly as AbCip does — `AbCipEquipmentTagParser` sets `Name: reference`). The parser reads the `transport` discriminator first, then the transport-specific fields. For a **pre-declared tag**, `FullName` is the tag's `Name`. `OmronDataType.ToDriverDataType()` (extension in the driver, mirroring `AbCipDataTypeExtensions`) maps to `DriverDataType`; `SecurityClass` = `Operate` when `writable` else `ViewOnly`. **`writable` defaults to `true` when absent** — the parser inherits AbCip's convention, on both transport shapes (§5.2/§5.3). An Omron tag authored without an explicit `writable` field is therefore a **writable** node (`SecurityClass = Operate`). On a write-capable PLC driver this deserves an explicit operator-facing warning — see the operator-documentation item in §3.1. --- ## 6. Typed editor + validator Add **one typed editor per transport-aware model**, registered by the `"Omron"` DriverType: - **Model:** `src/Server/.../AdminUI/Uns/TagEditors/OmronTagConfigModel.cs` — pure `FromJson`/`ToJson`/`Validate`, preserving unknown keys via `TagConfigJson` (copy `AbCipTagConfigModel`). It holds a `Transport` enum plus the union of CIP + FINS fields; the razor shell shows CIP fields or FINS fields based on the selected `Transport`. `Validate()`: CIP ⇒ `tagName` required; FINS ⇒ `memoryArea` set + `address >= 0`. - **Editor:** `src/Server/.../AdminUI/Components/Shared/Uns/TagEditors/OmronTagConfigEditor.razor` — thin razor shell over the model (copy `AbCipTagConfigEditor`), with a transport toggle that swaps the field set (CIP: tagName + dataType + array; FINS: memoryArea + address + bit + dataType + wordSwap + array). - **Register in both maps** (one line each): - `TagConfigEditorMap.cs`: `["Omron"] = typeof(Components.Shared.Uns.TagEditors.OmronTagConfigEditor),` - `TagConfigValidator.cs`: `["Omron"] = j => OmronTagConfigModel.FromJson(j).Validate(),` ### The `JsonStringEnumConverter` enum-serialization trap (systemic — do NOT skip) Per the known systemic AdminUI bug: **all driver pages serialize enums NUMERICALLY by default, but the driver-side DTOs are string-typed** (`ParseEnum`/`TryReadEnumStrict` read enum *names*). An AdminUI-authored config with any enum field (`transport`, `dataType`, `memoryArea`, `cpuFamily`) serialized as an integer will **fault the driver** at parse. **Every Omron surface that serializes config JSON must emit enum names, not numbers:** the `OmronDriverPage.razor` config serializer and `OmronDriverProbe` attach `new JsonStringEnumConverter()` (mirror `AbCipDriverPage.razor`'s serializer options and `AbCipDriverProbe._opts`, which both already set `Converters = { new JsonStringEnumConverter() }`); `OmronTagConfigModel.ToJson` writes enum names via the AdminUI `TagConfigJson.Set` helper, exactly as `AbCipTagConfigModel.ToJson` does (no converter needed on that path). Enums serialize as their **name string**, matching the driver's strict-name enum reads. Confirm this in the live-`/run` verify — unit tests do not catch it (Blazor binding + numeric-enum serialization pass all unit tests). Also add Omron to `DriverTypePicker.razor` (`_types` list) so the New-driver card grid shows it, and add an `OmronDriverPage.razor` under `Components/Pages/Clusters/Drivers/` (copy `AbCipDriverPage.razor`) plus the `DriverEditRouter` mapping and `DriverIdentitySection`/`EquipmentTagConfigInspector` "Omron" entries where AbCip appears. --- ## 7. Factory + registration `OmronDriverFactoryExtensions` mirrors `AbCipDriverFactoryExtensions`: ```csharp public static class OmronDriverFactoryExtensions { public const string DriverTypeName = "Omron"; public static void Register(DriverFactoryRegistry registry) { ArgumentNullException.ThrowIfNull(registry); registry.Register(DriverTypeName, CreateInstance); // Tier defaults to A, matching AbCip } internal static OmronDriver CreateInstance(string driverInstanceId, string driverConfigJson) => new(ParseOptions(driverInstanceId, driverConfigJson), driverInstanceId); internal static OmronDriverOptions ParseOptions(string driverInstanceId, string driverConfigJson) { /* … */ } } ``` `ParseOptions` deserializes into config DTOs and maps to `OmronDriverOptions` — copy AbCip's `ParseEnum` (name-based, throws on unknown) and `PositiveTimeoutOrDefault` (clamps `timeoutMs: 0` to the default so a misconfigured zero can't fault every op — the R2-01 sibling hardening). `InitializeAsync` re-parses the config JSON on reinit so a changed config takes effect (copy AbCip's re-parse-in-`InitializeAsync` pattern). **Wiring (one line each):** - `DriverFactoryBootstrap.Register(...)`: `Driver.Omron.OmronDriverFactoryExtensions.Register(registry);` - `DriverFactoryBootstrap.AddOtOpcUaDriverProbes(...)`: `services.TryAddEnumerable(ServiceDescriptor.Singleton());` (+ a `using OmronProbe = Driver.Omron.OmronDriverProbe;` alias). Probes MUST be wired on admin nodes — Test Connect is an admin-pinned singleton. `OmronDriverProbe` (`IDriverProbe`, `DriverType="Omron"`) is transport-aware: for a CIP device do the two-phase AbCip probe (bare TCP `:44818` preflight → libplctag `plc=omron-njnx` Forward-Open, treat tag-not-found statuses as *reachable*); for a FINS device do a TCP `:9600` connect + a single `0x0101` DM-word read (or the node-address handshake) to confirm the endpoint speaks FINS. --- ## 8. Resilience / timeout Inherit the fleet-hardened patterns; the R2-01 frozen-peer lesson is non-negotiable for **both** transports: - **Per-op deadline on every read and write.** A frozen peer that accepts TCP but never answers must not wedge a poll. CIP: libplctag `Tag.Timeout` from `options.Timeout` (`PositiveTimeoutOrDefault` clamps a zero, as AbCip does — the libplctag setter throws on non-positive). FINS: an explicit per-call `CancellationTokenSource.CancelAfter(options.Timeout)` around every socket read/write, and a socket `ReceiveTimeout`, so an async FINS read cannot ignore the deadline (the exact gap the S7 R2-01 fix closed — async reads that ignored the socket timeout). - **Evict-on-failure parity with AbCip.** A non-zero wire status or transport exception **evicts** the cached handle/socket so the next call re-creates it (copy `EvictRuntime`). FINS closes + reopens the socket; CIP disposes + recreates the libplctag `Tag`. - **Per-device connection backoff** (`ConnectionBackoff`, 05/STAB-8): inside an open backoff window a data-path caller fails fast (no connect), reset on success — copy AbCip's `device.Backoff` usage. - **Poll backoff cap** 30 s fleet-wide; `HandlePollError` degrades health to `Degraded` preserving `LastSuccessfulRead`, never downgrading `Faulted`. - **`WriteIdempotent` flagging.** Default `false` (a pulse/counter-advance/recipe-step write must not auto-replay on a write timeout). Operators flag only tags whose semantics make replay safe (level-set holding values, analog set-points) via the tag's `writeIdempotent` field → carried into `DriverAttributeInfo.WriteIdempotent` (exactly as AbCip's pre-declared tags carry it). **Honest status of the retry gate:** the invoker seam exists (`IDriverCapabilityInvoker.ExecuteWriteAsync(hostName, isIdempotent, …)` — the non-idempotent arm forces `RetryCount = 0`), but the host's sole write dispatch (`DriverInstanceActor.HandleWriteAsync`) currently hardcodes `isIdempotent: false`; per-tag opt-in is the documented-but-unshipped forward path (see the `WriteIdempotentAttribute` remarks). So Omron carries the flag from day one, and flagged tags gain retry when that fleet-wide plumbing lands — same position as AbCip today. FINS bit/word writes to a set-point are the typical idempotent case; a FINS write to a command register is not. Health surface: `GetHealth()` returns `DriverHealth`; transitions on read/write success/failure exactly as AbCip. --- ## 9. Test fixtures The honest gap: **there is no free NJ/NX CIP simulator.** ### CIP (NJ/NX) - **Primary CI surface — hand-rolled `IOmronCipRuntime` fake** (the same seam pattern AbCip uses via `IAbCipTagRuntime`/factory injection). Drives read/write/decode/array/BOOL-in-word/status-mapping unit tests with **no hardware and no container**. This is where correctness of the driver *body* is proven. When mirroring AbCip's test structure, do **not** copy its integration-harness profile-class pattern that had the textual static-initializer-order bug (`AbServerProfile.cs`, fixed in `326b22a7`) — initialize profile constants free of cross-field static init-order dependence. - **Live gate — env-gated `Category=LiveIntegration`** against real NJ/NX hardware, skipping cleanly when the env var is absent (the pattern the HistorianGateway live suite uses). This is the **only** way to validate the real `plc=omron-njnx` libplctag wire path. **P1 live-hardware gate checklist, in order:** 1. **CIP string layout — FIRST.** Omron NJ/NX's string layout differs from Logix, and whether libplctag's `GetString` handles it correctly can only be proven on hardware. This is the likeliest wire surprise, and the `IOmronCipRuntime` fake cannot cover it — run a `String` read/write round-trip before anything else. 2. Basic read/write round-trips per `OmronDataType` over the real `plc=omron-njnx` path. 3. Network-Publish behavior (unpublished variable → wire failure, per §3.1). 4. Array reads/writes + dotted UDT-member access (§3.1's live-gate item). Env vars e.g. `OMRON_CIP_ENDPOINT` / `OMRON_CIP_TEST_TAG` / `OMRON_CIP_WRITE_SANDBOX_TAG` / `OMRON_CIP_STRING_SANDBOX_TAG`. **Flag as an infra-gated known limitation** in the driver doc + this design until an NJ/NX is on the bench. (Omron's own CX-Simulator is Windows-only + license-gated in CX-One — not a CI fixture.) ### FINS - **Primary — hand-rolled byte-level `IFinsTransport` fake** (mirror the Modbus `IModbusTransport` fake): deterministic, no container, the most valuable surface. Covers `0x0101`/`0x0102` framing, area-code table, word-swap, end-code parsing, and the FINS/TCP handshake. - **Integration fixture — open-source FINS simulators** wrapped in a Dockerfile under `tests/Drivers/.../Omron/Docker/` with the `project=lmxopcua` label, deployed to the Linux Docker host `10.100.0.35` via `lmxopcua-fix sync omron` (repo compose file is the source of truth). Both known OSS sims — [`hiroeorz/omron-fins-simulator`](https://github.com/hiroeorz/omron-fins-simulator) and [`ahmadfarisfs/fins_simulator_omron`](https://github.com/ahmadfarisfs/fins_simulator_omron) — are UDP-oriented and ship no image, so wrap them; FINS/TCP-handshake coverage stays on the hand-rolled fake. Contracts-level unit tests for `OmronEquipmentTagParser` + `OmronTagConfigModel` round-trips (strict-enum reject, unknown-key preservation, both transport shapes) run everywhere, no fixture. --- ## 10. Phasing + effort **Omron is scheduled LAST of the active driver set** — the no-free-CIP-sim gap means CIP correctness can't be proven in CI, so it should land when live NJ/NX hardware is available for the gate. | Phase | Scope | Effort | Gate | |---|---|---|---| | **P1 — Omron CIP r/w (NJ/NX)** | `Driver.Omron` + `.Contracts`, `DriverType="Omron"`, `OmronCipTransport` on libplctag `plc=omron-njnx`; Connect/Read/Write/Subscribe(poll)/Probe; authored + equipment tags; typed editor + validator; factory + registration | Low–Medium (70–80% AbCip reuse) | hand-rolled runtime fake (CI) + env-gated live gate (hardware; §9 checklist — string layout first) | | **P2 — Omron FINS r/w (CJ/CS/CP)** | `IFinsTransport` codec (TCP+UDP, `0x0101`/`0x0102`, area-code table, word-swap) behind the same `IDriver` shell; memory-area TagConfig + editor fields (no browse) | Medium (net-new codec, no dep/license risk) | byte-level fake (CI) + OSS FINS sim container + **FINS validation gate** (below) | **P2 FINS validation gate (explicit):** every FINS wire specific in this design — the area-code table, the `0x0101`/`0x0102` command framing, the FINS/TCP node-address handshake, port 9600, and the bit-write behavior — is **research-sourced with no in-repo evidence**. The hand-rolled FINS framer is therefore **gated on validation against the Omron W227 FINS reference manual plus golden request/response vectors captured from live CJ/CS/CP hardware before it is trusted**; the captured vectors become the byte-level fake's fixtures (§9). Wire specifics stay parameterised per CPU family (the `FinsAreaCodeTable` keying already hedged in §5.3), so a manual/hardware discrepancy is a table correction, not a codec rewrite. | **P3 — Sysmac tag-export importer (CIP)** | AdminUI offline importer: parse Sysmac Studio variable export → author `OmronTagDefinition`s. **Not** a live browser. | Medium | parser unit tests on real export samples | ### Top risks 1. **No free NJ/NX CIP simulator** (the headline). CIP wire correctness (string/array/UDT/ Network-Publish) is provable only on live hardware behind the env-gated `LiveIntegration` suite → **infra-gated known limitation** until an NJ/NX is on the bench. The hand-rolled fake proves the driver *body*, not the *wire* — and the NJ/NX-vs-Logix **string layout** is the likeliest wire surprise, so it is the *first* item on the P1 live-gate checklist (§9). 2. **CIP browse is not free reuse.** libplctag's `@tags` walker returns `ErrorUnsupported` on Omron NJ/NX; `SupportsOnlineDiscovery=false`, the universal browser doesn't apply, and browse ships as an offline Sysmac-export importer (P3), with online browse deferred to a later research phase. 3. **FINS wire specifics are research-sourced, not evidence-backed.** The area codes, `0x0101`/`0x0102` commands, FINS/TCP handshake, port 9600, and bit-write behavior have no in-repo precedent — the hand-rolled framer is gated on the P2 validation gate (W227 manual + golden vectors from live hardware) before it is trusted. Per-family variance in memory-area codes + multi-word ordering is contained by the CPU-family-parameterised `FinsAreaCodeTable` + per-tag `wordSwap`. 4. **Enum-serialization trap** — every config JSON surface must attach `JsonStringEnumConverter`, or AdminUI-authored Omron configs fault the driver. Live-`/run` verify catches it; unit tests don't. --- ## Sources See [`docs/research/drivers/omron.md`](../research/drivers/omron.md) §Sources for the full list (Omron W506/W227/W596 manuals, libplctag #466 / libplctag.NET #371, Wireshark FINS dissector, HslCommunication license situation, OSS FINS simulators).