Files
lmxopcua/docs/plans/2026-07-15-omron-driver-design.md
T
Joseph Doherty 8fc147d8d4
v2-ci / build (push) Successful in 3m14s
v2-ci / unit-tests (push) Failing after 9m13s
docs: driver-expansion program — 8 research reports + 7 design docs, parallel-reviewed
Adds the driver-expansion program design (umbrella: universal Discover-backed
browser + MTConnect, MQTT/Sparkplug B, BACnet/IP, SQL poll, Omron, Modbus RTU;
MELSEC deferred) plus the per-driver research reports.

All docs went through a 7-agent parallel review against the codebase before
this commit. Highlights fixed in review:

- universal browser: FOCAS FixedTree fills post-connect -> UntilStable settle
  + FixedTree.Enabled patch; MQTT reconciled to bespoke (was contradicting the
  program doc's SupportsOnlineDiscovery=false verdict)
- modbus-rtu: SerialPort.ReadTimeout doesn't bound async BaseStream reads ->
  linked-CTS per-op deadline (R2-01 class); BCL enum reuse would leak
  System.IO.Ports into Contracts
- bacnet: DiscoveryRediscoverPolicy enum name; UDP 47808 contention; live
  suite rewritten around unicast Who-Is + BBMD (broadcast doesn't cross VMs)
- sql-poll: real tier registration via DriverFactoryRegistry.Register;
  blackhole gate must not docker-pause the shared central SQL Server
- mqtt: Sparkplug v3.0 STATE topic form; first-in-repo proto codegen noted
- omron: host hardcodes isIdempotent:false today (retry seam unshipped);
  v1 scopes UDTs to dotted-leaf access
- mtconnect: SecurityClassification.ViewOnly; factory ParseEnum<T> pattern
- program doc: both valid enum-serialization patterns; IRediscoverable is
  change-signal-gated; RTU P2 adds System.IO.Ports; label is host-side
2026-07-15 16:40:36 -04:00

31 KiB
Raw Blame History

Omron PLC Driver — Executable Implementation Design

Status: Build-ready design, 2026-07-15. Turns the research report docs/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 Tags 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 ~7080% 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 §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, fully documented (W227 + Wireshark dissector); 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), 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 referencesProjectReference only to the zero-dependency Core.Abstractions leaf (for the shared TagConfigJson readers), exactly like AbCip.Contracts.

Shared CIP core — defer

The research flags ~7080% 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:

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:

  • CipOmronCipTransport (libplctag tag-handle cache).
  • FinsFinsTcpTransport / 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 → ReadAsyncGetStatus → 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:

_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 v1OmronDataType 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.


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, libplctag.NET #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:

// 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 OmronTagDefinitions (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)

{
  "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)

{
  "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

{
  "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 = 015 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.


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:

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.
  • 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, Network-Publish behavior, and Omron string/array/UDT quirks. Env vars e.g. OMRON_CIP_ENDPOINT / OMRON_CIP_TEST_TAG / OMRON_CIP_WRITE_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 and 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 LowMedium (7080% AbCip reuse) hand-rolled runtime fake (CI) + env-gated live gate (hardware)
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
P3 — Sysmac tag-export importer (CIP) AdminUI offline importer: parse Sysmac Studio variable export → author OmronTagDefinitions. 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.
  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 per-family variance in memory-area codes + multi-word ordering — 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 §Sources for the full list (Omron W506/W227/W596 manuals, libplctag #466 / libplctag.NET #371, Wireshark FINS dissector, HslCommunication license situation, OSS FINS simulators).