docs: driver-expansion program — 8 research reports + 7 design docs, parallel-reviewed
Adds the driver-expansion program design (umbrella: universal Discover-backed browser + MTConnect, MQTT/Sparkplug B, BACnet/IP, SQL poll, Omron, Modbus RTU; MELSEC deferred) plus the per-driver research reports. All docs went through a 7-agent parallel review against the codebase before this commit. Highlights fixed in review: - universal browser: FOCAS FixedTree fills post-connect -> UntilStable settle + FixedTree.Enabled patch; MQTT reconciled to bespoke (was contradicting the program doc's SupportsOnlineDiscovery=false verdict) - modbus-rtu: SerialPort.ReadTimeout doesn't bound async BaseStream reads -> linked-CTS per-op deadline (R2-01 class); BCL enum reuse would leak System.IO.Ports into Contracts - bacnet: DiscoveryRediscoverPolicy enum name; UDP 47808 contention; live suite rewritten around unicast Who-Is + BBMD (broadcast doesn't cross VMs) - sql-poll: real tier registration via DriverFactoryRegistry.Register; blackhole gate must not docker-pause the shared central SQL Server - mqtt: Sparkplug v3.0 STATE topic form; first-in-repo proto codegen noted - omron: host hardcodes isIdempotent:false today (retry seam unshipped); v1 scopes UDTs to dotted-leaf access - mtconnect: SecurityClassification.ViewOnly; factory ParseEnum<T> pattern - program doc: both valid enum-serialization patterns; IRediscoverable is change-signal-gated; RTU P2 adds System.IO.Ports; label is host-side
This commit is contained in:
@@ -0,0 +1,524 @@
|
||||
# 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>
|
||||
Reference in New Issue
Block a user