8fc147d8d4
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
525 lines
31 KiB
Markdown
525 lines
31 KiB
Markdown
# Omron PLC Driver — Executable Implementation Design
|
||
|
||
**Status:** Build-ready design, 2026-07-15. Turns the research report
|
||
[`docs/research/drivers/omron.md`](../research/drivers/omron.md) into a concrete implementation plan.
|
||
**Kind:** Standard **Equipment-kind driver** (same shape as Modbus / S7 / AbCip / TwinCAT / FOCAS).
|
||
Points are ordinary equipment `Tag`s bound to the driver via `TagConfig.FullName`, authored on the
|
||
`/uns` Tags tab. No alias machinery, no bespoke namespace kind.
|
||
|
||
> **Primary reuse target:** `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/` (+ `.AbCip.Contracts/`).
|
||
> Omron NJ/NX speak CIP, so the CIP path reuses ~70–80% of AbCip's libplctag body. Read this doc
|
||
> alongside `AbCipDriver.cs`, `AbCipDriverFactoryExtensions.cs`, `AbCipEquipmentTagParser.cs`, and
|
||
> `IModbusTransport.cs` (the hand-rolled-transport template for FINS).
|
||
|
||
---
|
||
|
||
## 1. Motivation + transport decision
|
||
|
||
Omron exposes **two entirely different Ethernet protocols** depending on controller generation, and
|
||
they cover **disjoint hardware** — neither subsumes the other. We support **both, CIP-first.**
|
||
|
||
| Transport | Port / framing | Controller families | Addressing model | Online-browsable? |
|
||
|---|---|---|---|---|
|
||
| **EtherNet/IP CIP** | TCP/UDP **44818**, CIP explicit messaging | **NJ / NX / NY (Sysmac)** | **Named variables** (symbolic tags), like Rockwell Logix | **No** (libplctag `@tags` returns `ErrorUnsupported` — §4) |
|
||
| **FINS** | FINS/TCP + FINS/UDP on **9600** | **CJ / CS / CP / CV**, NSJ | **Memory areas** (CIO/WR/HR/AR/DM/EM) + word/bit offset | **No** (flat memory, no symbol table) |
|
||
|
||
**Decision — ship CIP first, FINS second:**
|
||
1. CIP targets the modern/current Omron line (NJ/NX/NY) most likely in new installs.
|
||
2. The CIP path **reuses the already-hardened AbCip stack** (libplctag, `PollGroupEngine`,
|
||
`EquipmentTagRefResolver`, resilience seam, status mapping) — fastest path to a working
|
||
read/write driver. Omron NJ/NX "variables" are CIP symbolic tags reached exactly like
|
||
ControlLogix, which is why libplctag already speaks it via `plc=omron-njnx`.
|
||
3. FINS is simpler as a protocol but is a **net-new transport codec** with zero reuse, targeting
|
||
legacy hardware.
|
||
|
||
Full protocol background + sources: [`docs/research/drivers/omron.md`](../research/drivers/omron.md) §1.
|
||
|
||
---
|
||
|
||
## 2. Project layout
|
||
|
||
Two projects, mirroring the AbCip split (`Driver` + zero-dependency `Contracts` leaf). **Both
|
||
transports live in one driver assembly** behind a single `DriverType = "Omron"`, discriminated by a
|
||
`transport` field in config — the two transports share the `IDriver` shell, poll wiring, resilience
|
||
seam, and `EquipmentTagRefResolver`, and only diverge at the wire codec.
|
||
|
||
```
|
||
src/Drivers/
|
||
ZB.MOM.WW.OtOpcUa.Driver.Omron.Contracts/ # options, enums, equipment-tag parser (NO package refs)
|
||
OmronTransport.cs # enum { Cip, Fins }
|
||
OmronDriverOptions.cs # OmronDriverOptions + OmronDeviceOptions + OmronTagDefinition + OmronProbeOptions
|
||
OmronDataType.cs # enum (Bool/SInt/Int/DInt/LInt/USInt/UInt/UDInt/ULInt/Real/LReal/String/Dt)
|
||
OmronMemoryArea.cs # enum { Cio, Wr, Hr, Ar, Dm, Em } (FINS)
|
||
OmronEquipmentTagParser.cs # TagConfig-JSON → OmronTagDefinition (mirror AbCipEquipmentTagParser)
|
||
ZB.MOM.WW.OtOpcUa.Driver.Omron/ # driver body
|
||
OmronDriver.cs # IDriver + capability interfaces; dispatches to a transport
|
||
OmronDriverFactoryExtensions.cs # Register() + ParseOptions() + config DTOs
|
||
OmronDriverProbe.cs # IDriverProbe (Test Connect) — transport-aware
|
||
OmronStatusMapper.cs # wire status → OPC UA StatusCode (shared)
|
||
OmronHostAddress.cs # omron://gw/path and fins://ip:port/net.node.unit parsers
|
||
Cip/
|
||
IOmronCipRuntime.cs # tag-runtime seam (mirror IAbCipTagRuntime) — the CI test seam
|
||
LibplctagOmronRuntime.cs # libplctag.NET impl, plc=omron-njnx
|
||
OmronCipTransport.cs # per-device tag-handle cache, read/write/probe
|
||
Fins/
|
||
IFinsTransport.cs # framed request/response seam (mirror IModbusTransport)
|
||
FinsTcpTransport.cs # hand-rolled FINS/TCP + node-address handshake
|
||
FinsUdpTransport.cs # hand-rolled FINS/UDP (no handshake)
|
||
FinsFrame.cs # header build/parse, 0x0101 read / 0x0102 write, area-code table
|
||
FinsAreaCodeTable.cs # (OmronMemoryArea, bit?) → area code, CPU-family-parameterised
|
||
```
|
||
|
||
### Library + license
|
||
|
||
| Path | Library | License | Notes |
|
||
|---|---|---|---|
|
||
| **CIP** | **`libplctag.NET`** (`<PackageReference Include="libplctag"/>`), attribute string `plc=omron-njnx` | native core dual MPL-2.0 / LGPL-2.1; .NET wrapper **MPL-2.0** | **Zero new license review** — the identical dependency AbCip already ships. |
|
||
| **FINS** | **Hand-rolled** (no third-party dep) | our own code | FINS framing is ~200 lines, 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`](https://github.com/HslCommunication-Community/HslCommunication-Community)),
|
||
not current releases. Use it only as a *protocol reference* to cross-check the hand-rolled FINS
|
||
framing — never as a dependency.
|
||
|
||
`Driver.Omron.csproj` mirrors `Driver.AbCip.csproj`: `net10.0`, `TreatWarningsAsErrors`,
|
||
`ProjectReference` to `Driver.Omron.Contracts`, `Core.Abstractions`, `Core`; a single
|
||
`<PackageReference Include="libplctag"/>`; `InternalsVisibleTo` the test project.
|
||
`Driver.Omron.Contracts.csproj` has **NO package references** — `ProjectReference` only to the
|
||
zero-dependency `Core.Abstractions` leaf (for the shared `TagConfigJson` readers), exactly like
|
||
`AbCip.Contracts`.
|
||
|
||
### Shared CIP core — defer
|
||
|
||
The research flags ~70–80% AbCip reuse and asks whether to extract a shared `Cip.Core`. **Decision:
|
||
keep Omron self-contained in v1** (copy AbCip's tag-runtime/poll/status patterns into
|
||
`Driver.Omron/Cip/` and specialise). Rationale: (a) a premature shared-core extraction couples two
|
||
drivers before we know Omron's real wire deltas (string encoding, array quirks, Network-Publish); (b)
|
||
Omron's tag-list enumerator is *not* shareable with Rockwell anyway (§4). **Revisit a shared
|
||
`ZB.MOM.WW.OtOpcUa.Driver.Cip.Core` extraction only if/when a third CIP driver lands or the Omron
|
||
CIP body proves byte-identical to AbCip's after the live gate** — track as a follow-up, not a v1 gate.
|
||
The deltas from AbCip are small and localized: the `plc=omron-njnx` attribute string, Omron's data-type
|
||
code set (§3), string encoding, and the browse story.
|
||
|
||
---
|
||
|
||
## 3. Capability mapping
|
||
|
||
`OmronDriver` implements the same capability set as `AbCipDriver`:
|
||
|
||
```csharp
|
||
public sealed class OmronDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
|
||
IHostConnectivityProbe, IPerCallHostResolver, IDisposable, IAsyncDisposable
|
||
```
|
||
|
||
(No `IAlarmSource` in v1 — Omron has no analog to AbCip's ALMD projection; alarms can be authored as
|
||
scripted alarms on the equipment page like any flat-address driver.)
|
||
|
||
`InitializeAsync` reads the parsed `OmronDriverOptions`, and **per device** selects a transport
|
||
implementation by `device.Transport`:
|
||
|
||
- `Cip` → `OmronCipTransport` (libplctag tag-handle cache).
|
||
- `Fins` → `FinsTcpTransport` / `FinsUdpTransport` (framed socket codec).
|
||
|
||
Both transports expose the same internal seam the driver body calls
|
||
(`ReadAsync` / `WriteAsync` / `ProbeReadAsync`), so `OmronDriver`'s `IReadable`/`IWritable`/poll code
|
||
is transport-agnostic and routes by the resolved tag's `Transport`.
|
||
|
||
| Capability | CIP (NJ/NX) | FINS (CJ/CS/CP) |
|
||
|---|---|---|
|
||
| **`InitializeAsync`** (connect) | libplctag Forward-Open per device; attribute `protocol=ab-eip&gateway=<ip>&path=<cip-path>&plc=omron-njnx&name=<tag>` — via the .NET wrapper this is typed `Tag` properties (`Protocol.ab_eip` + `PlcType.Omron`), and AbCip's `LibplctagTagRuntime.MapPlcType` **already maps** `"omron-njnx" → PlcType.Omron` on the shipped libplctag 1.5.2 | open TCP `:9600` + FINS/TCP node-address handshake (cache assigned client node); UDP variant skips the handshake |
|
||
| **`IReadable`** | reuse AbCip per-tag runtime: create handle → `ReadAsync` → `GetStatus` → decode | build `0x0101` Memory Area Read (area code + 3-byte address + word count), parse 2-byte end-code + payload; multi-word reads for 32/64-bit |
|
||
| **`IWritable`** | full r/w; BOOL-in-word + array via AbCip's `EncodeValue`/bit-RMW paths | `0x0102` Memory Area Write; bit writes use bit-access area codes (no RMW needed — bit codes address bits directly) |
|
||
| **`ISubscribable`** | **poll-based** via shared `PollGroupEngine` (no native push) | **poll-based** via `PollGroupEngine` (FINS has no subscription) |
|
||
| **`ITagDiscovery`** | **authored-only** — emit pre-declared tags; `SupportsOnlineDiscovery=false` (§4). Sysmac tag-export importer feeds these tags offline (§4) | **authored-only**; `SupportsOnlineDiscovery=false` |
|
||
| **`IHostConnectivityProbe`** | reuse AbCip probe-loop: cheap tag read at interval → `HostState` transitions | cheap DM-word read at interval → `HostState` transitions |
|
||
|
||
The poll/subscribe wiring copies AbCip verbatim:
|
||
|
||
```csharp
|
||
_poll = new PollGroupEngine(
|
||
reader: ReadAsync,
|
||
onChange: (h, r, snap) => OnDataChange?.Invoke(this, new DataChangeEventArgs(h, r, snap)),
|
||
onError: HandlePollError,
|
||
backoffCap: TimeSpan.FromSeconds(30)); // 05/STAB-8 fleet-wide cap
|
||
```
|
||
|
||
`ResolveHost` routes each reference to its device host via `EquipmentTagRefResolver` (per-host
|
||
resilience isolation — a broken device B must not trip device A's breaker), copied from
|
||
`AbCipDriver.ResolveHost`.
|
||
|
||
### 3.1 Data-type mapping
|
||
|
||
Omron CIP (NJ/NX) uses IEC 61131 types that align with the existing `OmronDataType` (modelled on
|
||
`AbCipDataType`); FINS is raw words the TagConfig must type explicitly.
|
||
|
||
| `OmronDataType` | CIP (NJ/NX) native | FINS (word interpretation) | `DriverDataType` / OPC UA |
|
||
|---|---|---|---|
|
||
| `Bool` | BOOL | bit-area read, or bit N of a word | Boolean |
|
||
| `SInt` / `USInt` | 1-byte | low byte of a word | SByte / Byte |
|
||
| `Int` / `UInt` | 2-byte | 1 word | Int16 / UInt16 |
|
||
| `DInt` / `UDInt` | 4-byte | 2 words (word-order matters) | Int32 / UInt32 |
|
||
| `LInt` / `ULInt` | 8-byte | 4 words | Int64 / UInt64 |
|
||
| `Real` | IEEE-754 32 | 2 words | Float |
|
||
| `LReal` | IEEE-754 64 | 4 words | Double |
|
||
| `String` | CIP string | word-packed ASCII, fixed length | String |
|
||
| `Dt` | Sysmac DATE_AND_TIME/TIME | banked words | DateTime (best-effort) |
|
||
|
||
Arrays: `ValueRank=1`, `ArrayDim` from `arrayLength`, mirroring AbCip's explicit `IsArray` flag (a
|
||
1-element array is still an array — `ElementCount` alone can't carry the signal).
|
||
|
||
**UDT/structure scoping (CIP, v1):** structure *members* are addressed leaf-wise via a dotted
|
||
`tagName` (e.g. `Conveyor.Speed` — a Sysmac structure-member path resolved symbolically by
|
||
libplctag), each authored as its own typed tag. **Whole-structure reads are out of scope in v1** —
|
||
`OmronDataType` deliberately has no `Structure` marker (unlike `AbCipDataType`), because AbCip's
|
||
whole-UDT path rides on the Logix Template Object decode (`AbCipTemplateCache` /
|
||
`CipTemplateObjectDecoder`), which is Rockwell-specific and not known to apply to Omron's structure
|
||
metadata. Whether dotted member access holds for all NJ/NX structure shapes is a live-gate item
|
||
(§9).
|
||
|
||
**Word/byte-order caveat (FINS only):** Omron stores multi-word values with a specific word order
|
||
that differs from naïve concatenation. The FINS codec exposes a **per-tag `wordSwap`** option (same
|
||
class of concern S7/Modbus already handle). CIP via libplctag handles endianness internally, so no
|
||
`wordSwap` on the CIP path.
|
||
|
||
**Network Publish gate (CIP):** On NJ/NX a variable is only reachable over the network if the
|
||
programmer set its **Network Publish** attribute (`Publish Only`) in **Sysmac Studio**. Tags without
|
||
it are invisible to any external client — including our reads. Document this loudly in
|
||
`docs/drivers/Omron.md`: a CIP read of an unpublished variable fails at the wire, not a config bug.
|
||
|
||
---
|
||
|
||
## 4. Browse story — reconciliation with the universal browser
|
||
|
||
This is the single most important finding, and it must be stated honestly against the universal
|
||
discovery-browser design (`docs/plans/2026-07-15-universal-discovery-browser-design.md`).
|
||
|
||
### 4.1 Both transports set `SupportsOnlineDiscovery = false`
|
||
|
||
The universal `DiscoveryDriverBrowser` (Tier 1) works by running a driver's `DiscoverAsync` against a
|
||
`CapturingAddressSpaceBuilder` and re-presenting the captured tree. It is gated on
|
||
`ITagDiscovery.SupportsOnlineDiscovery` — a default-`false` interface member that does **not exist
|
||
yet**; the Wave-0 universal browser adds it (today `ITagDiscovery` carries only the
|
||
`RediscoverPolicy` default member). That gate exactly encodes
|
||
"does `DiscoverAsync` enumerate from the *device*, or merely replay pre-declared tags?"
|
||
|
||
**For Omron, `DiscoverAsync` can only replay pre-declared/authored tags on BOTH transports:**
|
||
|
||
- **CIP (NJ/NX):** libplctag's `@tags` walker (CIP Symbol Object class `0x6B`) — the exact mechanism
|
||
AbCip uses for controller-tag enumeration — returns **`ErrorUnsupported` / `PLCTAG_ERR_NOT_FOUND`
|
||
on Omron NJ/NX**. Omron exposes its published-variable list through a *different* CIP object/service
|
||
than Rockwell, which libplctag has not implemented (upstream
|
||
[libplctag #466](https://github.com/libplctag/libplctag/issues/466),
|
||
[libplctag.NET #371](https://github.com/libplctag/libplctag.NET/issues/371)). So online CIP
|
||
enumeration is **not available** the way it is on Rockwell.
|
||
- **FINS:** flat memory banks, no symbol/metadata table on the wire — nothing to enumerate, ever.
|
||
|
||
Therefore Omron **does not** opt into `SupportsOnlineDiscovery`. Concretely, `OmronDriver` inherits
|
||
the default:
|
||
|
||
```csharp
|
||
// OmronDriver: no override of SupportsOnlineDiscovery → stays false (authored replay).
|
||
public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.Once;
|
||
```
|
||
|
||
Consequence per the universal-browser design §5/§7: Omron sits in the **"No — not browsable"** row
|
||
alongside Modbus/S7/MELSEC/AbLegacy. The universal browser's `CanBrowse` returns false, the AdminUI
|
||
renders **manual entry** (no Browse button), and there is **no bespoke `IBrowseSession` in v1.** This
|
||
is the honest reconciliation: the universal browser does **not** help Omron, because Omron cannot
|
||
device-enumerate.
|
||
|
||
### 4.2 The v1 browse experience — offline Sysmac tag-export importer (CIP)
|
||
|
||
The lowest-risk, highest-value browse story for CIP is **offline import**, not a live browser:
|
||
|
||
- NJ/NX projects export the published global-variable table from **Sysmac Studio** (CSV / tag file).
|
||
- Build an **AdminUI importer** that parses this export into candidate `OmronTagDefinition`s (name +
|
||
data type + array shape) and authors them as equipment tags (or fills a driver-config `Tags` list).
|
||
- This needs **no online CIP enumeration** and dodges the entire libplctag-can't-list-NJ/NX problem.
|
||
|
||
Placement: a small importer under the AdminUI Tags/driver surface (parse → preview → author), NOT an
|
||
`IDriverBrowser`. Scoped as **Phase 3** (below). FINS has no equivalent — **manual entry only**
|
||
(memory-area dropdown + word/bit offset in the typed editor).
|
||
|
||
### 4.3 Future live CIP browse (explicitly out of scope for v1)
|
||
|
||
If libplctag adds Omron NJ/NX variable listing, *or* we hand-write the Omron CIP tag-list service
|
||
(Rockwell-different, research-grade), a bespoke `Driver.Omron.Browser` implementing
|
||
`IDriverBrowser` (`DriverType="Omron"`, CIP config only) could be added later — it would **override**
|
||
the universal fallback per the two-tier resolution. It would reuse the `BrowseNode`/`IBrowseSession`
|
||
plumbing but **not** the wire enumerator (Omron's tag-list service differs from Rockwell's). Not built
|
||
in v1. Even then, only Network-Published variables would be visible.
|
||
|
||
**The driver is fully usable without any browser** — pre-declared/imported tags always work, and
|
||
unmapped types fall back to the raw-JSON TagConfig editor. Browse is convenience, not correctness.
|
||
|
||
---
|
||
|
||
## 5. TagConfig + driver-config JSON
|
||
|
||
Follows the AbCip equipment-tag convention (`AbCipEquipmentTagParser`): a leading `{` marks a
|
||
TagConfig blob; camelCase property names; strict enum reads via `TagConfigJson.TryReadEnumStrict` (a
|
||
typo'd enum **rejects** the tag → `BadNodeIdUnknown`, never a silently-wrong Good). A `transport`
|
||
discriminator distinguishes the two shapes.
|
||
|
||
### 5.1 Driver config (bound to `DriverConfig` at `DriverHost.RegisterAsync`)
|
||
|
||
```jsonc
|
||
{
|
||
"timeoutMs": 2000,
|
||
"devices": [
|
||
{ "transport": "Cip", "hostAddress": "omron://10.100.0.40/1,0", "deviceName": "NJ501-Line1" },
|
||
{ "transport": "Fins", "hostAddress": "fins://10.100.0.41:9600/0.1.0", "cpuFamily": "CJ2", "deviceName": "CJ2M-Legacy" }
|
||
],
|
||
"probe": { "enabled": true, "intervalMs": 5000, "timeoutMs": 2000, "probeReference": "Conveyor.Heartbeat" },
|
||
"tags": [ /* optional pre-declared tags; equipment tags are authored per-tag instead */ ]
|
||
}
|
||
```
|
||
|
||
- `OmronDeviceOptions`: `Transport`, `HostAddress`, optional `DeviceName`, `CpuFamily` (FINS
|
||
area-code table selector). Per-device routing keyed on `HostAddress` (via `IPerCallHostResolver`),
|
||
exactly like `AbCipDeviceOptions`.
|
||
- `OmronHostAddress.TryParse` accepts **both** forms: `omron://gateway[:port]/cip-path` (CIP) and
|
||
`fins://ip[:port]/net.node.unit` (FINS). Malformed → device fails init (never silently connects to
|
||
nothing), matching AbCip.
|
||
|
||
### 5.2 Per-tag TagConfig — CIP (NJ/NX)
|
||
|
||
```json
|
||
{
|
||
"transport": "Cip",
|
||
"deviceHostAddress": "omron://10.100.0.40/1,0",
|
||
"tagName": "Conveyor.Speed",
|
||
"dataType": "Real",
|
||
"isArray": false,
|
||
"arrayLength": 1,
|
||
"writable": true
|
||
}
|
||
```
|
||
|
||
`tagName` is the Sysmac global-variable name (near-identical to AbCip's `tagPath`; CIP path reuses
|
||
AbCip's `isArray`/`arrayLength`/`writable`/`dataType` semantics verbatim).
|
||
|
||
### 5.3 Per-tag TagConfig — FINS
|
||
|
||
```json
|
||
{
|
||
"transport": "Fins",
|
||
"deviceHostAddress": "fins://10.100.0.41:9600/0.1.0",
|
||
"memoryArea": "DM",
|
||
"address": 100,
|
||
"bit": null,
|
||
"dataType": "Int",
|
||
"wordSwap": false,
|
||
"isArray": false,
|
||
"arrayLength": 1,
|
||
"writable": true
|
||
}
|
||
```
|
||
|
||
- `memoryArea` ∈ `{Cio, Wr, Hr, Ar, Dm, Em}` (+ optional `emBank` for extended memory). The codec
|
||
maps `(memoryArea, bit==null ? word : bit)` to the correct FINS area code via
|
||
`FinsAreaCodeTable` — a **CPU-family-parameterised** table (word vs bit codes are distinct; codes
|
||
vary slightly per CPU family, so the table is keyed on `device.CpuFamily`). Representative
|
||
word/bit codes: CIO `0xB0`/`0x30`, WR `0xB1`/`0x31`, HR `0xB2`/`0x32`, AR `0xB3`/`0x33`,
|
||
DM `0x82`/`0x02`, EM `0xA0+bank`/`0x20+bank`.
|
||
- `address` = word offset; `bit` = 0–15 for bit access (`null` ⇒ word access).
|
||
- `deviceHostAddress`'s `net.node.unit` triple is the FINS destination.
|
||
- `wordSwap` handles Omron multi-word ordering for 32/64-bit types.
|
||
|
||
### 5.4 `DriverAttributeInfo.FullName` per transport
|
||
|
||
`FullName` is the value the picker/importer commits as `TagConfig.FullName` and the driver resolves
|
||
via `EquipmentTagRefResolver`. For an **equipment tag**, `FullName` **is the raw TagConfig JSON blob**
|
||
(exactly as AbCip does — `AbCipEquipmentTagParser` sets `Name: reference`). The parser reads the
|
||
`transport` discriminator first, then the transport-specific fields. For a **pre-declared tag**,
|
||
`FullName` is the tag's `Name`. `OmronDataType.ToDriverDataType()` (extension in the driver, mirroring
|
||
`AbCipDataTypeExtensions`) maps to `DriverDataType`; `SecurityClass` = `Operate` when `writable` else
|
||
`ViewOnly`.
|
||
|
||
---
|
||
|
||
## 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.
|
||
- **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`](https://github.com/hiroeorz/omron-fins-simulator)
|
||
and [`ahmadfarisfs/fins_simulator_omron`](https://github.com/ahmadfarisfs/fins_simulator_omron) —
|
||
are UDP-oriented and ship no image, so wrap them; FINS/TCP-handshake coverage stays on the
|
||
hand-rolled fake.
|
||
|
||
Contracts-level unit tests for `OmronEquipmentTagParser` + `OmronTagConfigModel` round-trips
|
||
(strict-enum reject, unknown-key preservation, both transport shapes) run everywhere, no fixture.
|
||
|
||
---
|
||
|
||
## 10. Phasing + effort
|
||
|
||
**Omron is scheduled LAST of the active driver set** — the no-free-CIP-sim gap means CIP correctness
|
||
can't be proven in CI, so it should land when live NJ/NX hardware is available for the gate.
|
||
|
||
| Phase | Scope | Effort | Gate |
|
||
|---|---|---|---|
|
||
| **P1 — Omron CIP r/w (NJ/NX)** | `Driver.Omron` + `.Contracts`, `DriverType="Omron"`, `OmronCipTransport` on libplctag `plc=omron-njnx`; Connect/Read/Write/Subscribe(poll)/Probe; authored + equipment tags; typed editor + validator; factory + registration | Low–Medium (70–80% AbCip reuse) | hand-rolled runtime fake (CI) + env-gated live gate (hardware) |
|
||
| **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 `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*.
|
||
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`](../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>
|