# 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`** (`<PackageReference Include="libplctag"/>`), 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
`<PackageReference Include="libplctag"/>`; `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=<ip>&path=<cip-path>&plc=omron-njnx&name=<tag>` — 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<T>` (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<IDriverProbe, OmronProbe>());` (+ 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).
</content>
</invoke>
