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
31 KiB
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 ~70–80% of AbCip's libplctag body. Read this doc alongsideAbCipDriver.cs,AbCipDriverFactoryExtensions.cs,AbCipEquipmentTagParser.cs, andIModbusTransport.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:
- CIP targets the modern/current Omron line (NJ/NX/NY) most likely in new installs.
- 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 viaplc=omron-njnx. - 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 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:
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:
_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.
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
@tagswalker (CIP Symbol Object class0x6B) — the exact mechanism AbCip uses for controller-tag enumeration — returnsErrorUnsupported/PLCTAG_ERR_NOT_FOUNDon 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-configTagslist). - 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, optionalDeviceName,CpuFamily(FINS area-code table selector). Per-device routing keyed onHostAddress(viaIPerCallHostResolver), exactly likeAbCipDeviceOptions.OmronHostAddress.TryParseaccepts both forms:omron://gateway[:port]/cip-path(CIP) andfins://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}(+ optionalemBankfor extended memory). The codec maps(memoryArea, bit==null ? word : bit)to the correct FINS area code viaFinsAreaCodeTable— a CPU-family-parameterised table (word vs bit codes are distinct; codes vary slightly per CPU family, so the table is keyed ondevice.CpuFamily). Representative word/bit codes: CIO0xB0/0x30, WR0xB1/0x31, HR0xB2/0x32, AR0xB3/0x33, DM0x82/0x02, EM0xA0+bank/0x20+bank.address= word offset;bit= 0–15 for bit access (null⇒ word access).deviceHostAddress'snet.node.unittriple is the FINS destination.wordSwaphandles 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— pureFromJson/ToJson/Validate, preserving unknown keys viaTagConfigJson(copyAbCipTagConfigModel). It holds aTransportenum plus the union of CIP + FINS fields; the razor shell shows CIP fields or FINS fields based on the selectedTransport.Validate(): CIP ⇒tagNamerequired; FINS ⇒memoryAreaset +address >= 0. - Editor:
src/Server/.../AdminUI/Components/Shared/Uns/TagEditors/OmronTagConfigEditor.razor— thin razor shell over the model (copyAbCipTagConfigEditor), 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>());(+ ausing 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.Timeoutfromoptions.Timeout(PositiveTimeoutOrDefaultclamps a zero, as AbCip does — the libplctag setter throws on non-positive). FINS: an explicit per-callCancellationTokenSource.CancelAfter(options.Timeout)around every socket read/write, and a socketReceiveTimeout, 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 libplctagTag. - 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'sdevice.Backoffusage. - Poll backoff cap 30 s fleet-wide;
HandlePollErrordegrades health toDegradedpreservingLastSuccessfulRead, never downgradingFaulted. WriteIdempotentflagging. Defaultfalse(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'swriteIdempotentfield → carried intoDriverAttributeInfo.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 forcesRetryCount = 0), but the host's sole write dispatch (DriverInstanceActor.HandleWriteAsync) currently hardcodesisIdempotent: false; per-tag opt-in is the documented-but-unshipped forward path (see theWriteIdempotentAttributeremarks). 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
IOmronCipRuntimefake (the same seam pattern AbCip uses viaIAbCipTagRuntime/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=LiveIntegrationagainst 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 realplc=omron-njnxlibplctag 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
IFinsTransportfake (mirror the ModbusIModbusTransportfake): deterministic, no container, the most valuable surface. Covers0x0101/0x0102framing, 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 theproject=lmxopcualabel, deployed to the Linux Docker host10.100.0.35vialmxopcua-fix sync omron(repo compose file is the source of truth). Both known OSS sims —hiroeorz/omron-fins-simulatorandahmadfarisfs/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) |
| 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
- 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
LiveIntegrationsuite → infra-gated known limitation until an NJ/NX is on the bench. The hand-rolled fake proves the driver body, not the wire. - CIP browse is not free reuse. libplctag's
@tagswalker returnsErrorUnsupportedon 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. - FINS per-family variance in memory-area codes + multi-word ordering — contained by the
CPU-family-parameterised
FinsAreaCodeTable+ per-tagwordSwap. - Enum-serialization trap — every config JSON surface must attach
JsonStringEnumConverter, or AdminUI-authored Omron configs fault the driver. Live-/runverify 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).