Files
lmxopcua/docs/plans/2026-07-15-omron-driver-design.md
T
Joseph Doherty fd0bec4ffe
v2-ci / build (push) Successful in 3m30s
v2-ci / unit-tests (push) Failing after 8m53s
docs(driver-expansion): resolve all open design concerns from the review pass
Every concern the 7-agent review parked is now decided and integrated:

- universal browser: in-flight capture coalescing keyed (driverType,
  config-hash) + global cap of 4 concurrent captures; CanBrowse catches a
  throwing TryCreate and defensively shuts down the throwaway instance;
  cleanup ShutdownAsync bounded at 10s (R2-01); picker Refresh = close +
  re-capture. Coalesced sessions share an immutable tree, so DisposeAsync
  drops the session's reference, not the tree.
- mtconnect: UNAVAILABLE pinned to BadNoCommunication (fleet's
  BadCommunicationError stays reserved for the driver's own transport
  failures); TIME_SERIES sampleCount flows into ArrayDim; Subscribe timeout
  bounds only the stream-start handshake; library version/TFM folded into
  the license checklist.
- mqtt: browse rebirth is an explicit DriverOperator-gated "Request rebirth"
  button (RequestRebirthAsync(scope)) - never fired by open/root/expand;
  hand-rolled reconnect loop committed for P1 (MQTTnet v5 dropped
  ManagedMqttClient); Wave-2-start library re-verification checkbox;
  implement from the Sparkplug v3.0 spec text.
- bacnet: P1 opens with a first-spike checklist (package TFM, BBMD/
  segmented-RPM/COV API smoke, unicast-I-Am fixture behavior); AdminUI-node
  browse registers a second foreign device - BBMD FD-table sizing note.
- sql-poll: split-node browse needs env parity for Sql__ConnectionStrings__
  refs on admin nodes (actionable error + session-only pasted literal);
  one-row-per-key query contract (last-wins + rate-limited warning);
  absent key -> BadNoData; operationTimeout > commandTimeout authoring rule;
  Browser->Driver.Sql SqlClient transitive on AdminUI accepted on record.
- omron: FINS framer gated on W227 + live golden vectors; CIP string layout
  is the first P1 live-gate item; AbCip harness static-init anti-pattern
  note; writable-defaults-true operator warning.
- modbus-rtu: first P1 step confirms pymodbus exposes framer=rtu on a TCP
  server (custom-script fallback otherwise).
- program doc: new cross-cutting rule - driver ctors must be connection-free
  (the universal browser's CanBrowse throwaway instances depend on it).
2026-07-15 16:54:09 -04:00

562 lines
34 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 ~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`](../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 ~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`:
```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` = 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`.
**`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 | LowMedium (7080% 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>