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,294 @@
|
||||
# Existing-driver browse audit — which of the 6 no-browser drivers should get an `IDriverBrowser`
|
||||
|
||||
> **Status:** research complete, 2026-07-15. Author: browse-audit sweep.
|
||||
> **Scope:** the 6 shipped drivers with **no** `IDriverBrowser` today — AbCip, TwinCAT,
|
||||
> AbLegacy, FOCAS, S7, Modbus. (OpcUaClient + Galaxy already have browsers — see
|
||||
> `docs/plans/2026-05-28-driver-browsers-design.md`.)
|
||||
> **Question answered:** which protocols support live online discovery / symbol enumeration,
|
||||
> what the browse would return, and how a picked node maps to that driver's `TagConfig`.
|
||||
|
||||
## TL;DR
|
||||
|
||||
The single most important finding: **AbCip and TwinCAT already contain a complete, working
|
||||
symbol-enumeration stack** — but it is buried in the *runtime* driver's `DiscoverAsync`
|
||||
(gated by an `EnableControllerBrowse` option) and is **not** exposed as the interactive,
|
||||
per-click `IDriverBrowser` the AdminUI address picker consumes. Building their browsers is
|
||||
therefore mostly a *wrapping* job over code that already ships and is already unit-tested,
|
||||
not a from-scratch protocol implementation. That makes them high-value / low-effort and the
|
||||
clear top priority.
|
||||
|
||||
---
|
||||
|
||||
## 1. Verdict table
|
||||
|
||||
| Driver | Browseable? | Mechanism (protocol-native discovery) | Existing code to reuse | Recommended action |
|
||||
|---|---|---|---|---|
|
||||
| **AbCip** (ControlLogix EtherNet/IP CIP) | **Yes** | CIP **Symbol Object class 0x6B** enumerated via libplctag's `@tags` pseudo-tag; UDT members via **Template Object class 0x6C** | `IAbCipTagEnumerator` / `LibplctagTagEnumerator` + `CipSymbolObjectDecoder` + `CipTemplateObjectDecoder` + `AbCipTemplateCache` + `AbCipUdtMemberLayout` **already exist** | **Add browser — priority 1** |
|
||||
| **TwinCAT** (Beckhoff ADS) | **Yes** | ADS **symbol upload** (`ADSIGRP_SYM_UPLOAD`) via `SymbolLoaderFactory` (flat mode) | `AdsTwinCATClient.BrowseSymbolsAsync` + `TwinCATSymbolExpander` **already exist** | **Add browser — priority 2** |
|
||||
| **FOCAS** (Fanuc CNC) | **Borderline** (curated, not free-form) | Fixed FOCAS API surface: live-enumerated axes/spindles/identity + per-series valid Macro/Parameter/PMC ranges | `FocasDriver.DiscoverAsync` FixedTree + `FixedTreeCache` + `FocasCapabilityMatrix` **already exist** | **Add curated browser — priority 3 (defer)** |
|
||||
| **AbLegacy** (Rockwell PCCC / PLC-5 / SLC) | **No** | PCCC data-table is numeric (`N7:0`), no symbolic catalog; libplctag exposes no file-directory list | none (discovery = static re-advertisement of configured tags) | **Skip** |
|
||||
| **S7** (Siemens S7comm) | **No** | Symbols live in the TIA/STEP7 project, not the CPU; S7netplus exposes no symbol/block-list API | none | **Skip** |
|
||||
| **Modbus** | **No** | Flat register space; no discovery in the Modbus protocol | none | **Skip** |
|
||||
|
||||
---
|
||||
|
||||
## 2. Protocol-browseability evidence (sources)
|
||||
|
||||
- **CIP Symbol Object 0x6B** — the CIP object used to *browse* controller symbol addresses;
|
||||
Rockwell's `Get Instance Attribute List` (service 0x55) enumerates the tag table, and the
|
||||
ControlLogix tag database is accessed via ANSI-symbolic Data-Table-Read against the returned
|
||||
tag names. Some firmware/devices don't implement 0x6B, so a browse must degrade gracefully.
|
||||
(AdvancedHMI *Generic EtherNet/IP and CIP Technical Information*; scadaprotocols.com *CIP
|
||||
Object Model*; PLCS.net tag-read threads.)
|
||||
- **ADS symbol upload** — Beckhoff's documented flow reads `ADSIGRP_SYM_UPLOADINFO2` then
|
||||
`ADSIGRP_SYM_UPLOAD` (+ `ADSIGRP_SYM_DT_UPLOAD` for datatypes); the .NET `SymbolLoaderFactory`
|
||||
wraps this and yields a `DynamicSymbolsCollection` you enumerate by `InstancePath`, descending
|
||||
`SubSymbols` for struct/UDT/FB members. (Beckhoff InfoSys *Access Data via Symbol Loader* /
|
||||
*SymbolLoaderFactory.Create*; fisothemes *TwinCAT ADS in .NET* guide.)
|
||||
- **S7comm has no on-wire symbol table** — S7-300/400/1200/1500 CPUs store only compiled code;
|
||||
symbol tables + comments live in the offline TIA/STEP7 project. S7-1200/1500 *optimized* blocks
|
||||
are addressable only symbolically and only via the PLC's **OPC UA server** — classic S7comm
|
||||
GET/PUT (what S7netplus speaks) cannot browse or even read them by name. (FlowFuse *Read S7
|
||||
optimized datablocks*; Industrial Monitor Direct *S7-1500 optimized block access* + *Siemens S7
|
||||
upload missing symbols*.)
|
||||
- **PCCC is numeric** — PLC-5/SLC access is `Protected Typed Logical Read/Write` against numeric
|
||||
data-table addresses (`N7:0`, length N). There are no symbolic names on the wire and no
|
||||
standard "list the files" catalog service. (Rockwell 1785-6.8.5 *Connecting PLC-5/SLC*; Ipesoft
|
||||
*EtherNet/IP and encapsulated PCCC*.)
|
||||
|
||||
---
|
||||
|
||||
## 3. Browse designs for the **Yes / Borderline** drivers
|
||||
|
||||
The two existing browsers are the template: a sibling `*.Browser` project holding an
|
||||
`IDriverBrowser` factory + an `IBrowseSession`, registered one line in
|
||||
`src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs` (`AddAdminUI`). The
|
||||
picker razor bodies already exist per-driver under
|
||||
`src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/` — today
|
||||
`AbCipAddressPickerBody.razor`, `TwinCATAddressPickerBody.razor`, and `FOCASAddressPickerBody.razor`
|
||||
are **static manual-entry stubs**; only `OpcUaClientAddressPickerBody` and
|
||||
`GalaxyAddressPickerBody` embed the shared `DriverBrowseTree`. So "wiring the picker" is the
|
||||
same delta that was applied to those two.
|
||||
|
||||
> **Architectural note that differs from OpcUaClient/Galaxy:** for those two, the enumeration
|
||||
> logic lives *inside* the `.Browser` project. For AbCip and TwinCAT the enumeration already
|
||||
> lives in the *runtime* `Driver.*` project. The design doc's decision #4 ("keep AdminUI from
|
||||
> pulling runtime `Driver.*` SDK chains") was about not dragging heavy SDKs transitively — but
|
||||
> the AbCip/TwinCAT **browsers must connect with those exact SDKs anyway** (`libplctag`,
|
||||
> `Beckhoff.TwinCAT.Ads`), so that concern is moot here. Two clean options:
|
||||
> **(a)** the `.Browser` project references the runtime `Driver.AbCip` / `Driver.TwinCAT`
|
||||
> project directly and calls the existing enumerator (least work); or
|
||||
> **(b)** extract the enumerator + decoders into a small shared core lib both the runtime driver
|
||||
> and the browser consume (cleaner separation, more churn). Recommendation: **(a)** for the
|
||||
> first cut — the enumerator interfaces (`IAbCipTagEnumerator`, `ITwinCATClient.BrowseSymbolsAsync`)
|
||||
> are already the seam, so a later extraction to (b) is mechanical.
|
||||
|
||||
### 3.1 AbCip browser (`DriverType = "AbCip"`)
|
||||
|
||||
**Mechanism.** Open a libplctag connection from the form's `AbCipDriverOptions` JSON (the same
|
||||
shape the runtime factory + `AbCipDriverProbe` consume). For each configured device
|
||||
(`AbCipDeviceOptions.HostAddress`), read the `@tags` pseudo-tag and decode with the existing
|
||||
`CipSymbolObjectDecoder` → a stream of `AbCipDiscoveredTag { Name, ProgramScope, DataType,
|
||||
ReadOnly, IsSystemTag, ElementCount, IsArray, TemplateInstanceId }`. For a `Structure`-typed
|
||||
tag, resolve UDT members through the existing `AbCipTemplateCache` /
|
||||
`CipTemplateObjectDecoder` (class 0x6C) → `AbCipUdtShape` member list.
|
||||
|
||||
**Session semantics:**
|
||||
- `RootAsync` → one `Folder` node per device, plus a two-way split of the symbol table into
|
||||
`Controller/` (controller-scope tags, `ProgramScope == null`) and `Programs/<prog>/`
|
||||
(program-scope tags), driven off the already-decoded `ProgramScope`. Apply
|
||||
`AbCipSystemTagFilter` so `@`-prefixed infrastructure tags don't clutter the tree (parity
|
||||
with the runtime discovery filter).
|
||||
- `ExpandAsync(nodeId)` →
|
||||
- device / scope folder → its atomic tags as `Leaf` and its UDT tags as `Folder`;
|
||||
- a UDT `Folder` → its members (via the Template Object) as `Leaf`, recursing for nested
|
||||
structs (member `nodeId = "{tagPath}.{member}"`).
|
||||
- `AttributesAsync` → **empty** (AbCip has no Galaxy-style attribute side-panel; the leaf *is*
|
||||
the address). Kind is the terminating signal, same as OpcUaClient.
|
||||
|
||||
**Node → `TagConfig` mapping.** The picker commits the leaf's `NodeId` (the Logix symbolic
|
||||
path) into the AbCip TagConfig JSON authored by `AbCipTagConfigModel` / parsed by
|
||||
`AbCipEquipmentTagParser.TryParse`:
|
||||
|
||||
```json
|
||||
{ "tagPath": "Program:Main.MyTag", "dataType": "DInt",
|
||||
"deviceHostAddress": "ab://10.0.0.5/1,0",
|
||||
"isArray": false, "arrayLength": 1, "writable": true }
|
||||
```
|
||||
|
||||
The browser already knows `dataType` (from `AbCipDiscoveredTag.DataType`), `isArray` +
|
||||
`arrayLength` (from `IsArray` + `ElementCount`), and can pre-fill `writable = !ReadOnly` — so
|
||||
the pick can populate the whole config, not just the path (a nicer UX than OpcUaClient's
|
||||
path-only commit). `deviceHostAddress` is the device the tree node came from.
|
||||
|
||||
**Project layout** (mirrors `Driver.OpcUaClient.Browser`):
|
||||
```
|
||||
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Browser/
|
||||
AbCipDriverBrowser.cs (IDriverBrowser; DriverType="AbCip"; opens libplctag conn)
|
||||
AbCipBrowseSession.cs (IBrowseSession over IAbCipTagEnumerator + template cache)
|
||||
ZB.MOM.WW.OtOpcUa.Driver.AbCip.Browser.csproj
|
||||
→ refs Commons + Driver.AbCip (option a) [+ libplctag transitively]
|
||||
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Browser.Tests/ (fake enumerator, decoder golden)
|
||||
```
|
||||
|
||||
**DI line** to add in `EndpointRouteBuilderExtensions.AddAdminUI`:
|
||||
```csharp
|
||||
services.AddSingleton<IDriverBrowser, AbCipDriverBrowser>();
|
||||
```
|
||||
|
||||
**Effort: ~2–3 days.** The protocol-hard part (0x6B decode, 0x6C template decode, system-tag
|
||||
filter, UDT layout) is done and tested. Work is: JSON-open path (copy the probe's connect),
|
||||
the two-level `RootAsync`/`ExpandAsync` projection, and the picker razor (copy the OpcUaClient
|
||||
tree wiring). Live-gate against the `abcip`/`controllogix` docker fixture on `10.100.0.35:44818`.
|
||||
|
||||
### 3.2 TwinCAT browser (`DriverType = "TwinCAT"`)
|
||||
|
||||
**Mechanism.** Open an `AdsClient` from the form's `TwinCATDriverOptions` JSON (same shape the
|
||||
runtime consumes), then call the **already-implemented**
|
||||
`AdsTwinCATClient.BrowseSymbolsAsync` → `IAsyncEnumerable<TwinCATDiscoveredSymbol>`. That method
|
||||
builds a `SymbolLoaderFactory.Create(_client, SymbolsLoadMode.Flat)` loader and runs the pure
|
||||
`TwinCATSymbolExpander.ExpandLeaves` to descend struct/UDT/FB instances to atomic members
|
||||
(deduped by `InstancePath`). Each yielded symbol already carries the mapped `TwinCATDataType`,
|
||||
optional array length, and read-only flag.
|
||||
|
||||
**Session semantics:**
|
||||
- `RootAsync` → one `Folder` per device; under it the top-level PLC scopes as folders
|
||||
(`Constants`, `MAIN`, `GVL`, `Global_Version`, `TwinCAT_SystemInfoVarList`, …), synthesised
|
||||
by splitting each symbol's dotted `InstancePath` on the first segment. Apply
|
||||
`TwinCATSystemSymbolFilter` to hide the infrastructure symbol lists (parity with runtime
|
||||
discovery).
|
||||
- `ExpandAsync(nodeId)` → children at the next `InstancePath` depth: atomic symbols as `Leaf`,
|
||||
structs/FB instances as `Folder`. Because `BrowseSymbolsAsync` yields the fully-expanded leaf
|
||||
set, the session can build the whole path→children index once on open and serve expands from
|
||||
memory (no per-click wire call — the symbol blob is downloaded once by the loader).
|
||||
- `AttributesAsync` → **empty** (leaf is the address).
|
||||
|
||||
**Node → `TagConfig` mapping.** Commit the leaf's `InstancePath` as `symbolPath` in the TwinCAT
|
||||
TagConfig JSON (`TwinCATTagDefinition`: `symbolPath`, `dataType`, `deviceHostAddress`,
|
||||
`arrayLength`, `writable`). The browser knows `dataType` + `arrayLength` + `writable`
|
||||
(`!ReadOnly`) from the discovered symbol, so it pre-fills the full config.
|
||||
|
||||
**Two live caveats (already flagged in `AdsTwinCATClient`):**
|
||||
1. **AMS router.** The AdminUI host process needs a reachable AMS router to browse — on Windows
|
||||
from TwinCAT XAR, elsewhere from the `Beckhoff.TwinCAT.Ads.TcpRouter` package hosted in-proc.
|
||||
`Beckhoff.TwinCAT.Ads` **7.0.172** is cross-platform-capable (netstandard), so this runs on
|
||||
the Linux/macOS AdminUI host *if* a router is wired — but that is a deployment prerequisite to
|
||||
document, not a code change.
|
||||
2. **Flat-mode `SubSymbols`.** The code comment warns that on some TC3 firmware, `Flat` mode may
|
||||
not populate `SubSymbols` for struct/UDT/FB symbols, silently dropping members; the documented
|
||||
fallback is `SymbolsLoadMode.VirtualTree`. This must be **live-gated on a real TC3 target**
|
||||
before trusting struct expansion.
|
||||
|
||||
**Project layout:** `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Browser/`
|
||||
(`TwinCATDriverBrowser` + `TwinCATBrowseSession`, refs Commons + Driver.TwinCAT).
|
||||
**DI line:** `services.AddSingleton<IDriverBrowser, TwinCATDriverBrowser>();`
|
||||
|
||||
**Effort: ~3–4 days** + a live-gate session on a real TC3 PLC for the `SubSymbols`/router risk.
|
||||
The enumeration + expansion + type-mapping are done and unit-tested; work is the JSON-open
|
||||
path, the path→tree projection, the picker razor, and the live validation.
|
||||
|
||||
### 3.3 FOCAS browser (`DriverType = "FOCAS"`) — curated, borderline
|
||||
|
||||
FOCAS has **no free-form symbol tree** — it's a fixed API surface. But the driver already does a
|
||||
**live protocol-native discovery**: `FocasDriver.DiscoverAsync` (FixedTree) live-reads
|
||||
`cnc_sysinfo` / `cnc_rdaxisname` / `cnc_rdspdlname` at session init (cached in `FixedTreeCache`)
|
||||
to enumerate the machine's actual axes, spindles, identity, timers, program info, and status.
|
||||
Separately, `FocasCapabilityMatrix` encodes the *valid ranges* of Macro variables, CNC
|
||||
Parameters, and PMC letter-areas per `FocasCncSeries` (static tables, not live-queried).
|
||||
|
||||
**A curated browse would return:**
|
||||
- `RootAsync` → one `Folder` per device, then category folders: `Identity`, `Axes`, `Spindles`,
|
||||
`Program`, `Timers`, `Status` (populated live from `FixedTreeCache`), plus `Macros`,
|
||||
`Parameters`, `PMC` (populated from `FocasCapabilityMatrix` for the device's declared series).
|
||||
- `ExpandAsync` → e.g. `Axes/` → the live axis names as folders → per-axis `Leaf`s
|
||||
(`AbsolutePosition`, `MachinePosition`, `ServoLoad`, …); `Macros/` → a `Leaf` per valid macro
|
||||
number in the series range (or a paged range picker for the large ranges); `PMC/R` → valid R
|
||||
addresses.
|
||||
- `AttributesAsync` → **empty** (leaf is the address).
|
||||
|
||||
**Node → `TagConfig` mapping.** Commit the canonical FOCAS address string (`X0.0`, `R100`,
|
||||
`PARAM:1815/0`, `MACRO:500`) as `address` in the FOCAS TagConfig, with `dataType` pre-filled
|
||||
from the category (positions → Float64, macros → Float64, PMC bit → Bit, etc.).
|
||||
|
||||
**Effort: ~3–5 days**, medium value (curated list, not the "discover unknown tags" win that
|
||||
AbCip/TwinCAT give). Reuses `FixedTreeCache` + `FocasCapabilityMatrix`. **Defer** behind AbCip
|
||||
and TwinCAT.
|
||||
|
||||
---
|
||||
|
||||
## 4. Justification for the **No** drivers
|
||||
|
||||
### AbLegacy (PCCC / PLC-5 / SLC) — No
|
||||
PCCC addresses are opaque numeric data-table references (`N7:0`, `F8:0`, `B3:0/5`) with **no
|
||||
symbolic names on the wire** and **no standard catalog service**. libplctag (the driver's client,
|
||||
`libplctag` 1.5.2) exposes no PCCC data-table-file directory listing. The driver's `DiscoverAsync`
|
||||
merely re-advertises the statically configured `_options.Tags` — zero live reads. The only
|
||||
"browse" one could build is blind range-probing (try reading `N7:0`, `N9:0`, …, infer existence
|
||||
from status codes) which is slow, unreliable, and enumerates addresses not meaningful names — no
|
||||
better than the existing manual `N7:0`-style entry. **Skip.**
|
||||
|
||||
### S7 (S7comm) — No
|
||||
The Siemens CPU holds only compiled code; the symbol table lives exclusively in the offline
|
||||
TIA/STEP7 project. Classic S7comm (GET/PUT — what `S7netplus` 0.20.0 speaks) has no symbol-browse
|
||||
service, and `S7netplus` exposes no block-list/SZL API. S7-1200/1500 *optimized* blocks are
|
||||
reachable **only** symbolically via the CPU's own OPC UA server — which is precisely the
|
||||
`OpcUaClient` driver's job, not S7comm's. (An operator who wants to browse an S7-1500 optimized
|
||||
DB should point the **OpcUaClient** driver at the PLC's OPC UA server, where the existing
|
||||
OpcUaClient browser already works.) The driver's own `S7DriverOptions.Tags` doc-comment states it
|
||||
plainly: "S7 has a symbol-table protocol but S7.Net does not expose it, so the driver operates off
|
||||
a static tag list." **Skip.**
|
||||
|
||||
### Modbus — No
|
||||
Modbus is a flat, typeless register/coil space (`Coils`, `DiscreteInputs`, `InputRegisters`,
|
||||
`HoldingRegisters`) with **no discovery mechanism in the protocol at all** — a device cannot tell
|
||||
you which registers exist or what they mean. The transport is hand-rolled (`ModbusTcpTransport`,
|
||||
no NuGet). The driver's own option doc-comment says it: "Modbus has no discovery protocol — the
|
||||
driver returns exactly these." **Skip.** (The existing `ModbusAddressPickerBody` region+address
|
||||
builder is the right and only tool here.)
|
||||
|
||||
---
|
||||
|
||||
## 5. Priority ordering, shareable code, effort
|
||||
|
||||
**Build order (value × effort):**
|
||||
|
||||
| Priority | Browser | Value | Effort | Why this order |
|
||||
|---|---|---|---|---|
|
||||
| **1** | **AbCip** | High (ControlLogix ubiquity; symbolic tag browse is the flagship UX) | **~2–3 days** | Full 0x6B + 0x6C decode stack already ships and is tested; lowest effort, highest payoff |
|
||||
| **2** | **TwinCAT** | High (ADS symbol upload is complete + rich) | **~3–4 days** (+ TC3 live-gate) | `BrowseSymbolsAsync` already ships; small residual risk (Flat `SubSymbols`, AMS router) needs live validation |
|
||||
| **3** | **FOCAS** | Medium (curated, not free-form) | **~3–5 days** | Reuses FixedTree + capability matrix, but lower discovery value; defer |
|
||||
| — | AbLegacy / S7 / Modbus | — | — | No browser (documented above) |
|
||||
|
||||
**Shareable-code opportunities:**
|
||||
- **AbCip → future Omron / generic-EtherNet/IP browser.** The CIP browse core
|
||||
(`CipSymbolObjectDecoder` 0x6B + `CipTemplateObjectDecoder` 0x6C + `AbCipSystemTagFilter` +
|
||||
`AbCipUdtMemberLayout`) is protocol-CIP, not Rockwell-specific. If a second CIP-family driver
|
||||
ever lands (Omron NX/NJ also speak CIP tag access), extracting that core into a shared
|
||||
`ZB.MOM.WW.OtOpcUa.Cip.Core` lib (design-doc option **b**) would let both browsers share the
|
||||
decode path. Not worth doing pre-emptively for one consumer — flag it as the natural refactor
|
||||
point when/if the second CIP driver appears.
|
||||
- **AbCip + TwinCAT both already put their enumerator behind an interface**
|
||||
(`IAbCipTagEnumerator`, `ITwinCATClient.BrowseSymbolsAsync`) — that interface *is* the reuse
|
||||
seam between runtime `DiscoverAsync` and the new browser session. No new abstraction needed.
|
||||
- Nothing shareable across the No drivers (they have no discovery to share).
|
||||
|
||||
**Net recommendation:** build **AbCip** then **TwinCAT** browsers now (both are wrap-existing-code
|
||||
jobs, ~1 week combined); **defer FOCAS** (curated, medium value); **do not build** AbLegacy, S7,
|
||||
or Modbus browsers — their protocols have no online discovery and the existing manual-entry
|
||||
pickers are the correct surface.
|
||||
|
||||
---
|
||||
|
||||
## Appendix — key source paths
|
||||
|
||||
- Contracts: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/{IDriverBrowser,IBrowseSession,BrowseNode}.cs`
|
||||
- Templates: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/`, `…Driver.Galaxy.Browser/`
|
||||
- DI: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs` (`AddAdminUI`, lines 44–50)
|
||||
- Picker bodies: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/*AddressPickerBody.razor`
|
||||
- **AbCip** enumeration: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/{IAbCipTagEnumerator,LibplctagTagEnumerator,CipSymbolObjectDecoder,CipTemplateObjectDecoder,AbCipTemplateCache,AbCipUdtMemberLayout,AbCipSystemTagFilter}.cs`; TagConfig: `…Driver.AbCip.Contracts/{AbCipDriverOptions,AbCipEquipmentTagParser}.cs`; browse gate `AbCipDriverOptions.EnableControllerBrowse`
|
||||
- **TwinCAT** enumeration: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/{AdsTwinCATClient (BrowseSymbolsAsync),TwinCATSymbolExpander,TwinCATSystemSymbolFilter}.cs`; TagConfig: `…Driver.TwinCAT.Contracts/{TwinCATDriverOptions,TwinCATEquipmentTagParser}.cs`; browse gate `TwinCATDriverOptions.EnableControllerBrowse`
|
||||
- **FOCAS**: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/{FocasDriver (DiscoverAsync/FixedTree),FocasCapabilityMatrix,IFocasClient}.cs`; TagConfig: `…Driver.FOCAS.Contracts/FocasDriverOptions.cs`
|
||||
- **AbLegacy**: `…Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs`; `…Driver.AbLegacy/AbLegacyDriver.cs` (`DiscoverAsync` = static re-advertise)
|
||||
- **S7**: `…Driver.S7.Contracts/S7DriverOptions.cs` (see `Tags` doc-comment)
|
||||
- **Modbus**: `…Driver.Modbus.Contracts/ModbusDriverOptions.cs` (see `Tags` doc-comment); `…Driver.Modbus/ModbusTcpTransport.cs`
|
||||
- Package versions (`Directory.Packages.props`): `libplctag` 1.5.2, `Beckhoff.TwinCAT.Ads` 7.0.172, `S7netplus` 0.20.0
|
||||
@@ -0,0 +1,151 @@
|
||||
# Next-driver research — index & consolidated roadmap
|
||||
|
||||
> **Overall program design:** [`docs/plans/2026-07-15-driver-expansion-program-design.md`](../../plans/2026-07-15-driver-expansion-program-design.md)
|
||||
> is the authoritative umbrella — shared architecture, cross-cutting rules, the browse-tier
|
||||
> decision, the dependency graph, and the wave build order. Start there; this file is the
|
||||
> research/design index it points back to.
|
||||
|
||||
Research sweep run 2026-07-15 (8 parallel agents). Each per-driver report covers
|
||||
protocol + .NET library options (with license analysis), capability mapping to the
|
||||
Equipment-kind driver seams, `TagConfig` JSON shape, a **browseability verdict +
|
||||
`IDriverBrowser`/`IBrowseSession` design where browsable**, test-fixture strategy,
|
||||
and effort/phasing. This index synthesizes them.
|
||||
|
||||
Scope decided with the user (2026-07-15): 7 new drivers + a browse-support audit of
|
||||
the existing driver fleet. See `[[project_next_driver_roadmap]]` memory.
|
||||
|
||||
## Reports
|
||||
|
||||
| Report | Driver |
|
||||
|---|---|
|
||||
| [`mtconnect-agent.md`](mtconnect-agent.md) | MTConnect (Agent-first) |
|
||||
| [`mqtt-sparkplug.md`](mqtt-sparkplug.md) | MQTT / Sparkplug B |
|
||||
| [`melsec-slmp.md`](melsec-slmp.md) | Mitsubishi MELSEC SLMP / MC Protocol |
|
||||
| [`omron.md`](omron.md) | Omron (CIP + FINS) |
|
||||
| [`bacnet-ip.md`](bacnet-ip.md) | BACnet/IP |
|
||||
| [`sql-poll.md`](sql-poll.md) | SQL poll |
|
||||
| [`modbus-rtu.md`](modbus-rtu.md) | Modbus RTU (extend existing Modbus) |
|
||||
| [`00-existing-driver-browse-audit.md`](00-existing-driver-browse-audit.md) | Browse audit of the 6 shipped browser-less drivers |
|
||||
|
||||
## Design docs (executable — built from the reports 2026-07-15)
|
||||
|
||||
Each non-deferred driver now has a build-ready design under `docs/plans/`:
|
||||
|
||||
| Driver | Design doc | Browse integration |
|
||||
|---|---|---|
|
||||
| Universal browser (Wave 0) | [`2026-07-15-universal-discovery-browser-design.md`](../../plans/2026-07-15-universal-discovery-browser-design.md) | — (the mechanism itself) |
|
||||
| MTConnect Agent | [`2026-07-15-mtconnect-driver-design.md`](../../plans/2026-07-15-mtconnect-driver-design.md) | **Universal** (`SupportsOnlineDiscovery`) |
|
||||
| BACnet/IP | [`2026-07-15-bacnet-ip-driver-design.md`](../../plans/2026-07-15-bacnet-ip-driver-design.md) | **Universal** (small sites); bespoke-lazy P-later |
|
||||
| MQTT/Sparkplug B | [`2026-07-15-mqtt-sparkplug-driver-design.md`](../../plans/2026-07-15-mqtt-sparkplug-driver-design.md) | **Bespoke** observation browser |
|
||||
| SQL poll | [`2026-07-15-sql-poll-driver-design.md`](../../plans/2026-07-15-sql-poll-driver-design.md) | **Bespoke** schema browser |
|
||||
| Omron | [`2026-07-15-omron-driver-design.md`](../../plans/2026-07-15-omron-driver-design.md) | None — offline Sysmac tag-export import |
|
||||
| Modbus RTU | [`2026-07-15-modbus-rtu-driver-design.md`](../../plans/2026-07-15-modbus-rtu-driver-design.md) | None (flat registers) — extends existing Modbus |
|
||||
|
||||
Browse split confirmed by the designs: **universal** covers MTConnect + BACnet (from `DiscoverAsync`);
|
||||
**bespoke** required for MQTT (passive observation window) and SQL (schema walk ≠ discovery);
|
||||
**Omron/Modbus-RTU** don't browse. All designs consume the universal browser's P1 addition of
|
||||
`ITagDiscovery.SupportsOnlineDiscovery` — build the universal browser first.
|
||||
|
||||
## New drivers — at a glance
|
||||
|
||||
| Driver | Library (license) | Read/Write | Browsable | Write in v1 | Effort |
|
||||
|---|---|---|---|---|---|
|
||||
| **MTConnect Agent** | TrakHound MTConnect.NET (MIT) | Read-only | **Yes** — `/probe` device model | No (telemetry std) | S–M (~1–1.5 wk) |
|
||||
| **MQTT / Sparkplug B** | MQTTnet v5 (MIT) + SparkplugNet (MIT, net10 risk) or hand-rolled Tahu | Read + subscribe | **Yes** — birth certs / topic tree (passive, time-bounded) | No | M |
|
||||
| **MELSEC SLMP** _(deferred)_ | Hand-roll 3E-binary framer (HslCommunication is a commercial-license blocker) | Read/Write | No — flat device memory | Yes | M (addressing) |
|
||||
| **Omron** | libplctag.NET (MPL-2.0) for CIP + hand-rolled FINS | Read/Write | CIP conditional (net-new) / FINS no | Yes | M–L (no free sim) |
|
||||
| **BACnet/IP** | System.IO.BACnet / ela-compil (MIT, net10 TFM) | Read + COV subscribe | **Yes** — Who-Is + object-list | No (write P3) | M–L |
|
||||
| **SQL poll** | Microsoft.Data.SqlClient (already in-repo) | Read-only | **Yes** — INFORMATION_SCHEMA | No (write later) | S–M |
|
||||
| **Modbus RTU** | Extend existing Modbus (new `IModbusTransport`) | Read/Write | No — flat registers | Yes | **S (lowest)** |
|
||||
|
||||
Notes:
|
||||
- **All fit the existing all-.NET-10 Equipment-kind model** — no COM gateway.
|
||||
- **MELSEC**: `HslCommunication` is a hard commercial-license blocker; the clean-license
|
||||
alternatives (McpX/MIT, McProtocol/LGPL) are too immature → hand-roll, patterned on S7.
|
||||
Repo already has `MelsecAddress`/`MelsecFamily` (from the Modbus-path work) to port.
|
||||
- **Omron CIP** reuses ~70–80% of AbCip's libplctag body, **but** libplctag's `@tags`
|
||||
walker returns `ErrorUnsupported` on Omron NJ/NX — CIP browse is net-new Omron work
|
||||
gated on the Sysmac "Network Publish" attribute, not free reuse.
|
||||
- **MQTT/Sparkplug** & **BACnet** discovery is **passive/time-bounded** (listen/broadcast
|
||||
over a window), unlike a synchronous OPC UA browse — the browse session handles this
|
||||
with an observation window (+ Sparkplug rebirth NCMD to force near-synchronous enum).
|
||||
- Every new driver needs a typed tag editor (`TagConfigEditorMap`+`TagConfigValidator`)
|
||||
and must apply `JsonStringEnumConverter` on enum config fields (the known
|
||||
`[[project_driver_enum_serialization_bug]]` trap).
|
||||
|
||||
## Existing-driver browse audit — verdicts
|
||||
|
||||
| Driver | Browsable? | Mechanism | Action |
|
||||
|---|---|---|---|
|
||||
| **AbCip** (ControlLogix EtherNet/IP) | **Yes** | CIP Symbol Object 0x6B + Template 0x6C — decode stack already ships | **Add browser** (~2–3 d) |
|
||||
| **TwinCAT** (Beckhoff ADS) | **Yes** | ADS symbol upload (`SymbolLoaderFactory`) — enumerator already ships | **Add browser** (~3–4 d) |
|
||||
| **FOCAS** (Fanuc CNC) | Borderline | Curated, not free-form (axes/spindles + macro/param/PMC ranges) | Curated browser, **deferred** (~3–5 d) |
|
||||
| **AbLegacy** (PCCC) | No | Numeric addresses (`N7:0`), no symbolic catalog | Skip (documented) |
|
||||
| **S7** (S7comm) | No | Symbols live in the TIA project, not the CPU | Skip (use OpcUaClient for optimized DBs) |
|
||||
| **Modbus** (TCP/RTU) | No | Flat register space, no discovery | Skip (documented) |
|
||||
|
||||
**Key finding:** AbCip & TwinCAT already contain complete, unit-tested symbol-enumeration
|
||||
logic buried in the runtime driver's `DiscoverAsync` (gated by `EnableControllerBrowse` /
|
||||
Flat-mode) and simply not surfaced as the interactive `IDriverBrowser` the AdminUI picker
|
||||
consumes. Their browsers are a **wrapping job over existing code**, not new protocol work —
|
||||
the razor picker bodies already exist as static manual-entry stubs.
|
||||
|
||||
**Shareable code:** the CIP browse core (0x6B/0x6C decoders + system-tag filter + UDT
|
||||
layout) is protocol-CIP, not Rockwell-specific — extract a shared `Cip.Core` lib **only
|
||||
if/when** Omron CIP browse lands (don't pre-abstract). Both AbCip & TwinCAT already expose
|
||||
their enumerator behind an interface = the ready-made reuse seam.
|
||||
|
||||
## Unified browse-support picture
|
||||
|
||||
**Browsers to build (net + existing), by protocol richness:**
|
||||
- Synchronous, model-rich: **AbCip**, **TwinCAT**, **MTConnect** (`/probe`), **SQL** (schema), **BACnet** (object-list), **Omron-CIP**.
|
||||
- Passive/time-bounded: **MQTT/Sparkplug** (observation window / rebirth).
|
||||
- Curated/fixed surface: **FOCAS** (deferred).
|
||||
|
||||
**Not browsable — documented, decision recorded so it isn't revisited:**
|
||||
MELSEC SLMP · Modbus TCP · Modbus RTU · S7 · AbLegacy · Omron-FINS (all flat address spaces / no on-wire symbol table).
|
||||
|
||||
## Recommended build order (value × effort)
|
||||
|
||||
**Wave 0 — universal Discover-backed browser (the foundational browse piece):**
|
||||
1. **Universal `DiscoveryDriverBrowser`** — ONE generic `IDriverBrowser` that runs any
|
||||
discovery-capable driver's `ITagDiscovery.DiscoverAsync` against a capturing
|
||||
`IAddressSpaceBuilder` and serves the captured tree back through the picker. Lights up
|
||||
AbCip, TwinCAT, FOCAS, MTConnect, BACnet (small sites), authored-MQTT, and every future
|
||||
discovery driver's picker with **zero per-driver browser code**. Gated on a new
|
||||
online-discovery capability flag so flat-address drivers stay manual-entry. Design:
|
||||
[`docs/plans/2026-07-15-universal-discovery-browser-design.md`](../../plans/2026-07-15-universal-discovery-browser-design.md).
|
||||
2. **AbCip / TwinCAT** — covered *immediately* by the universal browser (their enumeration
|
||||
already lives in `DiscoverAsync`). Graduate each to a **bespoke lazy `IBrowseSession`**
|
||||
only if a real controller's symbol set is too large for the universal browser's eager
|
||||
one-shot discovery (UDT drill-down / per-click expand). Not up-front work anymore.
|
||||
|
||||
**Wave 1 — low-effort, high-leverage new drivers:**
|
||||
3. **Modbus RTU** — lowest-effort new capability; extends the existing driver via one new `IModbusTransport` (lead with RTU-over-TCP for the containerized server).
|
||||
4. **SQL poll** — zero new NuGet deps (SqlClient already in-repo), broad MES/ERP glue value, browsable.
|
||||
|
||||
**Wave 2 — strategic new drivers:**
|
||||
5. **MTConnect Agent** — MIT library, clean browse, complements FOCAS with vendor-neutral machine-tool telemetry.
|
||||
6. **MQTT / Sparkplug B** — UNS-strategic; plain-MQTT first to stand up the skeleton + validate MQTTnet/net10, then Sparkplug birth-driven discovery.
|
||||
|
||||
**Wave 3 — heavier / test-gated:**
|
||||
7. **BACnet/IP** — opens facilities/HVAC/energy; COV + discovery + BBMD are the meaty parts (live-gate the broadcast reachability).
|
||||
8. **Omron** — CIP-first (libplctag) then FINS; last of the active set because there's **no free NJ/NX CIP simulator**, so real-wire correctness is a live-hardware `LiveIntegration` gate.
|
||||
|
||||
**Deferred (research complete, not scheduled):**
|
||||
- **MELSEC SLMP** — deferred (2026-07-15). The mature library is a commercial-license
|
||||
blocker so it's a full hand-rolled 3E-binary framer with the hardest addressing model
|
||||
on the roadmap (device-code × bit/word × hex/octal/decimal × word order); revisit when
|
||||
a Mitsubishi-shop customer need makes it worth the build. Report stays valid as the
|
||||
starting point.
|
||||
|
||||
FOCAS browser slots opportunistically whenever the CNC surface is being touched.
|
||||
|
||||
## Per-driver add pattern (reference)
|
||||
|
||||
`*.Contracts` project + driver impl (Connect/Discover/Read/Subscribe[/Write]) + factory +
|
||||
`DriverType` string + typed tag editor (`TagConfigEditorMap`+`TagConfigValidator`) +
|
||||
optional `*.Browser` project (register one line at
|
||||
`EndpointRouteBuilderExtensions` AddAdminUI DI) + docker fixture (`project=lmxopcua`
|
||||
label). Watch the enum-serialization trap and add a per-query/-op timeout deadline
|
||||
(the R2-01 frozen-peer lesson) on every network call.
|
||||
@@ -0,0 +1,356 @@
|
||||
# BACnet/IP driver — research & implementation design
|
||||
|
||||
**Status:** Research / design proposal (not yet implemented)
|
||||
**Author:** research sweep, 2026-07-15
|
||||
**Scope:** A new **Equipment-kind** driver `ZB.MOM.WW.OtOpcUa.Driver.Bacnet` exposing
|
||||
BACnet/IP facility/HVAC/energy data under the unified namespace, following the same shape as
|
||||
Modbus / S7 / AbCip / OpcUaClient. BACnet is the closest analog to the **OpcUaClient** driver:
|
||||
it has native **device discovery** and native **change-of-value push subscriptions**, so it is a
|
||||
first-class *browseable* + *subscribe-capable* driver, not a bare poller.
|
||||
|
||||
---
|
||||
|
||||
## 1. Protocol summary + .NET library options
|
||||
|
||||
### 1.1 BACnet/IP in one screen
|
||||
|
||||
BACnet (ASHRAE Standard 135 / ISO 16484-5) is the dominant building-automation protocol
|
||||
(HVAC, lighting, energy metering, access control). **BACnet/IP** carries BACnet over UDP,
|
||||
default port **47808 (0xBAC0)**, with a layered framing:
|
||||
|
||||
- **BVLC** (BACnet Virtual Link Control) — the UDP-facing header. Distinguishes unicast,
|
||||
broadcast, and the **BBMD / foreign-device** distribution messages (see §1.3).
|
||||
- **NPDU** (Network layer) — source/destination network numbers + hop count; enables routing
|
||||
across BACnet networks (e.g. an IP↔MS/TP router).
|
||||
- **APDU** (Application layer) — the actual service request/response
|
||||
(ReadProperty, WriteProperty, SubscribeCOV, Who-Is/I-Am, …), plus **segmentation** for
|
||||
payloads larger than a single frame (a large `object-list` or `ReadPropertyMultiple` result
|
||||
is segmented across several APDUs with windowed ack).
|
||||
|
||||
**Object model.** A BACnet device is a `Device` object plus a collection of **objects**, each
|
||||
with a well-known **object-type** and an **object-instance** number. Common object types:
|
||||
|
||||
| Object type | Typical meaning | Present-Value type |
|
||||
|---|---|---|
|
||||
| Analog Input / Output / Value (AI/AO/AV) | sensor / setpoint / calc'd analog | REAL (float) |
|
||||
| Binary Input / Output / Value (BI/BO/BV) | contact / relay / flag | enumerated (0/1, active/inactive) |
|
||||
| Multistate Input / Output / Value (MSI/MSO/MSV) | discrete state (Off/Low/High) | unsigned int |
|
||||
| Device | identity, object-list, vendor, APDU limits | — |
|
||||
| Schedule, Calendar | time-based control | — |
|
||||
| Trend Log, Trend Log Multiple | historical samples | — |
|
||||
| Loop, Notification Class, File, … | control / infra | — |
|
||||
|
||||
Every object exposes **properties** addressed by a **property-identifier** (an enum). The ones
|
||||
that matter for a tag driver:
|
||||
|
||||
- **`present-value` (PROP_PRESENT_VALUE, 85)** — the live value. The read/subscribe target.
|
||||
- **`object-name` (77)**, **`description` (28)** — labels for browse.
|
||||
- **`units` (117)** — an *Engineering-Units* enum (degrees-C, kW, percent, …) on analog objects.
|
||||
- **`status-flags` (111)** — in-alarm / fault / overridden / out-of-service → maps to OPC UA quality.
|
||||
- **`object-list` (76)** — on the Device object: the array of every object the device holds
|
||||
(the enumeration backbone for discovery + browse).
|
||||
- **`priority-array` (87)** + **`relinquish-default` (104)** — for commandable outputs; writes
|
||||
target a priority slot (see §2 Write).
|
||||
|
||||
**Services used by a client/gateway:**
|
||||
|
||||
- **Who-Is / I-Am** — discovery. Client broadcasts `Who-Is` (optionally with a device-instance
|
||||
low/high range); each device answers `I-Am` carrying its device-instance, max-APDU,
|
||||
segmentation support, and vendor-id. *This is asynchronous and time-bounded* (broadcast, then
|
||||
collect I-Ams over a window) — the key wrinkle for the browse seam (§4).
|
||||
- **ReadProperty (RP)** — read one property of one object.
|
||||
- **ReadPropertyMultiple (RPM)** — read many properties across many objects in one request; the
|
||||
efficient bulk-read path (subject to segmentation for big results).
|
||||
- **WriteProperty (WP) / WritePropertyMultiple** — write, usually `present-value` at a priority.
|
||||
- **SubscribeCOV (Change-Of-Value)** — subscribe to an object; the device **pushes**
|
||||
`COVNotification` when `present-value` (or `status-flags`) changes beyond the COV increment.
|
||||
Subscriptions carry a **lifetime** (seconds) and must be **renewed** before expiry, or made
|
||||
*confirmed* vs *unconfirmed*. `SubscribeCOVProperty` targets a specific property.
|
||||
|
||||
### 1.3 Cross-subnet: BBMD & foreign-device registration
|
||||
|
||||
UDP broadcasts (Who-Is, I-Am, unconfirmed-COV) don't cross IP routers. BACnet solves this with
|
||||
**BBMDs** (BACnet Broadcast Management Devices): one BBMD per subnet forwards broadcasts to peer
|
||||
BBMDs via a **Broadcast Distribution Table (BDT)**. A client on a subnet *without* a BBMD (or on
|
||||
a different network, e.g. the OtOpcUa server VM) registers as a **Foreign Device** with a remote
|
||||
BBMD (`Register-Foreign-Device`, with a TTL that must be renewed). Once registered, the BBMD
|
||||
relays broadcasts to it. **For OtOpcUa this is the common case** — the server usually is not on
|
||||
the same L2 segment as the controllers, so foreign-device registration to a site BBMD is the
|
||||
realistic connect path. This is a first-class config knob (§3), not an afterthought.
|
||||
|
||||
### 1.2 .NET library options
|
||||
|
||||
| Library | Package / repo | License | Frameworks | Maturity | Verdict |
|
||||
|---|---|---|---|---|---|
|
||||
| **System.IO.BACnet (ela-compil fork)** | NuGet **`BACnet`** — [github.com/ela-compil/BACnet](https://github.com/ela-compil/BACnet) | **MIT** | net48, netstandard2.0, **net8.0, net10.0** | ~247★ / 45 releases / v4.0; core library used by **YABE** (Yet Another BACnet Explorer) | **RECOMMENDED** |
|
||||
| bacnet-stack (C) | [github.com/bacnet-stack/bacnet-stack](https://github.com/bacnet-stack/bacnet-stack) | GPL/BSD (dual) | C, P/Invoke only | Reference impl; used by the docker sims | No — C interop, not a .NET API |
|
||||
| BACsharp / gecambridge / sxul forks | various | MIT | older TFMs | Stale forks of the same SVN origin | No — ela-compil is the live one |
|
||||
|
||||
**Chosen library: `System.IO.BACnet` (ela-compil fork), NuGet `BACnet`, MIT.**
|
||||
|
||||
Why: it is the *actively maintained* fork of the canonical Morten Kvistgaard / F. Chaxel
|
||||
`System.IO.BACnet` port of Steve Karg's bacnet-stack, **it explicitly multi-targets `net10.0`**
|
||||
(so no TFM friction with this repo), MIT license fits, and it is the engine inside YABE so the
|
||||
protocol coverage is battle-tested. It explicitly supports everything we need:
|
||||
**Who-Is/I-Am, ReadProperty, ReadPropertyMultiple, WriteProperty, SubscribeCOV /
|
||||
SubscribeCOVProperty + COV notifications, and BACnet/IP with BBMD + foreign-device registration**
|
||||
(confirmed on the repo's feature list and in `ela-compil/BACnet.Examples`:
|
||||
`BasicReadWrite`, `ObjectBrowseSample`, `BasicAdviseCOV`, `DemoBBMD`).
|
||||
|
||||
Honest caveats:
|
||||
- The API is **event/callback-driven and largely synchronous-with-callbacks**, not
|
||||
`async`/`Task`-first. `BacnetClient.WhoIs()` fires `OnIam` events; `ReadPropertyRequest` has a
|
||||
blocking overload + a begin/end async pattern. The driver must **wrap it behind an async seam**
|
||||
(`TaskCompletionSource` + timeout), exactly the way the OpcUaClient driver already wraps the
|
||||
OPC UA SDK's session calls. Budget for a thin adapter layer.
|
||||
- BACnet values come back as `BacnetValue` (a tagged union / `IList<BacnetValue>` for arrays);
|
||||
the driver owns the BACnet-type → `DriverDataType` mapping.
|
||||
- Transport is a single shared UDP socket (`BacnetIpUdpProtocolTransport`) per client — all
|
||||
devices on the network multiplex over it. One `BacnetClient` per driver instance (per network
|
||||
interface / BBMD), **not** one per device — mirrors "single Session per OpcUaClient driver."
|
||||
|
||||
---
|
||||
|
||||
## 2. Capability mapping (IDriver + capability interfaces)
|
||||
|
||||
The driver implements `IDriver` plus the capability interfaces it can honor. Recommended set:
|
||||
`IDriver, ITagDiscovery, IReadable, ISubscribable, IWritable, IHostConnectivityProbe`
|
||||
(and later `IHistoryProvider` via Trend Log, `IAlarmSource` via COV/event enrollment — phase 2+).
|
||||
|
||||
| OtOpcUa capability | BACnet mechanism |
|
||||
|---|---|
|
||||
| **`IDriver.InitializeAsync`** | Bind the UDP socket on the configured interface/port; if `ForeignDevice` configured, `Register-Foreign-Device` to the BBMD (+ start TTL-renew timer). Optionally fire an initial `Who-Is` to warm the device table. Health = Healthy once the socket is bound (BACnet has no "connection"; liveness is per-device via COV/read). |
|
||||
| **`ITagDiscovery.DiscoverAsync`** | `Who-Is` (bounded collect window) → for each `I-Am`, read Device `object-list` (RPM, segmented) → for each object read `object-name` + `units` + `object-type` → register a variable node per (device, object[, property]). `RediscoverPolicy = Once` (like OpcUaClient); redeploy re-runs it. Note: for large sites, discovery is authored via the **browse picker** (§4) rather than a blind full-network discover — same as OpcUaClient where tags are usually hand-picked. |
|
||||
| **`IReadable.ReadAsync`** | Group the batch's `FullReference`s by device; issue **ReadPropertyMultiple** per device for `present-value` (+ `status-flags` for quality). Fall back to per-object ReadProperty for devices that report no RPM support in their `I-Am`. Map BACnet result → `DataValueSnapshot` (value + StatusCode from status-flags + timestamp=now). |
|
||||
| **`ISubscribable.SubscribeAsync`** | **SubscribeCOV** per object (the real win — native push). Register an `OnCOVNotification` handler that fans changes to `OnDataChange`. Maintain a **lifetime-renew timer** (re-subscribe before expiry). Devices that reject COV (return error) **degrade to a poll loop** at `publishingInterval` transparently — so the subscribe surface always works. |
|
||||
| **`IWritable.WriteAsync`** | **WriteProperty** `present-value` at a configurable **priority** (1–16; default 8 "Manual Operator" or 16 "lowest"), or `null` to relinquish. Non-idempotent → surface `BadTimeout` on ambiguous outcome (mirror OpcUaClient's write-result honesty). **Verdict: ship read-only in v1**, add write in phase 2 (priority-array semantics + commandable-vs-non-commandable validation are a correctness minefield; see §6). |
|
||||
| **`IHostConnectivityProbe`** | Periodic `Who-Is` to a specific device-instance (or a cheap RP of the Device object's `system-status`); Running↔Stopped transitions raise `OnHostStatusChanged`. For foreign-device mode, probe = "is the BBMD registration alive." |
|
||||
| **`GetHealth` / `GetMemoryFootprint` / `FlushOptionalCachesAsync`** | Standard. Footprint ≈ discovered-object count × constant (mirror OpcUaClient). Flush drops the device/object browse cache. |
|
||||
|
||||
### 2.1 Data-type mapping (BACnet → DriverDataType)
|
||||
|
||||
`DriverDataType` members available: `Boolean, Int16/32/64, UInt16/32/64, Float32, Float64,
|
||||
String, DateTime, Reference`.
|
||||
|
||||
| BACnet present-value / property type | `DriverDataType` | Notes |
|
||||
|---|---|---|
|
||||
| REAL (analog present-value) | `Float32` | AI/AO/AV |
|
||||
| DOUBLE | `Float64` | |
|
||||
| BOOLEAN | `Boolean` | |
|
||||
| Enumerated: binary Active/Inactive (BI/BO/BV) | `Boolean` | 0=inactive,1=active |
|
||||
| Enumerated: multistate (MSI/MSO/MSV) | `UInt16` (or `UInt32`) | present-value is 1-based state index; expose `state-text` array as metadata later |
|
||||
| Unsigned Integer | `UInt32` | |
|
||||
| Signed Integer | `Int32` | |
|
||||
| CharacterString (`object-name`, `description`) | `String` | |
|
||||
| BACnetDateTime / Date / Time | `DateTime` | Schedules, Trend Log timestamps |
|
||||
| ObjectIdentifier / property references | `Reference` / `String` | rarely a tag target |
|
||||
| Array property (element index N) | element type + `IsArray` | ValueRank=1 with ArrayDimensions |
|
||||
|
||||
**Engineering Units** (the BACnet `units` enum: `degrees-celsius`, `kilowatt-hours`, `percent`,
|
||||
…) map to the OPC UA node's **EngineeringUnits** (EUInformation) — surface it as node metadata
|
||||
(the address-space builder's variable metadata), captured at discovery/browse time so the UNS
|
||||
node carries proper units. **`status-flags`** (in-alarm/fault/overridden/out-of-service) →
|
||||
OPC UA StatusCode: `out-of-service` or `fault` ⇒ Bad/Uncertain quality; else Good.
|
||||
|
||||
---
|
||||
|
||||
## 3. TagConfig JSON shape
|
||||
|
||||
Two layers, matching every other Equipment-kind driver: a **driver-level config** (the
|
||||
`DriverConfig` blob bound in `InitializeAsync`, analogous to `ModbusDriverOptions`) and a
|
||||
**per-tag `TagConfig`** blob (authored on the `/uns` TagModal; the driver's equipment-tag parser
|
||||
turns it into an addressing record — mirror `ModbusEquipmentTagParser`).
|
||||
|
||||
### 3.1 Driver-level config (`BacnetDriverOptions`)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"localEndpoint": "0.0.0.0", // interface to bind; "0.0.0.0" = all
|
||||
"port": 47808, // 0xBAC0 default
|
||||
"localDeviceInstance": 4194302, // our own Device instance id (must be unique on the net)
|
||||
"foreignDevice": { // null when on-segment / a BBMD is local
|
||||
"bbmdAddress": "10.20.0.1",
|
||||
"bbmdPort": 47808,
|
||||
"ttlSeconds": 900 // registration TTL; driver auto-renews at ~TTL/2
|
||||
},
|
||||
"apduTimeoutMs": 3000,
|
||||
"apduRetries": 3,
|
||||
"maxSegmentsAccepted": 16, // segmentation window we advertise
|
||||
"discovery": {
|
||||
"whoIsWindowMs": 4000, // collect I-Am for this long (the time-bounded wrinkle)
|
||||
"deviceInstanceLow": 0, // optional Who-Is range filter
|
||||
"deviceInstanceHigh": 4194303
|
||||
},
|
||||
"cov": {
|
||||
"preferConfirmed": true, // confirmed vs unconfirmed COV
|
||||
"lifetimeSeconds": 300, // per-subscription lifetime; renewed before expiry
|
||||
"fallbackPollMs": 1000 // poll interval for devices that reject COV
|
||||
},
|
||||
"probe": { "enabled": true, "deviceInstance": 100, "intervalMs": 5000 }
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 Per-tag `TagConfig` (authored on the TagModal / emitted by the browse picker)
|
||||
|
||||
A BACnet tag address is fully described by **device-instance + object-type + object-instance +
|
||||
property-id (+ optional array index)**. `present-value` is the default property.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"deviceInstance": 100, // the target device (from I-Am)
|
||||
"objectType": "AnalogInput", // enum: AnalogInput/AnalogOutput/AnalogValue/
|
||||
// BinaryInput/.../MultistateValue/...
|
||||
"objectInstance": 3, // object-instance number
|
||||
"propertyId": "PresentValue", // default; may be StatusFlags, Units, etc.
|
||||
"arrayIndex": null, // non-null → element of an array property
|
||||
"dataType": "Float32", // driver data-type hint (from browse-time units/type)
|
||||
"writable": false, // v1: always false (read-only); v2: enables WriteProperty
|
||||
"writePriority": 8 // v2 only: BACnet command priority 1..16
|
||||
}
|
||||
```
|
||||
|
||||
This blob is stored verbatim as the equipment tag's `TagConfig.FullName` reference; the driver's
|
||||
`BacnetEquipmentTagParser.TryParse` (leading `{` ⇒ TagConfig blob, same convention as
|
||||
`ModbusEquipmentTagParser`) turns it into the internal address record used for RP/RPM/COV keys.
|
||||
The parser publishes values back keyed by the same reference string so the runtime forward-router
|
||||
resolves them — identical to the Modbus contract.
|
||||
|
||||
---
|
||||
|
||||
## 4. BROWSEABILITY VERDICT + browse design
|
||||
|
||||
### VERDICT: **YES — strongly browseable.** BACnet is arguably *more* browseable than most
|
||||
drivers: Who-Is enumerates devices, `object-list` enumerates a device's objects, and per-object
|
||||
property reads give names/units/types. This is a first-class `IDriverBrowser`, on par with the
|
||||
OpcUaClient browser.
|
||||
|
||||
New project **`ZB.MOM.WW.OtOpcUa.Driver.Bacnet.Browser`**, mirroring
|
||||
`ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser`: one `IDriverBrowser` (`DriverType = "Bacnet"`)
|
||||
that `OpenAsync(configJson)` → binds a transient `BacnetClient` (+ BBMD reg if configured) and
|
||||
returns a `BacnetBrowseSession : IBrowseSession`. Registered in
|
||||
`AdminUI/EndpointRouteBuilderExtensions.cs` alongside the other two:
|
||||
`services.AddSingleton<IDriverBrowser, BacnetDriverBrowser>();`.
|
||||
|
||||
Three-level tree mapped onto `RootAsync / ExpandAsync / AttributesAsync`:
|
||||
|
||||
| Seam | BACnet action | Returns |
|
||||
|---|---|---|
|
||||
| **`RootAsync`** | Broadcast `Who-Is` (range from config), **collect `I-Am` over `whoIsWindowMs`**, dedupe by device-instance | one `BrowseNode` per device (`NodeId = "dev:<instance>"`, `Kind=Folder`, `HasChildrenHint=true`, DisplayName = device `object-name` if a quick RP resolves, else `Device <instance>`) |
|
||||
| **`ExpandAsync("dev:<instance>")`** | Read the device's `object-list` (RPM, segmented); optionally batch-read each object's `object-name` | one `BrowseNode` per object (`NodeId = "dev:<instance>/<objtype>:<objinst>"`, `Kind=Leaf` — an object commits to a tag, `DisplayName = object-name`) |
|
||||
| **`AttributesAsync("dev:.../<objtype>:<objinst>")`** | RPM the object's key properties: `present-value, units, status-flags, description` (+ `state-text` for multistate) | `AttributeInfo[]` for the side-panel — surfaces the resolved `DriverDataType`, units, writability (present iff commandable object type), so the picker pre-fills the TagConfig |
|
||||
|
||||
**Commit mapping.** Selecting an object (a Leaf) + `present-value` → the picker emits the §3.2
|
||||
TagConfig blob: `deviceInstance` from the device node, `objectType`/`objectInstance` from the
|
||||
object node, `propertyId = "PresentValue"`, `dataType` from `AttributesAsync`. (Selecting a
|
||||
non-default property is an advanced path — emit the chosen `propertyId`.)
|
||||
|
||||
### The time-bounded-discovery wrinkle
|
||||
|
||||
Unlike OPC UA's synchronous `Browse` request/response, **Who-Is is a broadcast + asynchronous
|
||||
I-Am collection**: there is no "the response." The session handles this by making `RootAsync`
|
||||
*block for `whoIsWindowMs`* while accumulating `OnIam` callbacks into a concurrent set, then
|
||||
returning the snapshot. Design points:
|
||||
|
||||
- Cache the discovered device table on the session after the first `RootAsync`; a re-expand
|
||||
doesn't re-broadcast. Offer a cheap "rescan" that re-broadcasts and merges (devices that
|
||||
came online later appear). LastUsedUtc refresh + TTL reaper eviction as usual.
|
||||
- The window is a **latency floor** on the first `RootAsync` (a few seconds). That's acceptable
|
||||
for an interactive picker (show a spinner "discovering devices…"), and it matches the
|
||||
`ConnectBudget` pattern the Galaxy browser already uses (30s connect budget). Bound it hard.
|
||||
- Foreign-device mode: the transient browse client must register with the BBMD before Who-Is,
|
||||
or no I-Ams cross the router — reuse the runtime driver's registration code in the browser
|
||||
(same "mirror the runtime option shape" discipline the Galaxy browser follows).
|
||||
- `object-list` on a big device can be large ⇒ **segmentation** must work in the browse client
|
||||
(the ela-compil client handles segmented RPM; just don't cap `maxSegmentsAccepted` too low).
|
||||
|
||||
---
|
||||
|
||||
## 5. Test-fixture strategy
|
||||
|
||||
BACnet has good open simulators; all are UDP servers we can containerize on the shared docker
|
||||
host (`10.100.0.35`) with a `project=lmxopcua` label, driven by `lmxopcua-fix up bacnet`:
|
||||
|
||||
1. **`bacnet-stack` demo server** ([bacnet-stack/bacnet-stack](https://github.com/bacnet-stack/bacnet-stack))
|
||||
— the reference C stack's `bacserv` demo. Configurable device-instance + object set via env;
|
||||
answers Who-Is/I-Am, RP/RPM, WP, and **SubscribeCOV**. The canonical target.
|
||||
2. **`bacstack-compliance-docker`** ([fh1ch/bacstack-compliance-docker](https://github.com/fh1ch/bacstack-compliance-docker))
|
||||
— a ready-made **BACnet server simulator as a Docker container** (DockerHub image), built on
|
||||
bacnet-stack. Lowest-friction "just run a device."
|
||||
3. **`bacnet-docker`** ([mnp/bacnet-docker](https://github.com/mnp/bacnet-docker) /
|
||||
[desolat/bacnet-docker](https://github.com/desolat/bacnet-docker)) — docker-compose framework
|
||||
to stand up **multiple** BACnet/IP servers + clients on one host; good for a **multi-device
|
||||
Who-Is** discovery test and a **BBMD/foreign-device** topology test (put a BBMD + a device on
|
||||
one compose network, register the driver as a foreign device).
|
||||
4. **YABE** (Yet Another BACnet Explorer, Windows GUI) — manual cross-check that our reads/COV
|
||||
match a known-good client (it uses the same ela-compil library, so it's an apples-to-apples
|
||||
oracle). Also a good source of protocol-behavior sanity.
|
||||
5. **ela-compil `BACnet.Examples/BasicServer`** — a pure-.NET device sim we can embed directly in
|
||||
the integration test process (no docker), useful for deterministic COV-notification unit/
|
||||
integration tests without UDP-on-CI flakiness. Pair with `BasicAdviseCOV` as the client oracle.
|
||||
|
||||
**Gotcha — UDP + broadcast on docker/CI:** BACnet relies on subnet **broadcast** for Who-Is;
|
||||
bridged docker networks and macOS-hosted CI don't broadcast cleanly. Two mitigations: (a) run
|
||||
BACnet fixtures on the Linux docker host with `network_mode: host` or a dedicated bridge and test
|
||||
from a peer on that host (the `10.100.0.35` pattern), and (b) for hermetic unit tests use the
|
||||
embedded `BasicServer` + **directed (unicast) Who-Is to a known instance/address**, sidestepping
|
||||
broadcast entirely. Expect an **env-gated live-integration suite** (like the historian/S7 live
|
||||
gates) rather than pure in-proc for the discovery/COV/BBMD legs.
|
||||
|
||||
---
|
||||
|
||||
## 6. Effort / risk / phasing
|
||||
|
||||
**Overall effort: Medium-Large.** Bigger than Modbus (Modbus has no discovery, no push), roughly
|
||||
on par with or slightly above the OpcUaClient driver, because of the async-wrapping of a
|
||||
callback-style library + the COV lifetime/renewal machinery + BBMD.
|
||||
|
||||
Suggested phasing (each phase independently shippable, mirroring how OpcUaClient landed across PRs):
|
||||
|
||||
- **Phase 1 — Connect + Read + Discover + Browse (read-only).** `BacnetDriverOptions`,
|
||||
`BacnetDriver : IDriver, ITagDiscovery, IReadable, IHostConnectivityProbe`; the async adapter
|
||||
over `BacnetClient`; RP/RPM read path; Who-Is/object-list discovery; the
|
||||
`Bacnet.Browser` project + AdminUI registration; typed tag editor (`TagConfigEditorMap` +
|
||||
`TagConfigValidator`) or start on the raw-JSON fallback. **Foreign-device/BBMD included here**
|
||||
because without it the driver can't reach most real sites. *This is the MVP and delivers real value.*
|
||||
- **Phase 2 — SubscribeCOV push.** `ISubscribable` via SubscribeCOV + lifetime-renew timer +
|
||||
poll fallback for non-COV devices. The headline feature over poll-only drivers.
|
||||
- **Phase 3 — Write.** `IWritable` via WriteProperty with priority-array semantics + commandable
|
||||
validation. Deferred deliberately (see risk below).
|
||||
- **Phase 4 — History / Alarms.** `IHistoryProvider` over **Trend Log** objects
|
||||
(`ReadRange`); `IAlarmSource` over intrinsic/algorithmic **event enrollment** + COV of
|
||||
`status-flags`. Nice-to-have; large surface.
|
||||
|
||||
### Top risks
|
||||
|
||||
1. **BBMD / foreign-device registration + UDP broadcast reachability (HIGH).** The single most
|
||||
likely thing to make it "work on my bench, fail at the customer." The server is usually not on
|
||||
the controllers' L2 segment, so correct foreign-device registration (and its TTL renewal) is
|
||||
load-bearing, and it's exactly what's hardest to reproduce in docker/CI. Mitigation: build BBMD
|
||||
in from Phase 1, test it explicitly with the `bacnet-docker` multi-network compose, and gate a
|
||||
live suite against a real site BBMD (the established env-gated live-gate pattern).
|
||||
2. **COV lifetime/renewal + non-COV device fallback (HIGH).** Subscriptions silently expire if
|
||||
not renewed; some devices cap concurrent COV subscriptions or don't support COV at all
|
||||
(must degrade to polling without the operator noticing a gap). Getting the renewal timer,
|
||||
the confirmed-vs-unconfirmed choice, and the transparent poll fallback right is subtle and
|
||||
won't be caught by unit tests — needs the live/sim COV soak (mirror the continuous-historization
|
||||
live-gate discipline).
|
||||
|
||||
Secondary risks: the ela-compil library's **callback/blocking API** needs a careful async wrapper
|
||||
(TCS + timeout + cancellation) to fit the driver's `async` seams; **write priority-array**
|
||||
semantics (why Phase 3 is deferred — writing the wrong priority can fight the BMS's own control
|
||||
logic, a safety concern); and **segmentation** for large `object-list`/RPM results (the library
|
||||
handles it, but `maxSegmentsAccepted` must not be set too low).
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
- ela-compil/BACnet (System.IO.BACnet, MIT, net10.0): <https://github.com/ela-compil/BACnet>
|
||||
- ela-compil/BACnet.Examples (WhoIs/OnIam, RPM, SubscribeCOV, BBMD, ObjectBrowse):
|
||||
<https://github.com/ela-compil/BACnet.Examples>
|
||||
- bacnet-stack (reference C stack + demo server): <https://github.com/bacnet-stack/bacnet-stack>
|
||||
- fh1ch/bacstack-compliance-docker (BACnet device sim container): <https://github.com/fh1ch/bacstack-compliance-docker>
|
||||
- mnp/bacnet-docker (multi-device compose rig): <https://github.com/mnp/bacnet-docker>
|
||||
- BACnet protocol / object model / COV overviews: Chipkin (<https://docs.chipkin.com/protocols/bacnet/>),
|
||||
Actility (<https://www.actility.com/what-is-the-bacnet-object-model/>),
|
||||
Software Toolbox (<https://softwaretoolbox.com/resources/what-is-bacnet>)
|
||||
@@ -0,0 +1,408 @@
|
||||
# Mitsubishi MELSEC SLMP / MC-Protocol driver — research
|
||||
|
||||
**Status:** research spike (no code). Recommendation is to build a native
|
||||
**SLMP / MC-protocol** Equipment-kind driver, hand-rolling the 3E-binary frame,
|
||||
patterned on the existing S7 (byte-oriented binary TCP) and Modbus (polling,
|
||||
pre-declared-tag, no-browse) drivers.
|
||||
|
||||
**Scope note vs. `docs/v2/mitsubishi.md`:** that document catalogues reaching
|
||||
MELSEC *over Modbus TCP* via bolt-on Ethernet modules (QJ71MT91, RJ71EN71 MODBUS
|
||||
slave mode, FX5U built-in MODBUS, FX3U-ENET-P502…). This driver is the **native
|
||||
alternative** that talks the PLC's own protocol and supersedes that lossy path.
|
||||
The two can coexist — the Modbus path stays valid for sites that only enabled the
|
||||
MODBUS slave — but SLMP is the strictly-better option wherever the CPU's native
|
||||
Ethernet/MC endpoint is reachable.
|
||||
|
||||
---
|
||||
|
||||
## 1. Protocol summary + the gap it closes
|
||||
|
||||
### SLMP vs. MC 3E / 4E / 1E
|
||||
|
||||
**MC protocol** (MELSEC Communication protocol) is Mitsubishi's long-standing
|
||||
client/server request-response protocol for reading and writing CPU device
|
||||
memory over Ethernet (and serial). **SLMP** (SeamLess Message Protocol) is the
|
||||
modern superset: SLMP's 3E and 4E frames are *bit-for-bit identical* to the
|
||||
"QnA-compatible 3E/4E" MC-protocol frames, so an SLMP client talks to any
|
||||
MC-protocol server and vice-versa. For our purposes **SLMP ≡ MC-protocol 3E/4E**;
|
||||
we implement one framer and cover both product lines.
|
||||
|
||||
Frame families:
|
||||
|
||||
| Frame | Subheader (req/resp) | Use | Notes |
|
||||
|---|---|---|---|
|
||||
| **3E** | `5000` / `D000` | **Primary target.** Q / L / iQ-R / iQ-F / FX5 Ethernet | Stateless; no serial number. Simplest and most universal. |
|
||||
| **4E** | `5400…0000` / `D400…0000` | iQ-R / iQ-F, when request/response correlation is wanted | 3E **plus** a 2-byte serial number echoed in the response — lets a client pipeline and match replies. Superset of 3E. |
|
||||
| **1E** | `00`/`01`/`02`/`03` cmd bytes; `80`+cmd resp | Legacy A-series and some FX | Different, older layout. Only needed for very old CPUs. |
|
||||
|
||||
Each frame carries a **binary** or **ASCII** encoding. Binary is half the bytes
|
||||
and what every modern deployment uses; ASCII exists mainly for text-only serial
|
||||
links. We implement **3E binary** first, structure the framer so **4E binary**
|
||||
is a thin superset, and treat 1E + ASCII as out-of-scope v1 (documented fallback).
|
||||
|
||||
### 3E binary request layout (the frame we hand-roll)
|
||||
|
||||
Reading `D200`, 1 word (from Mitsubishi *SLMP Reference Manual* SH-080956ENG and
|
||||
the FA-Support worked example [3][4]):
|
||||
|
||||
```
|
||||
50 00 Subheader (request 3E)
|
||||
00 Network No. (0x00 = own/local network)
|
||||
FF PC No. (0xFF = local/host CPU)
|
||||
FF 03 Request dest module I/O (0x03FF = own CPU)
|
||||
00 Request dest module station
|
||||
0C 00 Request data length (little-endian; bytes that follow this field)
|
||||
10 00 Monitoring timer (0x0010 = 250 ms units; 0 = wait forever)
|
||||
01 04 Command (0x0401 Batch Read, little-endian on wire)
|
||||
00 00 Subcommand (0x0000 = word units; 0x0001 = bit units)
|
||||
C8 00 00 Head device number (200, 3 bytes little-endian)
|
||||
A8 Device code (0xA8 = D, data register)
|
||||
01 00 Device point count (1 word, little-endian)
|
||||
```
|
||||
|
||||
Response: `D000` subheader, the routing echo, a data length, a **2-byte end code**
|
||||
(`0000` = success; non-zero = error, e.g. `C051` device-count over range,
|
||||
`4031` wrong device, `C059`/`C05C` command/subcommand error), then the payload.
|
||||
|
||||
Key commands:
|
||||
|
||||
| Command | Subcmd | Meaning | Max points |
|
||||
|---|---|---|---|
|
||||
| `0401` | `0000` | Batch Read, **word** units | 960 words (3E) |
|
||||
| `0401` | `0001` | Batch Read, **bit** units | 7168 bits |
|
||||
| `1401` | `0000` / `0001` | Batch Write, word / bit | 960 / 7168 |
|
||||
| `0403` | `0000` | **Random Read** (scattered word/dword addresses) | 192 points |
|
||||
| `1402` | `0000` | Random Write (scattered) | ~160 points |
|
||||
| `0406` | — | Read block (multiple blocks) | — |
|
||||
| `1001` / `1002` | — | Remote STOP / RUN | — |
|
||||
| `0101` | — | Read CPU model name (cheap connectivity probe) | — |
|
||||
|
||||
### Device codes (3E binary, 1-byte code)
|
||||
|
||||
Bit-vs-word classification is the crux of the addressing model. Non-exhaustive;
|
||||
**verify the full table against SH-080956ENG before coding** — these are the
|
||||
common ones:
|
||||
|
||||
| Device | Code (hex) | Kind | Number base in engineering tools |
|
||||
|---|---|---|---|
|
||||
| X input | `9C` | **bit** | **hex** (Q/L/iQ-R), octal (FX/iQ-F) |
|
||||
| Y output | `9D` | **bit** | **hex** (Q/L/iQ-R), octal (FX/iQ-F) |
|
||||
| M internal relay | `90` | bit | decimal |
|
||||
| L latch relay | `92` | bit | decimal |
|
||||
| F annunciator | `93` | bit | decimal |
|
||||
| B link relay | `A0` | **bit** | **hex** |
|
||||
| SM special relay | `91` | bit | decimal |
|
||||
| D data register | `A8` | **word** | decimal |
|
||||
| W link register | `B4` | **word** | **hex** |
|
||||
| R file register | `AF` | word | decimal |
|
||||
| ZR extended file reg | `B0` | word | **hex** |
|
||||
| SD special register | `A9` | word | decimal |
|
||||
| TN timer current | `C2` | word | decimal |
|
||||
| CN counter current | `C5` | word | decimal |
|
||||
| TS/CS timer/counter contact | `C1`/`C4` | bit | decimal |
|
||||
|
||||
### The gap SLMP closes over the Modbus-TCP path
|
||||
|
||||
`docs/v2/mitsubishi.md` documents, in detail, why the Modbus path is lossy. SLMP
|
||||
removes each of those failure modes:
|
||||
|
||||
1. **No per-site "Modbus Device Assignment" block.** SLMP addresses the CPU's
|
||||
device memory directly by `<device code, number>` (e.g. `D200`, `M100`).
|
||||
There is *no* 16-entry assignment table to configure in GX Works and no
|
||||
"two sites with the same module expose different maps" problem — the biggest
|
||||
single source of Modbus-path fragility disappears.
|
||||
2. **X/Y reachable natively** as their own bit devices, not shoehorned into a
|
||||
second non-zero coil bank (Modbus default maps X/Y at offset 8192+). The
|
||||
hex-vs-octal number-base trap **remains** (it is a CPU convention, not a
|
||||
transport artifact) — see §2/§3 — but there is no *second* Modbus-offset
|
||||
translation layered on top.
|
||||
3. **Full device coverage.** L, F, B, W, R, ZR, SM/SD, timers/counters are all
|
||||
directly addressable. The Modbus path can only see whatever the engineer
|
||||
chose to expose in the assignment block.
|
||||
4. **No FC caps / sub-spec quirks.** No "QJ71MT91 doesn't support FC16", no
|
||||
125-register FC03 ceiling, no odd-coil-byte truncation. SLMP batch read is
|
||||
960 words in one PDU vs. Modbus' 125.
|
||||
5. **Random read/write** of scattered addresses in one round-trip (`0403`/`1402`)
|
||||
— impossible in Modbus without one PDU per contiguous run.
|
||||
6. **Word-order (CDAB) is still a per-tag concern** (§3) — 32-bit values still
|
||||
span two consecutive words low-word-first — but this is now *our* decode
|
||||
choice, not something filtered through a module's fixed behavior.
|
||||
|
||||
### .NET library options — LICENSE analysis
|
||||
|
||||
| Option | License | .NET / maturity | Verdict |
|
||||
|---|---|---|---|
|
||||
| **HslCommunication** (`MelsecMcNet`) | **Commercial / NOT free** — "公对公签订合同", company-to-company contract + VAT invoice; source only with paid license [5][6] | net35+; very mature, widely used | **BLOCKER — do not use.** Requires a signed commercial contract. Rules it out for this repo. |
|
||||
| **McProtocol** (SecondShiftEngineer) | **LGPL-3.0** [7] | netstandard2.0 (loads on net10); MC1E/3E/4E; last updated **2018** | LGPL is usable when consumed as an unmodified dynamic library, but it is a **copyleft** dependency to vet with legal, and it is **7+ years stale**. Useful as a *reference*, weak as a *dependency*. |
|
||||
| **McpX** | **MIT** [8] | net7/8/9 + netstandard, cross-platform; TCP/UDP, 3E/4E binary+ASCII, batch + random + monitor + remote password; first release 2025, actively developed (v0.7.0 Jun 2026), ~53★, single maintainer | **MIT is clean.** The most license-friendly library and feature-complete, but young + one-maintainer + pre-1.0 → supply-chain/bus-factor risk for a production driver. |
|
||||
| **`s-pms/melsec_mc_net`** | **MIT** [9] | **C** (not .NET) — Windows/Linux; 3E binary+ASCII, batch + typed read/write; full device-code table | Not consumable from .NET, but an **excellent MIT reference** for a hand-roll (device codes, framing, transaction serialization). |
|
||||
| **libslmp / libslmp2** (Neucrede) | open-source C/C++ | C/C++ | Reference only, not .NET. |
|
||||
|
||||
**Recommendation: hand-roll the 3E-binary framer.** Rationale:
|
||||
- The frame is small and fully specified (SH-080956ENG); the existing **S7 driver
|
||||
already proves this repo hand-rolls byte-oriented binary TCP** with a clean
|
||||
`IS7Plc` seam and a fake for tests. SLMP is *simpler* than S7's PDU negotiation.
|
||||
- The only clean-licence library (McpX, MIT) is young/one-maintainer/pre-1.0 —
|
||||
taking it as a hard dependency in a production OT server is more risk than a
|
||||
~600-line framer we own and test.
|
||||
- HslCommunication (the mature option) is a hard **commercial-licence blocker**.
|
||||
- Keep McpX and the MIT `melsec_mc_net`/`libslmp` sources as **cross-check
|
||||
references** for the framer + device-code table.
|
||||
|
||||
The repo already carries a **head-start**: `MelsecAddress` +`MelsecFamily` in
|
||||
`ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing` encode the hex-vs-octal X/Y family
|
||||
logic (written for the Modbus path). That logic ports directly into the new
|
||||
driver's addressing project.
|
||||
|
||||
---
|
||||
|
||||
## 2. Capability mapping
|
||||
|
||||
Same capability-interface set as Modbus/S7 (`IDriver, ITagDiscovery, IReadable,
|
||||
IWritable, ISubscribable, IHostConnectivityProbe`). This is a **full read/write**
|
||||
PLC driver.
|
||||
|
||||
| Capability | SLMP mapping |
|
||||
|---|---|
|
||||
| **Connect** (`IDriver.InitializeAsync`) | TCP client to CPU Ethernet port (default **502** is Modbus; SLMP default is engineer-configured, commonly `1025`/`5007` or a user-set port; **UDP optional** — SLMP supports both; start TCP-only like Modbus). Open socket, optionally issue `0101` Read-CPU-model as a connect assertion. Reconnect/keepalive/idle-disconnect knobs mirror `ModbusDriverOptions`. |
|
||||
| **Read** (`IReadable`) | **Batch Read `0401`** for contiguous runs (word subcmd `0000` for D/W/R/ZR/TN/CN; bit subcmd `0001` for M/X/Y/B/L). **Random Read `0403`** to coalesce scattered addresses into one PDU. A read planner (like Modbus' block coalescing / `MaxReadGap`) groups tags by device code into batch reads, splitting at the 960-word PDU cap. |
|
||||
| **Write** (`IWritable`) | **Batch Write `1401`** (word/bit) for runs; **Random Write `1402`** for scattered. Bit-write to M/Y honored; word-write to D/W/R. Write-through gated by the standard `WriteOperate` node authz + `NodeWriteRouter` like every other protocol driver (the `EquipmentTagRefResolver<TDef>` pattern). |
|
||||
| **Subscribe** (`ISubscribable`) | **Poll-based**, exactly like Modbus/S7 — **SLMP has no native push/unsolicited path** for general device polling. Reuse the shared polling overlay engine (the same `ISubscribable` polling helper Modbus uses). (SLMP *does* have a "device monitor register" `0801`/monitor `0802` mechanism, but it is a stateful convenience, not a change-push; poll is the right model.) |
|
||||
| **Discover** (`ITagDiscovery`) | **Offline / config-driven.** SLMP exposes **no on-wire symbol table** — the driver returns exactly the pre-declared tags from `SlmpDriverOptions.Tags`, identical to Modbus. No online enumeration. |
|
||||
| **Probe** (`IHostConnectivityProbe`) | Cheap `0101` Read-CPU-model, or a 1-word `0401` read of a known device, on an interval; raise `OnHostStatusChanged` on transitions. |
|
||||
| **Alarms / History** | None native. (A future scripted-alarm layer works the same as for any polling driver.) |
|
||||
|
||||
### Data-type mapping (SLMP words/bits → OPC UA)
|
||||
|
||||
| OPC UA type | SLMP encoding | Notes |
|
||||
|---|---|---|
|
||||
| Boolean | 1 bit device (M/X/Y/B/L) via bit-subcmd; or 1 bit of a word device | Bit-in-word needs a `bitIndex` like Modbus `BitInRegister`. |
|
||||
| Int16 / UInt16 | 1 word device | Native width. |
|
||||
| Int32 / UInt32 | 2 consecutive words | **word order** matters — MELSEC native is low-word-first (`CDAB` when viewed as a word pair). Per-tag `wordOrder` knob. |
|
||||
| Float (Single) | 2 words | Same word-order concern. |
|
||||
| Int64 / UInt64 / Double | 4 words | Same. |
|
||||
| String | N words, 2 ASCII chars/word | Byte-order-within-word knob (like Modbus `StringByteOrder`); MELSEC packs low byte = first char in some setups. |
|
||||
| DateTime | vendor-specific packing | v1: skip or map from a documented word layout. |
|
||||
| Array | `ValueRank=1`, `arrayLength` × element-words, one batch read | Cap at 960-word PDU; auto-chunk. |
|
||||
|
||||
**Addressing model = device code + number + base.** A tag names a *device code*
|
||||
(`D`,`M`,`X`,`Y`,`W`,`B`,`R`,`ZR`,…), a *device number*, and — critically — the
|
||||
number's **base**: X/Y/B/W/ZR are **hex** on Q/L/iQ-R and X/Y are **octal** on
|
||||
FX/iQ-F; D/M/L/F/R/timers/counters are **decimal** everywhere. The driver must
|
||||
preserve the engineering-tool base the operator typed (the `MelsecFamily` enum
|
||||
already models this). Word-vs-bit is a property of the device code and selects
|
||||
the batch-read subcommand.
|
||||
|
||||
---
|
||||
|
||||
## 3. TagConfig JSON shape
|
||||
|
||||
Mirrors `ModbusTagDefinition` / `ModbusEquipmentTagParser` (leading-`{` marks an
|
||||
equipment-tag TagConfig blob; strict enum reads reject typos → `BadNodeIdUnknown`).
|
||||
|
||||
Proposed per-tag fields:
|
||||
|
||||
| Field | Type | Meaning |
|
||||
|---|---|---|
|
||||
| `device` | enum string | Device code: `D`,`M`,`X`,`Y`,`W`,`B`,`R`,`ZR`,`L`,`F`,`SM`,`SD`,`TN`,`CN`,… |
|
||||
| `number` | string | Device number **as the operator types it in GX Works** (kept as string to preserve hex/octal). |
|
||||
| `numberBase` | enum | `Decimal` / `Hex` / `Octal` — defaulted from the driver-level `MelsecFamily`, overridable per tag. |
|
||||
| `dataType` | enum | `Boolean,Int16,UInt16,Int32,UInt32,Int64,UInt64,Float,Double,String`. |
|
||||
| `bitIndex` | int 0–15 | For a Boolean read from a bit of a word device (omit for true bit devices). |
|
||||
| `wordOrder` | enum | `ABCD` / `CDAB` — 32/64-bit word order (MELSEC native = `CDAB`, low word first). Default `CDAB`. |
|
||||
| `stringLength` | int | ASCII chars for `String` (2 per word). |
|
||||
| `stringByteOrder` | enum | High-byte-first vs low-byte-first within a word. |
|
||||
| `arrayLength` | int | `isArray && arrayLength>=1` → OPC UA array. |
|
||||
| `writable` | bool | Defaults true; node authz is the real gate. |
|
||||
|
||||
**Example** — a 32-bit float production count at `D200` (hex-family Q CPU, native
|
||||
CDAB word order), and a bit alarm at `M100`:
|
||||
|
||||
```json
|
||||
{
|
||||
"device": "D",
|
||||
"number": "200",
|
||||
"numberBase": "Decimal",
|
||||
"dataType": "Float",
|
||||
"wordOrder": "CDAB",
|
||||
"writable": false
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"device": "M",
|
||||
"number": "100",
|
||||
"numberBase": "Decimal",
|
||||
"dataType": "Boolean"
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"device": "X",
|
||||
"number": "1A",
|
||||
"numberBase": "Hex",
|
||||
"dataType": "Boolean",
|
||||
"_comment": "Q-series X1A = physical input 26 decimal; hex base preserved from GX Works"
|
||||
}
|
||||
```
|
||||
|
||||
Driver-level `SlmpDriverOptions` mirrors `ModbusDriverOptions`: `Host`, `Port`,
|
||||
`Frame` (`ThreeE`/`FourE`), `Encoding` (`Binary`), `NetworkNo`/`PcNo`/`DestModuleIo`/
|
||||
`DestStation` routing bytes (defaults `0x00/0xFF/0x03FF/0x00` = local CPU),
|
||||
`MonitoringTimer`, `Family` (`Q_L_iQR`/`F_iQF`), `Timeout`, reconnect/keepalive/
|
||||
idle knobs, `MaxPointsPerRead` (≤960), a read-coalescing gap budget, and the
|
||||
pre-declared `Tags` list.
|
||||
|
||||
---
|
||||
|
||||
## 4. BROWSEABILITY VERDICT — **NO**
|
||||
|
||||
**Definitively not browseable. No `*.Browser` project is warranted.**
|
||||
|
||||
SLMP/MC-protocol exposes a **flat, typed device-memory space** (`D`, `M`, `X`,
|
||||
`Y`, `W`, `R`, …) addressed purely by `<device code, number>`. There is **no
|
||||
on-wire symbol table, no tag directory, and no metadata service** in the
|
||||
protocol. The commands enumerated in the SLMP Reference Manual are memory
|
||||
read/write, remote CPU control, and self-test — none returns "what tags exist."
|
||||
This is structurally identical to Modbus and S7, both of which are (correctly)
|
||||
non-browseable in this repo: the address space is a raw memory map, not a
|
||||
discoverable namespace. The symbolic tag names live only in the GX Works project
|
||||
file on the engineer's PC, never on the wire.
|
||||
|
||||
**Searched-for exceptions, none qualifying:**
|
||||
- **CPU model / capability reads** (`0101`, self-test `0619`) return device
|
||||
*types and counts*, not a symbol list — useful for a connect assertion, not
|
||||
browse.
|
||||
- **Device monitor register** (`0801`/`0802`) is a client-side convenience for
|
||||
re-reading a *previously specified* set — the client supplies the addresses;
|
||||
the CPU never volunteers them.
|
||||
- **Label/tag communication** (iQ-R "device/label access via SLMP" with a name
|
||||
string) exists in newer firmware but requires the client to *already know* the
|
||||
global-label name and only resolves a name the engineer defined — it is a
|
||||
by-name read, still **not an enumeration**. Not a browse source.
|
||||
- **GX Works project (`.gx3`) / CSV label export** could seed tags *offline*, but
|
||||
that is a file-import feature, not an on-wire `IDriverBrowser` session, and is
|
||||
out of scope here.
|
||||
|
||||
Verdict: **NO browser.** Tags are authored via the pre-declared list + the typed
|
||||
tag editor (§7), exactly like Modbus/S7. In `TagConfigEditorMap` the driver gets
|
||||
a typed editor but **no** `IDriverBrowser`/address-picker.
|
||||
|
||||
---
|
||||
|
||||
## 5. Test-fixture strategy
|
||||
|
||||
Follow the repo's established **hand-rolled TCP stub** pattern (S7's `IS7Plc`
|
||||
fake, FOCAS's mock, Modbus' transport seam). SLMP is a request/response binary
|
||||
protocol over TCP, so a deterministic stub is straightforward and CI-friendly.
|
||||
|
||||
**Recommended layers:**
|
||||
|
||||
1. **Unit — framer round-trip tests** (no socket). Encode/decode 3E-binary
|
||||
request/response byte arrays against golden vectors taken from SH-080956ENG
|
||||
and the MIT `melsec_mc_net`/McpX examples. This is where the device-code table,
|
||||
hex/octal number parsing, word-order (CDAB), and end-code handling get pinned.
|
||||
Port the existing `MelsecAddress` address tests.
|
||||
|
||||
2. **In-process fake `ISlmpClient`** (mirrors `FakeFocasClient` / S7's fake) for
|
||||
driver-level read/write/subscribe/discover behavior without a network.
|
||||
|
||||
3. **Integration — a hand-rolled SLMP server stub** (a `TcpListener` that decodes
|
||||
3E-binary requests and serves a seeded device-memory dictionary), packaged as a
|
||||
Docker fixture under `tests/.../Docker/` with the `project=lmxopcua` label and
|
||||
an env-gated skip (like `FocasSimFixture`'s `localhost:PORT` probe). This gives
|
||||
real-socket read/write round-trips, batch-read chunking, and reconnect tests
|
||||
deterministically. **This is the primary integration path** — write the stub;
|
||||
don't depend on vendor tooling in CI.
|
||||
|
||||
4. **Optional real-target gates (not in CI):**
|
||||
- **GX Works3 / GX Works2 simulator (GX Simulator3)** exposes an SLMP-capable
|
||||
virtual CPU but is **Windows-only, licensed, GUI-driven** — usable for a
|
||||
manual bring-up gate on a Windows box, not for automated CI (same posture as
|
||||
the AVEVA/mxaccessgw live gates).
|
||||
- **Open-source sims:** community MC/SLMP server sims exist (e.g. Go
|
||||
`moge800/gomcprotocol`, Node `plcpeople/mcprotocol` has a server mode,
|
||||
`libslmp`/`libmelcli` samples). Any could back a container, but a
|
||||
repo-owned .NET stub is lower-maintenance and matches house style.
|
||||
- A real FX5U / iQ-R on the bench is the final acceptance gate (a `LiveIntegration`
|
||||
env-gated suite, mirroring the historian live gate).
|
||||
|
||||
Reuse `docs/v2/mitsubishi.md`'s `Mitsubishi_<model>_<behavior>` test-naming
|
||||
convention for the behavioral cases (CDAB word order, hex X20 = 32, octal X20 = 16,
|
||||
960-word batch cap, end-code `C051` on over-range).
|
||||
|
||||
---
|
||||
|
||||
## 6. Effort / risk / phasing
|
||||
|
||||
**Overall effort: moderate** — comparable to the S7 driver. The framer is small;
|
||||
the complexity budget is almost entirely in the **addressing model** (device
|
||||
codes × bit/word × hex/octal/decimal base × CDAB word order), which is exactly
|
||||
where MELSEC drivers go wrong. Front-load it.
|
||||
|
||||
**Top risks:**
|
||||
1. **Addressing correctness** (highest). The hex/octal/decimal base split per
|
||||
device family, plus CDAB word order for 32/64-bit values, is the #1 real-world
|
||||
bug source (per `docs/v2/mitsubishi.md`). Mitigation: exhaustive framer/address
|
||||
unit tests with golden vectors *before* any driver wiring; reuse `MelsecAddress`.
|
||||
2. **Library/licensing** — resolved by hand-rolling (avoids the HslCommunication
|
||||
commercial blocker and the McpX bus-factor risk), but it means we own the
|
||||
protocol correctness. Cross-check against the MIT `melsec_mc_net` + McpX + the
|
||||
SLMP Reference Manual.
|
||||
3. **Frame/port/family fragmentation** — 3E vs 4E, binary vs ASCII, TCP vs UDP,
|
||||
Q/L/iQ-R hex vs FX/iQ-F octal, engineer-chosen port. Mitigation: ship **3E
|
||||
binary / TCP** only in v1, structure the framer so 4E is a superset, document
|
||||
the rest as fallbacks (same discipline as S7).
|
||||
|
||||
**Phasing:**
|
||||
- **Phase 0 — Addressing + framer (no I/O).** New `…Driver.Slmp.Addressing`
|
||||
project (device codes, base parsing, word order — port `MelsecAddress`) and a
|
||||
3E-binary encoder/decoder with golden-vector unit tests. `…Driver.Slmp.Contracts`
|
||||
with `SlmpDriverOptions` + `SlmpTagDefinition` + `SlmpEquipmentTagParser`.
|
||||
- **Phase 1 — Read path.** `SlmpDriver : IDriver, ITagDiscovery, IReadable,
|
||||
IHostConnectivityProbe` over an `ISlmpClient` TCP seam (S7-style), with a fake
|
||||
+ the Docker stub server. Batch Read `0401` + read planner/coalescing + all
|
||||
scalar/array/string/word-order decoding.
|
||||
- **Phase 2 — Write + Subscribe.** Add `IWritable` (Batch Write `1401`, Random
|
||||
Write `1402`; write-through via the `EquipmentTagRefResolver<TDef>` +
|
||||
`NodeWriteRouter` pattern) and `ISubscribable` on the shared polling overlay.
|
||||
- **Phase 3 — AdminUI typed tag editor.** `SlmpTagConfigEditor` +
|
||||
`SlmpTagConfigModel` (FromJson/ToJson/Validate), registered in
|
||||
`TagConfigEditorMap` + `TagConfigValidator`. Driver-edit page + `IDriverProbe`
|
||||
(with the `JsonStringEnumConverter` fix from the driver enum-serialization
|
||||
memory). **No** `IDriverBrowser` (§4).
|
||||
- **Phase 4 — Random read/write coalescing + 4E frame + live gate.** `0403`/`1402`
|
||||
optimization, optional 4E, and an env-gated `LiveIntegration` suite against a
|
||||
real FX5U/iQ-R or GX Simulator3.
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
1. Mitsubishi Electric, *SLMP Reference Manual* (SH-080956ENG) —
|
||||
https://dl.mitsubishielectric.com/dl/fa/document/manual/plc/sh080956eng/sh080956engl.pdf
|
||||
2. Mitsubishi Electric, *MELSEC iQ-F FX5 User's Manual (SLMP)* (JY997D56001) —
|
||||
https://dl.mitsubishielectric.com/dl/fa/document/manual/plcf/jy997d56001/jy997d56001k.pdf
|
||||
3. Inductive Automation, *Understanding Mitsubishi PLCs* (3E frame, D-register
|
||||
word order) —
|
||||
https://support.inductiveautomation.com/hc/en-us/articles/16517576753165-Understanding-Mitsubishi-PLCs
|
||||
4. FA Support Me, *PLC and PC communication via SLMP Protocol* (3E-binary worked
|
||||
example) —
|
||||
https://www.fasupportme.com/portal/en/kb/articles/plc-and-pc-communication-via-slmp-protocol
|
||||
5. dathlin/HslCommunication (GitHub) — "Not free open source" —
|
||||
https://github.com/dathlin/hslcommunication
|
||||
6. HslCommunication commercial-licence page —
|
||||
http://www.hslcommunication.cn/Cooperation
|
||||
7. McProtocol NuGet (LGPL-3.0, MC1E/3E/4E, last updated 2018) —
|
||||
https://www.nuget.org/packages/McProtocol/
|
||||
8. McpX NuGet / repo (MIT, .NET 7/8/9, 3E/4E binary+ASCII, batch+random) —
|
||||
https://libraries.io/nuget/McpX
|
||||
9. s-pms/melsec_mc_net (GitHub, MIT, C reference impl, full device-code table) —
|
||||
https://github.com/s-pms/melsec_mc_net
|
||||
10. Neucrede/libslmp2 (open-source C/C++ SLMP library, reference) —
|
||||
https://github.com/Neucrede/libslmp2
|
||||
11. In-repo: `docs/v2/mitsubishi.md` (MELSEC-over-Modbus quirks this driver
|
||||
supersedes) and `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing/MelsecAddress.cs`
|
||||
(hex/octal family logic to port).
|
||||
@@ -0,0 +1,422 @@
|
||||
# Research: Modbus RTU (serial) support
|
||||
|
||||
**Status:** Research / roadmap. Not implemented.
|
||||
**Date:** 2026-07-15
|
||||
**Author:** research sweep
|
||||
**Scope:** Add Modbus **RTU** (serial + RTU-over-TCP) to the OtOpcUa server.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
Modbus RTU is **an added transport mode on the existing `ModbusDriver`, not a new
|
||||
driver.** The existing driver already splits the *protocol data unit* (PDU: function
|
||||
code + data) from the *transport* (socket + MBAP framing) behind a clean
|
||||
`IModbusTransport` seam, and the driver injects transports through a
|
||||
`Func<ModbusDriverOptions, IModbusTransport>` factory. RTU is a second
|
||||
`IModbusTransport` implementation that swaps MBAP framing for
|
||||
`[address][PDU][CRC-16]` framing over a serial line (or a raw TCP socket, for
|
||||
RTU-over-TCP). The register model, function codes, data-type codecs, read planner,
|
||||
coalescing, deadband, write path, and OPC UA materialisation are **100% reused
|
||||
unchanged**. Browseable = **NO** (flat register space, no discovery — identical to
|
||||
Modbus TCP). The pragmatic primary path for a containerised Linux server is
|
||||
**RTU-over-TCP to a serial→Ethernet gateway**, with direct `System.IO.Ports` serial as
|
||||
a secondary path for bare-metal / device-mapped deployments. This is very likely the
|
||||
**lowest-effort item on the driver roadmap**; the only real risk is serial-line
|
||||
timing/behaviour on Linux and in containers.
|
||||
|
||||
---
|
||||
|
||||
## 1. Extend-vs-new-driver verdict — **EXTEND**
|
||||
|
||||
### Why the existing code makes this easy
|
||||
|
||||
The Modbus driver is already layered exactly the way you'd want in order to add a
|
||||
transport. The seam is `IModbusTransport`
|
||||
(`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/IModbusTransport.cs`):
|
||||
|
||||
```csharp
|
||||
public interface IModbusTransport : IAsyncDisposable
|
||||
{
|
||||
Task ConnectAsync(CancellationToken ct);
|
||||
Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct);
|
||||
}
|
||||
```
|
||||
|
||||
The interface doc comment is explicit that it takes **"a PDU (function code + data,
|
||||
excluding the 7-byte MBAP header)"** and returns the **response PDU** — "the transport
|
||||
owns transaction-id pairing, framing, and socket I/O." That is precisely the RTU-vs-TCP
|
||||
boundary. Everything above the seam is transport-neutral:
|
||||
|
||||
- **`ModbusDriver.cs`** builds every PDU as a raw `byte[]` of `[functionCode, ...data]`
|
||||
— FC01/02/03/04/05/06/15/16 encoders (`ReadRegisterBlockAsync`, `ReadBitBlockAsync`,
|
||||
the FC05/06/15/16 write paths, the FC03→bit-swap→FC06 RMW) all call
|
||||
`transport.SendAsync(unitId, pdu, ct)` and decode the returned PDU. None of them
|
||||
touch MBAP, sockets, or CRC.
|
||||
- **`ModbusTcpTransport.cs`** is the *only* place the 7-byte MBAP header, the
|
||||
transaction-id counter (`_nextTx`), and `TcpClient`/`NetworkStream` I/O live. It
|
||||
wraps each PDU as `[TxId][Proto=0][Length][UnitId] + PDU`, single-flights via a
|
||||
`SemaphoreSlim _gate`, and does socket-level reconnect/retry.
|
||||
- **The driver injects the transport**: `ModbusDriver`'s constructor takes
|
||||
`Func<ModbusDriverOptions, IModbusTransport>? transportFactory`, defaulting to
|
||||
`o => new ModbusTcpTransport(...)`. Tests already substitute in-memory fakes through
|
||||
this same hook.
|
||||
|
||||
So the entire protocol/codec/planner/health/OPC-UA surface is transport-agnostic
|
||||
today. Adding RTU means adding **one class** behind the existing seam plus the config
|
||||
plumbing to select it.
|
||||
|
||||
### The concrete refactor
|
||||
|
||||
There is essentially **no refactor of existing code needed** — the split is already
|
||||
done. The work is additive:
|
||||
|
||||
1. **New `ModbusRtuTransport : IModbusTransport`** (serial). Wraps a
|
||||
`System.IO.Ports.SerialPort`. `ConnectAsync` opens the port with the configured
|
||||
baud/data-bits/parity/stop-bits. `SendAsync` frames the ADU as
|
||||
`[unitId][PDU][CRC-lo][CRC-hi]`, enforces the ≥3.5-character inter-frame silence
|
||||
before transmit, writes, then reads the response, strips the address + validates and
|
||||
strips the CRC-16, and returns the bare PDU. Reuses the same single-flight `_gate`
|
||||
pattern (mandatory on RTU — see §2).
|
||||
2. **New `ModbusRtuOverTcpTransport : IModbusTransport`** (RTU tunnelled over a socket).
|
||||
Identical RTU framing (`[address][PDU][CRC]`, no MBAP, no TxId) but the byte stream
|
||||
rides a `TcpClient`/`NetworkStream` to a serial gateway instead of a COM port. This
|
||||
can share the socket-management, keepalive, idle-disconnect, and reconnect/backoff
|
||||
machinery already in `ModbusTcpTransport` — the *only* difference from
|
||||
`ModbusTcpTransport` is the ADU framing (CRC instead of MBAP) and the absence of a
|
||||
transaction id. Consider extracting the socket lifecycle into a small shared base or
|
||||
helper so both TCP variants share it; the MBAP-vs-CRC framing is the swap point.
|
||||
3. **A CRC-16 helper** (`ModbusCrc.Compute(ReadOnlySpan<byte>)`) — the standard Modbus
|
||||
CRC with polynomial `0xA001` (reflected `0x8005`), CRC appended low-byte-first. Put
|
||||
it in `...Driver.Modbus` (or `.Addressing`).
|
||||
4. **Transport-mode selection** in `ModbusDriverOptions` + the config DTO + the factory
|
||||
(`ModbusDriverFactoryExtensions.CreateInstance`) — a `Transport` discriminator
|
||||
(`Tcp` | `Rtu` | `RtuOverTcp`) plus the serial parameters, wiring the default
|
||||
`transportFactory` closure to pick the right transport (see §3).
|
||||
5. **AdminUI**: extend `ModbusDriverPage.razor` with a serial parameters panel shown
|
||||
when `Transport != Tcp`. No new page — same driver page.
|
||||
|
||||
**Framing subtlety worth calling out.** In Modbus TCP the MBAP `Length` field tells the
|
||||
transport exactly how many response bytes to read (`ModbusTcpTransport.SendOnceAsync`
|
||||
reads a 7-byte header, then `Length-1` more). **RTU has no length field.** The RTU
|
||||
transport must determine the response length either (a) by parsing the function code
|
||||
and byte-count field (read responses carry a byte-count; FC05/06/15/16 echoes are
|
||||
fixed-length; an exception response is a fixed 5 bytes with the high bit set on the FC),
|
||||
or (b) by reading until an inter-character idle gap (T1.5/T3.5) elapses. Function-code-
|
||||
aware length calculation is the robust choice and is simplest given the driver already
|
||||
knows the FC set. This is the single genuinely new piece of logic RTU introduces.
|
||||
|
||||
**Verdict: extend the existing `ModbusDriver` with two new `IModbusTransport`
|
||||
implementations + a transport selector.** A sibling driver would duplicate the entire
|
||||
codec/planner/health/materialisation surface for zero benefit — the protocol above the
|
||||
wire is identical.
|
||||
|
||||
---
|
||||
|
||||
## 2. Capability mapping — identical to TCP, four deltas
|
||||
|
||||
RTU is *the same Modbus application protocol* as TCP: same register model (Coils /
|
||||
Discrete Inputs / Input Registers / Holding Registers), same function codes
|
||||
(FC01–06, 15, 16, and the exception PDU convention), same data types, same read/write
|
||||
semantics. Everything the driver does above the transport seam is unchanged.
|
||||
|
||||
| Capability | Modbus TCP (today) | Modbus RTU (added) | Delta? |
|
||||
|---|---|---|---|
|
||||
| Register model + function codes | ✅ | ✅ identical | none |
|
||||
| Read (FC01/02/03/04) | ✅ | ✅ | none — same PDU |
|
||||
| Write (FC05/06/15/16) | ✅ | ✅ | none — same PDU |
|
||||
| Data-type codecs, byte order, arrays, strings, BCD, bit-in-register | ✅ | ✅ | none |
|
||||
| Read coalescing / auto-prohibit / deadband / WriteOnChangeOnly | ✅ | ✅ | none |
|
||||
| Connectivity probe (FC03@0) | ✅ | ✅ | none — goes through `SendAsync` |
|
||||
| **Transport** | TCP socket | serial line / RTU-over-TCP socket | **serial vs socket** |
|
||||
| **Framing** | 7-byte MBAP header + TxId; TCP guarantees integrity | `[addr][PDU][CRC-16]`; app-level CRC | **CRC-16 vs MBAP** |
|
||||
| **Unit/slave id** | often 1 (one device per socket); gateway multiplexing exists | **central** — one bus, multiple drop slaves addressed by unit id | **more prominent** |
|
||||
| **Timing** | TCP framing; no inter-frame constraint | **≥3.5-char inter-frame silence**, T1.5 inter-char | **timing-based framing** |
|
||||
| Browse/discovery | none | none | none (see §4) |
|
||||
| Historian / alarms | out of scope | out of scope | none |
|
||||
|
||||
**The four deltas in detail:**
|
||||
|
||||
1. **Transport** — a `SerialPort` (or a socket to a gateway) replaces the `TcpClient`.
|
||||
The socket-reconnect / keepalive / idle-disconnect logic in `ModbusTcpTransport` is
|
||||
TCP-specific and does **not** apply to a serial line (a COM port doesn't "drop" the
|
||||
way a NAT'd socket does); the RTU serial transport has its own simpler
|
||||
open/reopen-on-error model. RTU-over-TCP *does* reuse the socket lifecycle.
|
||||
2. **Framing** — RTU wraps `[slaveAddress(1)][PDU][CRC-16-lo][CRC-16-hi]`. There is no
|
||||
MBAP header and no transaction id. The CRC-16 (poly `0xA001` reflected, appended
|
||||
**low byte first**) replaces TCP's transport-level integrity. The transport computes
|
||||
CRC on send and validates on receive, treating a CRC mismatch as a
|
||||
desync/communication error (map onto the existing `ModbusTransportDesyncException` /
|
||||
`BadCommunicationError` handling).
|
||||
3. **Unit-id semantics** — on RTU the unit/slave id is *the* addressing mechanism for a
|
||||
multi-drop bus; a single serial line commonly hosts several slaves. The driver
|
||||
already supports this: `ModbusTagDefinition.UnitId` is a per-tag override and
|
||||
`ResolveUnitId` + `BuildSlaveHostName` already key per-slave resilience by
|
||||
`host:port/unitN`. Multi-drop RTU "just works" with the existing per-tag UnitId
|
||||
plumbing — the read planner already refuses to coalesce across UnitIds. (For RTU the
|
||||
per-slave "host" key becomes `COMx/unitN` or `gatewayHost:port/unitN`.)
|
||||
4. **Timing** — RTU frames are delimited by silence, not length. Requests must be
|
||||
preceded by ≥3.5 character-times of idle; responses are read until the same idle gap
|
||||
(or, preferably, by function-code-aware length). Character time depends on
|
||||
baud/word-length: at 9600 baud, 8-N-1 (10 bits/char), 3.5 chars ≈ 3.6 ms. **Above
|
||||
19200 baud the spec fixes T3.5 at 1.75 ms and T1.5 at 750 µs** rather than scaling
|
||||
further. Single-flight is mandatory: RTU has no transaction id to correlate an
|
||||
interleaved response, so at most one transaction may be in flight on a bus — the
|
||||
existing `_gate` semaphore already provides this.
|
||||
|
||||
**Read + write are both fully supported**, exactly as with Modbus TCP.
|
||||
|
||||
Sources for framing/timing/CRC claims:
|
||||
[ModbusKit RTU/ASCII/TCP comparison](https://modbuskit.com/en/blog/modbus-rtu-tcp-ascii-comprehensive-comparison),
|
||||
[ModbusSimulator RTU vs TCP](https://modbussimulator.com/blog/modbus-rtu-vs-tcp-comparison-guide),
|
||||
[Industrial Monitor Direct — TCP vs RTU-over-TCP](https://industrialmonitordirect.com/blogs/knowledgebase/modbus-tcp-vs-modbus-rtu-over-tcpip-protocol-differences).
|
||||
|
||||
---
|
||||
|
||||
## 3. Config JSON shape
|
||||
|
||||
Per-tag config is **unchanged** — the existing `ModbusTagDefinition` / `ModbusTagDto`
|
||||
(region, address, data type, byte order, `UnitId` per-tag override, etc.) already
|
||||
covers everything RTU needs. The additions are **driver-level transport fields** only.
|
||||
|
||||
Proposed additions to the driver config DTO (`ModbusDriverConfigDto`):
|
||||
|
||||
| Field | Type | Applies to | Notes |
|
||||
|---|---|---|---|
|
||||
| `Transport` | `"Tcp"` \| `"Rtu"` \| `"RtuOverTcp"` | all | Discriminator. Default `"Tcp"` (back-compat). |
|
||||
| `SerialPort` | string | Rtu | COM port / device path, e.g. `"COM3"` or `"/dev/ttyUSB0"`. |
|
||||
| `BaudRate` | int | Rtu | e.g. 9600, 19200, 38400, 115200. |
|
||||
| `DataBits` | int | Rtu | Usually 8 (RTU). |
|
||||
| `Parity` | `"None"`\|`"Even"`\|`"Odd"` | Rtu | Modbus spec default **Even**; many devices use None. |
|
||||
| `StopBits` | `"One"`\|`"Two"` | Rtu | 1 with parity, 2 without, per spec. |
|
||||
| `Host` / `Port` | string / int | Tcp, **RtuOverTcp** | Reused for RtuOverTcp — the serial-gateway's socket. |
|
||||
| `InterFrameDelayMs` | int? | Rtu | Optional override of the computed T3.5 silence for slow/RF links. |
|
||||
|
||||
`Host`/`Port`/`UnitId`/`TimeoutMs`/`MaxRegistersPerRead`/... all stay. Serial fields are
|
||||
ignored when `Transport=Tcp`; `Host`/`Port` are ignored when `Transport=Rtu`.
|
||||
|
||||
### Example A — direct serial RTU
|
||||
|
||||
```json
|
||||
{
|
||||
"Transport": "Rtu",
|
||||
"SerialPort": "/dev/ttyUSB0",
|
||||
"BaudRate": 19200,
|
||||
"DataBits": 8,
|
||||
"Parity": "Even",
|
||||
"StopBits": "One",
|
||||
"UnitId": 1,
|
||||
"TimeoutMs": 1000,
|
||||
"Tags": [
|
||||
{ "Name": "Flow", "AddressString": "40001:F:ABCD", "Writable": false },
|
||||
{ "Name": "Setpt", "AddressString": "40010:F", "Writable": true },
|
||||
{ "Name": "Pump2Run", "Region": "Coils", "Address": 0, "DataType": "Bool",
|
||||
"Writable": true, "UnitId": 2 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`Pump2Run` shows a second drop slave (UnitId 2) on the same bus — no extra transport
|
||||
config, just the per-tag `UnitId` override the driver already honours.
|
||||
|
||||
### Example B — RTU-over-TCP to a serial→Ethernet gateway
|
||||
|
||||
```json
|
||||
{
|
||||
"Transport": "RtuOverTcp",
|
||||
"Host": "10.20.0.50",
|
||||
"Port": 4001,
|
||||
"UnitId": 1,
|
||||
"TimeoutMs": 1500,
|
||||
"Tags": [
|
||||
{ "Name": "Temp", "AddressString": "30001:I" },
|
||||
{ "Name": "Alarm", "Region": "DiscreteInputs", "Address": 5, "DataType": "Bool" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Same `Host`/`Port` shape as Modbus TCP, but the wire frames are raw RTU (address +
|
||||
CRC, no MBAP). This is the difference between a gateway operating in "Modbus TCP"
|
||||
translation mode (use `Transport: "Tcp"`) versus "transparent/RTU passthrough" mode
|
||||
(use `Transport: "RtuOverTcp"`).
|
||||
|
||||
**Note:** standard Modbus/TCP masters cannot parse RTU-over-TCP frames and vice-versa —
|
||||
the two are wire-incompatible, so the `Transport` discriminator must match the
|
||||
gateway's configured mode ([Industrial Monitor Direct](https://industrialmonitordirect.com/blogs/knowledgebase/modbus-tcp-vs-modbus-rtu-over-tcpip-protocol-differences)).
|
||||
|
||||
---
|
||||
|
||||
## 4. BROWSEABILITY VERDICT — **NO**
|
||||
|
||||
**Modbus RTU is not browseable — no address-space browser is warranted.** This is
|
||||
identical to Modbus TCP. Modbus (any transport) exposes a **flat, untyped register
|
||||
space** (coils / discrete inputs / input registers / holding registers, addressed
|
||||
0–65535) with **no discovery protocol** — there is no way to enumerate which registers
|
||||
exist, what they mean, or their data types. The mapping from register → engineering
|
||||
meaning lives entirely in the device's vendor documentation, not on the wire.
|
||||
|
||||
The existing driver reflects this exactly: `ModbusDriverOptions.Tags` is documented as
|
||||
"Pre-declared tag map. Modbus has no discovery protocol — the driver returns exactly
|
||||
these," and `DiscoverAsync` simply materialises the authored tag list into a flat
|
||||
`Modbus` folder. `RediscoverPolicy` is `Once`. RTU changes none of this. The AdminUI's
|
||||
`ModbusAddressPickerBody` is an **address *builder*** (grammar helper for composing a
|
||||
register string), **not** a live browser — and that stays correct for RTU too.
|
||||
|
||||
No browser. Authoring stays manual tag entry / address-builder assisted, same as TCP.
|
||||
|
||||
---
|
||||
|
||||
## 5. Cross-platform serial reality
|
||||
|
||||
The server can run on Linux (docker) as well as Windows, so serial-port availability
|
||||
matters.
|
||||
|
||||
- **`System.IO.Ports.SerialPort` is cross-platform** on .NET (5+): it ships the
|
||||
built-in implementation for **Windows and Linux**, distributed as the
|
||||
`System.IO.Ports` NuGet package (current `10.0.x` for .NET 10). On Linux it binds
|
||||
`/dev/tty*` devices via termios.
|
||||
([NuGet System.IO.Ports](https://www.nuget.org/packages/system.io.ports/),
|
||||
[MS Q&A](https://learn.microsoft.com/en-us/answers/questions/1444956/is-system-io-ports-currently-only-support-on-windo))
|
||||
- **macOS is the weak platform.** Serial support on macOS/MacCatalyst is limited/flaky
|
||||
(baud-rate quirks, `MacCatalyst` unsupported); developers typically fall back to
|
||||
virtual serial ports for testing.
|
||||
([Mark's Blog — virtual serial ports on macOS](https://mallibone.com/post/dotnet-on-macos),
|
||||
[dotnet/runtime #43719](https://github.com/dotnet/runtime/issues/43719)).
|
||||
This matters only for the **dev machine** (this repo's dev is macOS) — production
|
||||
targets are Windows/Linux. RTU unit-testing on macOS should use fakes /
|
||||
RTU-over-TCP, not a real COM port.
|
||||
- **Containers add a device-mapping hurdle.** A serial device must be explicitly passed
|
||||
into the container: `docker run --device=/dev/ttyUSB0` (or a compose `devices:`
|
||||
entry), and USB-serial adapters can re-enumerate (`/dev/ttyUSB0` ↔ `ttyUSB1`) across
|
||||
reboots/replug, so a stable `udev` symlink or `/dev/serial/by-id/...` path is
|
||||
advisable. On Windows containers COM passthrough is notoriously unreliable.
|
||||
([Docker forums — expose host serial port](https://forums.docker.com/t/how-to-expose-host-serial-port-to-container-correctly/81588),
|
||||
[Portainer device mapping](https://oneuptime.com/blog/post/2026-03-20-map-host-devices-containers-portainer/view))
|
||||
|
||||
**Why RTU-over-TCP is the pragmatic primary path for this server.** OtOpcUa is deployed
|
||||
as a containerised server (docker-dev rig, Linux docker host at `10.100.0.35`) that is
|
||||
generally **not physically attached to an RS-485 bus.** The idiomatic industrial
|
||||
topology is a **serial→Ethernet gateway** (Moxa NPort, Digi One, Lantronix, USR-TCP232,
|
||||
etc.) sitting on the RS-485 multidrop and exposing it over TCP. The server then talks
|
||||
**RTU-over-TCP** to the gateway — a plain socket, zero host-device mapping, no
|
||||
`System.IO.Ports` dependency on the container, no udev fragility, and it reuses the
|
||||
already-hardened socket lifecycle (keepalive / idle-disconnect / reconnect-backoff)
|
||||
from `ModbusTcpTransport`. Direct `System.IO.Ports` serial should ship too (for
|
||||
bare-metal Windows/Linux installs with a local COM port or device-mapped adapter), but
|
||||
**RTU-over-TCP is the path most deployments will actually use**, and it's the lower-risk
|
||||
one to build and test.
|
||||
|
||||
---
|
||||
|
||||
## 6. Test-fixture strategy
|
||||
|
||||
Three complementary options, in rough order of value for this repo:
|
||||
|
||||
1. **RTU-over-TCP against pymodbus (highest ROI, no serial hardware).** The existing
|
||||
Modbus fixture already runs `pymodbus.simulator` in docker
|
||||
(`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/`,
|
||||
binding `:5020`). pymodbus can serve an **RTU-framed TCP** server, so an
|
||||
`rtu_over_tcp` profile alongside the existing `standard`/`dl205`/`mitsubishi`/
|
||||
`exception_injection` profiles exercises the real RTU framing + CRC path end-to-end
|
||||
with **no serial anything** — same harness, same docker host, same
|
||||
`lmxopcua-fix up modbus <profile>` workflow. This validates the CRC codec and the
|
||||
function-code-aware framing, which is the only genuinely new logic.
|
||||
|
||||
2. **Virtual serial pair on Linux for the direct-serial transport.** `socat -d -d
|
||||
pty,raw,echo=0 pty,raw,echo=0` creates a linked `/dev/pts/N` ↔ `/dev/pts/M` pair;
|
||||
point a pymodbus **RTU serial** slave at one end and `ModbusRtuTransport` at the
|
||||
other. (`com0com` is the Windows equivalent.) This is the only way to cover the real
|
||||
`System.IO.Ports` open/read/write path without hardware, and it runs in a Linux
|
||||
container or on the docker host. macOS dev can't easily do this — run it on the
|
||||
Linux docker host or in CI.
|
||||
|
||||
3. **A dedicated RTU slave simulator** — `diagslave` (serial + TCP RTU modes) or
|
||||
ModbusPal — for manual / soak testing against a virtual pair or a real USB-serial
|
||||
adapter. Useful for the eventual live-gate but not for unit CI.
|
||||
|
||||
**Unit-level:** the CRC-16 helper gets a table-driven unit test against known Modbus CRC
|
||||
vectors, and `ModbusRtuTransport`/`ModbusRtuOverTcpTransport` can be tested with an
|
||||
in-memory duplex stream fake (the driver already fakes `IModbusTransport`; here we fake
|
||||
one level lower, the byte stream, to assert framing + CRC + response parsing). No PLC
|
||||
needed for the bulk of coverage.
|
||||
|
||||
**Recommended CI shape:** unit tests for CRC + RTU framing (fake stream) + an
|
||||
`rtu_over_tcp` pymodbus docker profile for integration; defer real-serial (socat pair /
|
||||
hardware) to an env-gated live suite like the other driver live gates.
|
||||
|
||||
---
|
||||
|
||||
## 7. Effort / risk
|
||||
|
||||
**Effort: LOW — likely the lowest-effort item on the driver roadmap.** Because the PDU
|
||||
layer is already transport-agnostic and injected, the net-new code is small and
|
||||
localised:
|
||||
|
||||
- `ModbusCrc` helper (~30 lines) + unit test.
|
||||
- `ModbusRtuOverTcpTransport` — can largely reuse `ModbusTcpTransport`'s socket
|
||||
lifecycle; the delta is CRC framing + FC-aware response length (no TxId). Extracting
|
||||
the shared socket lifecycle into a base/helper is the main refactor, and it's
|
||||
mechanical.
|
||||
- `ModbusRtuTransport` (serial) — `SerialPort` open + the same framing + T3.5 timing.
|
||||
- Config: `Transport` discriminator + serial fields on `ModbusDriverOptions`, the DTO,
|
||||
and the factory closure (~1 file each).
|
||||
- AdminUI: a serial-parameters panel on the existing `ModbusDriverPage.razor`, shown
|
||||
when `Transport != Tcp`, + the matching config-model round-trip. **Watch the known
|
||||
enum-serialization trap** (per project memory: driver pages serialize enums
|
||||
numerically but factory DTOs are string-typed — add `JsonStringEnumConverter` so
|
||||
`Transport`/`Parity`/`StopBits` round-trip as strings, mirroring OpcUaClient).
|
||||
- **Zero** changes to codecs, planner, coalescing, health, materialisation, HistoryRead,
|
||||
or the address parser.
|
||||
|
||||
**Risks (all manageable):**
|
||||
|
||||
- **RTU response framing without a length field** is the one novel piece of logic —
|
||||
get the function-code-aware length calculation (and exception-PDU short-frame
|
||||
detection) right, or fall back to idle-gap timeout. Cover with the fake-stream unit
|
||||
tests.
|
||||
- **Serial timing on Linux / in containers** — `System.IO.Ports` on Linux honours
|
||||
read timeouts but fine-grained T1.5/T3.5 inter-character gating is best-effort; slow
|
||||
or long RS-485/RF runs may need the `InterFrameDelayMs` override. This is the top
|
||||
residual risk and the reason to lead with RTU-over-TCP.
|
||||
- **macOS dev can't exercise real serial** — mitigated by making RTU-over-TCP the
|
||||
primary tested path and running the socat/serial suite on the Linux docker host / CI,
|
||||
not the dev Mac.
|
||||
- **USB-serial device enumeration** in containers (`/dev/ttyUSB*` renumbering) — a
|
||||
deployment/ops concern, addressed with `--device` + stable `by-id` paths, not a code
|
||||
risk.
|
||||
|
||||
**Bottom line:** small, additive, low-risk. Ship RTU-over-TCP first (reuses the hardened
|
||||
socket path, no host-device dependency, testable on the existing pymodbus docker
|
||||
harness), then direct `System.IO.Ports` serial for bare-metal installs.
|
||||
|
||||
---
|
||||
|
||||
## Key source files (for the implementer)
|
||||
|
||||
- `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/IModbusTransport.cs` — the seam RTU
|
||||
plugs into.
|
||||
- `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusTcpTransport.cs` — the reference
|
||||
transport; RTU-over-TCP reuses its socket lifecycle.
|
||||
- `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs` — transport-agnostic PDU
|
||||
builders + factory injection point (`transportFactory`).
|
||||
- `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusDriverOptions.cs` +
|
||||
`ModbusEquipmentTagParser.cs` — where the transport-mode + serial options are added.
|
||||
- `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverFactoryExtensions.cs` — DTO +
|
||||
transport selection.
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/ModbusDriverPage.razor`
|
||||
— driver config UI to extend.
|
||||
- `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/` — pymodbus
|
||||
fixture to add an `rtu_over_tcp` profile to.
|
||||
|
||||
## Sources
|
||||
|
||||
- [ModbusKit — RTU vs ASCII vs TCP comparison](https://modbuskit.com/en/blog/modbus-rtu-tcp-ascii-comprehensive-comparison)
|
||||
- [ModbusSimulator — RTU vs TCP guide](https://modbussimulator.com/blog/modbus-rtu-vs-tcp-comparison-guide)
|
||||
- [Industrial Monitor Direct — Modbus TCP vs RTU-over-TCP protocol differences](https://industrialmonitordirect.com/blogs/knowledgebase/modbus-tcp-vs-modbus-rtu-over-tcpip-protocol-differences)
|
||||
- [NModbus (C# Modbus, supports serial RTU/ASCII/TCP/UDP)](https://github.com/NModbus/NModbus)
|
||||
- [NuGet — System.IO.Ports](https://www.nuget.org/packages/system.io.ports/)
|
||||
- [Microsoft Q&A — System.IO.Ports platform support](https://learn.microsoft.com/en-us/answers/questions/1444956/is-system-io-ports-currently-only-support-on-windo)
|
||||
- [Mark's Blog — .NET virtual serial ports on macOS](https://mallibone.com/post/dotnet-on-macos)
|
||||
- [dotnet/runtime #43719 — macOS SerialPort baud limitation](https://github.com/dotnet/runtime/issues/43719)
|
||||
- [Docker forums — expose host serial port to container](https://forums.docker.com/t/how-to-expose-host-serial-port-to-container-correctly/81588)
|
||||
- [Portainer — mapping host serial/USB devices to containers](https://oneuptime.com/blog/post/2026-03-20-map-host-devices-containers-portainer/view)
|
||||
@@ -0,0 +1,292 @@
|
||||
# MQTT / Sparkplug B Driver — Research & Design
|
||||
|
||||
**Status:** Research / design proposal (not yet implemented)
|
||||
**Author:** research sweep, 2026-07-15
|
||||
**Scope:** A standard **Equipment-kind** driver (same shape as Modbus/S7/AbCip/TwinCAT/FOCAS/OpcUaClient) that ingests **plain MQTT** and **Sparkplug B** data into the OtOpcUa OPC UA / UNS address space.
|
||||
|
||||
This is a **subscribe-first** driver: the broker pushes data, we do not poll. Because OtOpcUa is a Unified-Namespace product, Sparkplug B alignment (the de-facto MQTT UNS payload standard) is the headline capability, with plain-MQTT/JSON as the broad-compatibility fallback.
|
||||
|
||||
---
|
||||
|
||||
## 1. Protocol summary + .NET library options
|
||||
|
||||
### 1.1 Plain MQTT
|
||||
|
||||
MQTT is a lightweight pub/sub transport: a client connects to a **broker**, subscribes to **topic filters** (with `+` single-level and `#` multi-level wildcards), and receives **retained** and live messages. Payloads are opaque byte arrays — by convention JSON, a scalar string/number, or a binary blob. There is no schema, no data-type metadata, and no built-in discovery: a consumer only knows a topic exists once a message arrives on it. "Last value" is available only if the publisher set the **retain** flag (the broker keeps the last retained message per topic and replays it to new subscribers).
|
||||
|
||||
Relevant knobs: QoS 0/1/2, `retain`, Last-Will-and-Testament (LWT), clean-session vs. persistent-session, TLS, username/password or client-cert auth, MQTT 3.1.1 vs 5.0.
|
||||
|
||||
### 1.2 Sparkplug B (Eclipse Tahu / Eclipse Sparkplug, spec v3.0)
|
||||
|
||||
Sparkplug B is an **open specification layered on MQTT** that adds the missing pieces for industrial data: a **mandated topic namespace**, a **protobuf payload schema**, **stateful session management**, and **auto-discovery via birth certificates**. It is the dominant "UNS over MQTT" standard.
|
||||
|
||||
**Topic namespace:** `spBv1.0/{group_id}/{message_type}/{edge_node_id}[/{device_id}]`
|
||||
- `group_id` — logical grouping (e.g. a site/area).
|
||||
- `edge_node_id` — an MQTT Edge-of-Network (EoN) node.
|
||||
- `device_id` — an optional physical device attached to the edge node.
|
||||
|
||||
**Message types:**
|
||||
|
||||
| Type | Meaning | Direction |
|
||||
|---|---|---|
|
||||
| `NBIRTH` | Edge-node **birth certificate** — enumerates every node-level metric with name, alias, datatype, and initial value; carries `bdSeq` | edge → host |
|
||||
| `DBIRTH` | Device **birth certificate** — enumerates a device's metrics (name/alias/datatype/value) | edge → host |
|
||||
| `NDATA` / `DDATA` | Node/device **data** — metric changes, usually referenced by **alias** only (no name) | edge → host |
|
||||
| `NDEATH` / `DDEATH` | Node/device **death** — LWT-driven; marks metrics STALE/uncertain | edge → host |
|
||||
| `NCMD` / `DCMD` | **Command / write** to a node or device metric (e.g. setpoint, or `Node Control/Rebirth`) | host → edge |
|
||||
| `STATE` | **Primary-host** application online/offline status (`ONLINE`/`OFFLINE`), retained, LWT-backed | host → edge |
|
||||
|
||||
**Key semantics the driver must honour:**
|
||||
- **Birth-certificate discovery.** DBIRTH/NBIRTH is the *only* place metric **names + datatypes** appear. A consumer must cache the birth to interpret later data. This is exactly what makes Sparkplug **browsable** (§4).
|
||||
- **Aliases.** After birth, NDATA/DDATA typically send only a numeric `alias` + value to save bandwidth. The consumer must maintain an **alias → (name, datatype)** map per edge-node/device, rebuilt on every (re)birth.
|
||||
- **Sequence numbers.** Every Sparkplug payload carries a `seq` (0–255, wraps) for gap detection; NBIRTH resets the sequence. `bdSeq` (birth/death sequence) in NBIRTH/NDEATH ties a death to its birth.
|
||||
- **Rebirth.** If the host sees a gap, an unknown alias, or connects late (missed the birth), it issues an `NCMD` writing boolean `Node Control/Rebirth = true`; the edge node re-publishes NBIRTH + all DBIRTHs. This is the recovery primitive that lets a late-joining consumer recover full metadata.
|
||||
- **Primary host / STATE.** A "primary host application" publishes a retained `STATE` message (with LWT set to `OFFLINE`, QoS 1, retain=true) so edge nodes know a trusted consumer is online. There **MUST be at most one** primary host client per host-id on a broker. OtOpcUa can consume as a non-primary application, or opt in as primary host (needed for guaranteed store-and-forward semantics on some edge devices).
|
||||
- **Metric datatypes** (protobuf `DataType` enum): `Int8/16/32/64`, `UInt8/16/32/64`, `Float`, `Double`, `Boolean`, `String`, `DateTime`, `Text`, `UUID`, `Bytes`, `File`, `DataSet`, `Template`, plus array variants (`Int8Array`, …). Each metric = `{name, alias, timestamp, datatype, value, is_historical, is_transient, is_null}`.
|
||||
|
||||
Sources: [Eclipse Sparkplug spec (PDF, v2.2 — namespace/STATE rules carry into v3.0)](https://sparkplug.eclipse.org/specification/version/2.2/documents/sparkplug-specification-2.2.pdf) · [Eclipse Sparkplug normative statements](https://github.com/eclipse-sparkplug/sparkplug/blob/master/docs/normative_statements.md) · [Tahu `sparkplug_b.proto`](https://raw.githubusercontent.com/eclipse/tahu/master/sparkplug_b/sparkplug_b.proto) · [HiveMQ: Sparkplug session state](https://www.hivemq.com/blog/understanding-mqtt-topic-namespace-iiot/) · [Steve's Internet Guide: Sparkplug payloads/messages](http://www.steves-internet-guide.com/sparkplug-payloads-and-messages/) · [EMQX Sparkplug docs](https://docs.emqx.com/en/emqx/latest/data-integration/sparkplug.html)
|
||||
|
||||
### 1.3 .NET library options
|
||||
|
||||
| Library | License | Maturity | .NET 10 | Notes |
|
||||
|---|---|---|---|---|
|
||||
| **MQTTnet** | **MIT** | **High.** Now a **.NET Foundation** project hosted under **`dotnet/MQTTnet`**; the standard .NET MQTT client+server. Millions of NuGet downloads, active. v5.x current. | **Yes** — v5 explicitly added `dotnet10` target. | Client + broker, MQTT 3.1.1 & 5.0, TLS, WebSocket. This is the transport layer for **both** modes. |
|
||||
| **SparkplugNet** | **MIT** | **Moderate.** Single-maintainer (SeppPenner), regular releases; latest **1.3.10 (2024-07-02)**. Supports Sparkplug **v3.0 / spBv1.0** (spAv1.0 obsolete). Provides `Application`, `Node`, `Device` base classes; the **Application** role receives N/DBIRTH + N/DDATA and can publish NCMD/DCMD. Lower adoption than MQTTnet. | **Targets net8.0/net9.0** today (plus "latest/LTS Core & Framework"). **No explicit net10 TFM yet** — will run on .NET 10 via net9.0 compat, but confirm at build time; may need an upstream TFM bump or a fork pin. | Wraps MQTTnet + `protobuf-net` internally. Depends on `MQTTnet >= 4.3.x` — watch for a **version clash** with a directly-referenced MQTTnet 5.x (see risk §6). |
|
||||
| **MQTTnet + hand-decoded Tahu protobuf** | MIT (+ Tahu, EPL/Apache) | Build-it-yourself | Yes | Reference the Tahu `sparkplug_b.proto`, generate C# with `protobuf-net` or `Google.Protobuf`, decode payloads ourselves on top of an MQTTnet subscription. Maximum control (aliases, seq, rebirth logic exactly as we want), no third-party Sparkplug abstraction, but we own all the state-machine code. |
|
||||
|
||||
**Recommendation:** Use **MQTTnet (v5, MIT, .NET Foundation, net10-ready)** as the transport for both modes. For Sparkplug decoding, **start by evaluating SparkplugNet** to save the birth/alias/rebirth plumbing, but treat the **"MQTTnet + Tahu-proto hand-decode"** path as the fallback if SparkplugNet's net10 support or its internal MQTTnet-4.x pin proves awkward. The Sparkplug protobuf schema is small and stable, so hand-decoding is a bounded, well-understood effort.
|
||||
|
||||
Sources: [MQTTnet on NuGet (v5.2.0)](https://www.nuget.org/packages/MQTTnet/) · [dotnet/MQTTnet (GitHub)](https://github.com/dotnet/MQTTnet) · [MQTTnet — .NET Foundation](https://old.dotnetfoundation.org/projects/mqttnet) · [SparkplugNet on NuGet](https://www.nuget.org/packages/SparkplugNet) · [SparkplugNet (GitHub, SeppPenner)](https://github.com/SeppPenner/SparkplugNet)
|
||||
|
||||
---
|
||||
|
||||
## 2. Capability mapping
|
||||
|
||||
The driver implements the composable `IDriver` capability interfaces from `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/`. Recommended set: `IDriver` + `ITagDiscovery` + `ISubscribable` + `IReadable` + `IHostConnectivityProbe` + `IRediscoverable`, and **optionally** `IWritable` (phase 2). Notably **not** a poller — unlike Modbus/S7 it does not use `PollGroupEngine`; it keeps a live broker connection and raises `OnDataChange` from the MQTT receive callback (closest existing analog: the native-push side of the OpcUaClient driver).
|
||||
|
||||
### 2.1 Subscribe (primary — `ISubscribable`)
|
||||
|
||||
The core of the driver. On `InitializeAsync`, connect to the broker (MQTTnet), set LWT/clean-session, and subscribe to the configured topic filters:
|
||||
- **Plain mode:** subscribe to each authored tag's topic (or a shared wildcard) and to nothing else.
|
||||
- **Sparkplug mode:** subscribe to `spBv1.0/{group_id}/#` (and `STATE/#` if primary host). Maintain per-edge-node/device **alias→metric** tables from N/DBIRTH; on N/DDATA, resolve each metric and raise `OnDataChange` with the authored tag's `FullReference`. On NDEATH/DDEATH, publish STALE/Bad-quality snapshots for that node's metrics. On a missed birth / unknown alias / seq gap, issue a **rebirth NCMD**.
|
||||
|
||||
`SubscribeAsync(fullReferences, publishingInterval, …)` maps requested `FullReference`s onto the live receive stream. MQTT/Sparkplug are inherently event-driven, so `publishingInterval` is advisory (used only for optional server-side coalescing/deadband, mirroring Modbus's `ShouldPublish`). The driver holds the broker connection for its whole lifetime; `OnDataChange` fires from the MQTT message handler. Emit an initial value from the retained message / last birth value on subscribe (OPC UA initial-data convention).
|
||||
|
||||
### 2.2 Discover (`ITagDiscovery`)
|
||||
|
||||
- **Sparkplug mode (rich):** DBIRTH/NBIRTH **enumerates** every metric with name + datatype → map directly onto Equipment + Tags. Natural UNS shape: `group_id` → Area/Line, `edge_node_id`/`device_id` → **Equipment**, each **metric → Tag**. Because births arrive asynchronously after connect, use `RediscoverPolicy = UntilStable` (like FOCAS) — keep discovering until the observed birth set stops growing, and implement `IRediscoverable` so a *new* DBIRTH (new device joins, or a rebirth with changed metrics) triggers an address-space rebuild.
|
||||
- **Plain mode (passive):** no schema. Two options: (a) **authored-only** — the operator declares tags (topic + JSON path) up front, `DiscoverAsync` returns exactly those (like Modbus's pre-declared `Tags`, `RediscoverPolicy = Once`); or (b) **observe-and-suggest** — subscribe `#` for a bounded window, build the observed topic tree, surface it in the browser (§4) for the operator to pick. Runtime discovery stays authored-only; wildcard observation is a **browse-time** concern, not an auto-provisioning one (avoids unbounded address spaces from chatty brokers).
|
||||
|
||||
### 2.3 Read (`IReadable`)
|
||||
|
||||
MQTT has no request/response read. `ReadAsync` returns the **last-value cache**: the last message seen on the topic (plain) or the last metric value from data/birth (Sparkplug). Seed it from the broker's **retained** message at connect (plain) and from birth values (Sparkplug). If no value has been observed yet, return `GoodNoData`/uncertain rather than an error. This satisfies OPC UA reads and HistoryRead-at-time without a live round-trip.
|
||||
|
||||
### 2.4 Write (`IWritable`) — **verdict: defer to phase 2, not in v1**
|
||||
|
||||
Writes are possible but semantically heavier and lower-priority for a UNS-ingest product:
|
||||
- **Sparkplug:** publish an **NCMD/DCMD** to `spBv1.0/{group}/NCMD/{node}[/{device}]` with the target metric (by name/alias) + new value. Fire-and-forget at the MQTT layer — there is **no synchronous write ack**; success is only observable when the edge node echoes the change back in the next N/DDATA. This mirrors the Galaxy gateway's fire-and-forget write (Galaxy "can never surface a write failure"), so a write returns `Good` optimistically and the value self-corrects on the next data message. Respect `WriteIdempotent` — Sparkplug commands (rebirth, pulse) are frequently non-idempotent.
|
||||
- **Plain:** publish to a configured command/write topic (often distinct from the read topic) with a formatted payload.
|
||||
|
||||
**Recommendation:** ship v1 **read/subscribe/discover only** (a genuinely useful UNS-ingest driver on its own), add `IWritable` (NCMD/DCMD + plain publish) in phase 2. Rationale: the write path has no ack model, needs careful idempotency/rebirth handling, and most UNS-ingest deployments are read-only consumers.
|
||||
|
||||
### 2.5 Data-type mapping (`DriverDataType`)
|
||||
|
||||
Map Sparkplug metric datatypes / inferred JSON types to `DriverDataType` (`src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverDataType.cs`):
|
||||
|
||||
| Sparkplug `DataType` | `DriverDataType` | OPC UA |
|
||||
|---|---|---|
|
||||
| Int8, Int16 | Int16 | Int16 |
|
||||
| Int32, UInt16 | Int32 / UInt16 | Int32 / UInt16 |
|
||||
| Int64, UInt32 | Int64 / UInt32 | Int64 / UInt32 |
|
||||
| UInt64 | UInt64 | UInt64 |
|
||||
| Float | Float32 | Float |
|
||||
| Double | Double64 → **Float64** | Double |
|
||||
| Boolean | Boolean | Boolean |
|
||||
| String, Text, UUID | String | String |
|
||||
| DateTime | DateTime | DateTime |
|
||||
| Bytes, File | (String/ByteString) | ByteString — phase 2 |
|
||||
| DataSet, Template | **unsupported v1** | skip / raw-JSON string |
|
||||
| `*Array` variants | `IsArray=true` + element type | ValueRank=1 |
|
||||
|
||||
Plain-MQTT/JSON: infer from the JSON token at the configured JSON path (number→Double/Int64, bool→Boolean, string→String), or honour an explicit per-tag `dataType` override (preferred — inference is brittle). `Int8`/`UInt8` widen to `Int16`/`UInt16` (no OPC UA byte-signed distinction needed). Complex `DataSet`/`Template` metrics are out of scope for v1 (flag + skip, or expose the raw JSON as a String tag).
|
||||
|
||||
---
|
||||
|
||||
## 3. TagConfig JSON shape
|
||||
|
||||
Two layers, mirroring every other driver: **driver options** (broker connection, one per driver instance) parsed by the driver factory, and **per-tag `TagConfig.FullName` + config** authored on the `/uns` Equipment → Tags tab.
|
||||
|
||||
### 3.1 Driver options (`MqttDriverOptions`, in `.Contracts`)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"host": "10.100.0.35",
|
||||
"port": 8883,
|
||||
"clientId": "otopcua-uns-1",
|
||||
"useTls": true,
|
||||
"allowUntrustedServerCertificate": false,
|
||||
"caCertificatePath": null,
|
||||
"username": "otopcua",
|
||||
"password": "", // supply via env, never commit
|
||||
"protocolVersion": "V500", // MQTT 3.1.1 | 5.0
|
||||
"cleanSession": true,
|
||||
"keepAliveSeconds": 30,
|
||||
"reconnectMinBackoffSeconds": 1,
|
||||
"reconnectMaxBackoffSeconds": 30,
|
||||
|
||||
"mode": "SparkplugB", // "Plain" | "SparkplugB"
|
||||
|
||||
// ---- Sparkplug-only ----
|
||||
"sparkplug": {
|
||||
"groupId": "Plant1", // subscribe spBv1.0/Plant1/#
|
||||
"hostId": "otopcua-host-1", // STATE/{hostId} identity
|
||||
"actAsPrimaryHost": false,
|
||||
"requestRebirthOnGap": true,
|
||||
"birthObservationWindowSeconds": 15 // discovery: how long to collect births
|
||||
},
|
||||
|
||||
// ---- Plain-only ----
|
||||
"plain": {
|
||||
"topicPrefix": "factory/",
|
||||
"defaultQos": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 Per-tag config — Sparkplug
|
||||
|
||||
`TagConfig.FullName` is the driver-side full reference the router resolves. For Sparkplug the natural key is the fully-qualified metric path; store the full descriptor in the tag config so the driver can resolve by **name or alias** and survive alias reassignment across rebirths.
|
||||
|
||||
```jsonc
|
||||
// TagConfig for one Sparkplug metric tag
|
||||
{
|
||||
"groupId": "Plant1",
|
||||
"edgeNodeId": "Line3EdgeNode",
|
||||
"deviceId": "Filler1", // omit/null for node-level metrics
|
||||
"metricName": "Temperature/degC", // stable identity across rebirths
|
||||
"dataType": "Float", // from DBIRTH; explicit for safety
|
||||
"isHistorized": false
|
||||
}
|
||||
// FullName convention: "Plant1/Line3EdgeNode/Filler1:Temperature/degC"
|
||||
```
|
||||
|
||||
### 3.3 Per-tag config — Plain MQTT
|
||||
|
||||
```jsonc
|
||||
// TagConfig for one plain-MQTT tag
|
||||
{
|
||||
"topic": "factory/line3/oven/temp",
|
||||
"payloadFormat": "Json", // "Json" | "Raw" | "Scalar"
|
||||
"jsonPath": "$.value", // JSONPath into the payload (Json only)
|
||||
"dataType": "Double", // explicit — inference is a fallback
|
||||
"qos": 1,
|
||||
"retainSeed": true // seed last-value from retained msg
|
||||
}
|
||||
// FullName convention: "factory/line3/oven/temp#$.value"
|
||||
```
|
||||
|
||||
Resolution mirrors Modbus's `EquipmentTagRefResolver<TDef>`: the router hands the driver a `FullReference` (the raw `TagConfig` JSON or a stable string), the driver parses+caches it once, and matches inbound messages/metrics against it.
|
||||
|
||||
---
|
||||
|
||||
## 4. Browseability verdict + browse design
|
||||
|
||||
### Verdict: **BROWSEABLE — both modes, but discovery is passive and time-bounded.**
|
||||
|
||||
Unlike an OPC UA server (synchronous request/response `Browse`) or a Galaxy repository (queryable DB), MQTT/Sparkplug discovery is **observational**: you learn what exists only by *listening* for a while. The browse session therefore **connects to the broker and observes for a bounded window**, accumulating a tree, then answers `ExpandAsync` from that accumulated snapshot. This is the central design wrinkle.
|
||||
|
||||
- **Sparkplug (rich, structured):** subscribe `spBv1.0/{group}/#`, optionally publish a **rebirth NCMD** to force every edge node to re-announce immediately (turns a passive wait into a near-synchronous enumeration), and collect NBIRTH/DBIRTH. Births yield a clean, typed tree: **Group → EdgeNode → Device → Metric**, with datatypes — directly pickable, no guessing.
|
||||
- **Plain (best-effort):** subscribe `#` (or a configured prefix) for the observation window and build the **observed topic tree**, splitting each topic on `/`. Leaves are topics that have carried a payload; the picker shows the last payload + inferred type as `AttributesAsync`. Coverage is only as good as broker traffic during the window (a topic silent during the window is invisible) — clearly a "suggestions" browse, not an authoritative enumeration.
|
||||
|
||||
### `IDriverBrowser` / `IBrowseSession` design
|
||||
|
||||
New project **`ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser`** (parallels `.OpcUaClient.Browser` / `.Galaxy.Browser`), implementing `Commons/Browsing/IDriverBrowser` + `IBrowseSession`, registered in `EndpointRouteBuilderExtensions` alongside the others:
|
||||
|
||||
```csharp
|
||||
// EndpointRouteBuilderExtensions.cs (~line 49)
|
||||
services.AddSingleton<IDriverBrowser, OpcUaClientDriverBrowser>();
|
||||
services.AddSingleton<IDriverBrowser, GalaxyDriverBrowser>();
|
||||
services.AddSingleton<IDriverBrowser, MqttDriverBrowser>(); // NEW
|
||||
```
|
||||
|
||||
**`MqttDriverBrowser.OpenAsync(configJson, ct)`** — connect an MQTTnet client from the same `MqttDriverOptions` JSON the runtime driver consumes (separate client-id suffix so it never collides with the running driver's session), subscribe to the discovery filter, and **kick off the observation window immediately**. Returns an `MqttBrowseSession` whose internal tree fills in over the window (and keeps filling as long as the session lives). Because `IBrowseSession` methods are async and the session is registered in the `BrowseSessionRegistry` (TTL-reaped, like the OPC UA one), the "wait for observation" cost is naturally amortised across the user's clicks in the picker.
|
||||
|
||||
**`MqttBrowseSession`:**
|
||||
- `RootAsync` — Sparkplug: the set of observed **groups** (or edge nodes under the configured group). Plain: top-level topic segments. Returns `BrowseNode { Kind = Folder, HasChildrenHint = true }`. If the window hasn't yielded anything yet, return what's accumulated so far (possibly empty) and let the user re-expand — or briefly await the first births. Optionally trigger a rebirth NCMD on first `RootAsync` in Sparkplug mode to populate fast.
|
||||
- `ExpandAsync(nodeId)` — walk one level down the accumulated tree (EdgeNode→Device→Metric, or next topic segment). Metrics / leaf-topics are `Kind = Leaf`.
|
||||
- `AttributesAsync(nodeId)` — Sparkplug: return the metric's `AttributeInfo { Name, DriverDataType, IsArray, SecurityClass }` from the cached birth. Plain: return the leaf topic's inferred type + last-seen payload snippet as a single synthetic attribute. (Sparkplug leaves are self-describing, so unlike OPC UA the side-panel is populated.)
|
||||
- `DisposeAsync` — disconnect the MQTTnet client; best-effort (registry reaper may race a disconnect), same pattern as `OpcUaClientBrowseSession`.
|
||||
|
||||
**Picked node → TagConfig:** on commit, the picker projects the selected `BrowseNode`/`AttributeInfo` into the §3 TagConfig JSON. Sparkplug: `NodeId` encodes `group/node/device:metric`, split into the descriptor fields. Plain: `NodeId` is the topic path → `topic` + a default `jsonPath` (`$` or operator-edited) + inferred `dataType`.
|
||||
|
||||
**Addressing the passive/time-bounded wrinkle explicitly:**
|
||||
1. Show a live "listening… N nodes/topics discovered" affordance in the picker so the operator understands coverage is accumulating, not instantaneous.
|
||||
2. In Sparkplug mode, **actively force a rebirth** to convert the passive wait into a fast, near-complete enumeration.
|
||||
3. Keep the browse session alive (registry TTL) so re-expanding later reflects newly observed topics without reconnecting.
|
||||
4. Always allow **manual entry** of a topic/metric (the typed tag editor, §7) as an escape hatch when a tag is silent during the window.
|
||||
|
||||
---
|
||||
|
||||
## 5. Test-fixture strategy
|
||||
|
||||
Follow the existing driver-fixture pattern: a `docker-compose.yml` under `tests/Drivers/.../Docker/` with the `project: lmxopcua` label, deployed to the shared Docker host `10.100.0.35` via `lmxopcua-fix sync/up` (see `CLAUDE.md` Docker Workflow).
|
||||
|
||||
- **Broker:** **Eclipse Mosquitto** (`eclipse-mosquitto`, tiny, MIT/EPL) as the default fixture — carries both plain MQTT and Sparkplug B unchanged. Alternative **EMQX** (`emqx/emqx`, Apache-2.0, open-source CE up to 1000 conns) when a management dashboard or built-in Sparkplug tooling is wanted. HiveMQ CE is a third option.
|
||||
- **Sparkplug edge-node simulator:** options, in rough order of convenience —
|
||||
- **Bevywise MQTT/IoT Simulator** — purpose-built Sparkplug B realtime data simulation.
|
||||
- **Eclipse Tahu** reference implementations (Java/Python) — author a small edge-node script that publishes NBIRTH/DBIRTH then periodic NDATA/DDATA; also exercises rebirth (subscribe NCMD `Node Control/Rebirth`). Most faithful to the spec.
|
||||
- A **project-owned C# simulator** using the same MQTTnet + Tahu-proto path the driver uses (kills two birds: validates our encode/decode symmetrically).
|
||||
- **Plain-MQTT fixture:** a tiny publisher container (`mosquitto_pub` loop, or a Python `paho-mqtt` script) emitting JSON on a handful of topics with `retain=true` so the last-value/read path and `#`-observation browse are testable.
|
||||
- **Public test brokers** (for ad-hoc manual smoke only, never CI — unauthenticated, rate-limited, no privacy): `test.mosquitto.org`, `broker.hivemq.com`, `broker.emqx.io`. Do **not** put Sparkplug production-shaped data on public brokers.
|
||||
- **Live-gate pattern:** an env-gated `*.IntegrationTests` suite (à la the HistorianGateway `LiveIntegration` category) that skips cleanly when `MQTT_FIXTURE_ENDPOINT` is unset, so the suite is macOS-offline-safe.
|
||||
|
||||
Sources: [Cedalo: Sparkplug B on Mosquitto](https://www.cedalo.com/blog/mqtt-sparkplug-mosquitto) · [EMQ: MQTT Sparkplug step-by-step](https://www.emqx.com/en/blog/mqtt-sparkplug-in-action-a-step-by-step-tutorial) · [Bevywise Sparkplug simulation](https://www.bevywise.com/blog/sparkplug-b-mqtt-simulation/) · [Ian Craggs: getting started with MQTT + Sparkplug](https://modelbasedtesting.co.uk/2022/01/22/getting-started-with-mqtt-and-sparkplug/)
|
||||
|
||||
---
|
||||
|
||||
## 6. Effort / risk / phasing
|
||||
|
||||
### Recommended order: **plain MQTT first, then Sparkplug B.**
|
||||
|
||||
Even though Sparkplug is the headline UNS capability, plain MQTT is the smaller, dependency-light slice that stands up the whole driver skeleton (project layout, `MqttDriverOptions`, factory/probe registration, `ISubscribable` receive loop, last-value `IReadable`, browser project, typed tag editor, fixtures). Sparkplug then layers the birth/alias/rebirth state machine + protobuf decode onto a proven connection+subscribe substrate. This also front-loads the reusable plumbing and de-risks the library decision (you can validate MQTTnet v5/net10 before committing to SparkplugNet).
|
||||
|
||||
| Phase | Scope | Rel. effort |
|
||||
|---|---|---|
|
||||
| **1 — Plain MQTT** | Projects (`Driver.Mqtt`, `.Contracts`, `.Browser`) + MQTTnet connect/TLS/auth/reconnect; `ISubscribable` (topic subscribe → `OnDataChange`); `IReadable` last-value (retained seed); authored-tag `ITagDiscovery`; `IHostConnectivityProbe`; `#`-observation browser; typed tag editor + validator; Mosquitto + JSON-publisher fixtures. | **M** |
|
||||
| **2 — Sparkplug B ingest** | `spBv1.0/{group}/#` subscribe; Tahu-proto decode (SparkplugNet **or** hand-rolled); N/DBIRTH → discovery (`UntilStable` + `IRediscoverable`); alias tables; seq-gap detect + **rebirth NCMD**; N/DDEATH → STALE; STATE/primary-host option; birth-driven browser + `AttributesAsync`; Sparkplug datatype map; Tahu/Bevywise simulator fixture. | **L** |
|
||||
| **3 — Write-through (optional)** | `IWritable`: Sparkplug NCMD/DCMD + plain publish; optimistic-Good + self-correct on echo; `WriteIdempotent` respect; write-topic config. | **M** |
|
||||
|
||||
### Top risks
|
||||
|
||||
1. **Sparkplug state-machine correctness (aliases + rebirth + seq).** The alias→metric mapping, sequence-gap detection, late-join rebirth, and death→STALE handling are the load-bearing logic and easy to get subtly wrong (e.g. an alias reused across a rebirth with a *different* metric silently mis-routes data to the wrong tag). Mitigation: bind tags by **stable metric name**, treat alias as a per-birth cache only; rebuild the alias table on every (re)birth; test explicitly against rebirth + missed-birth + gap scenarios with a controllable simulator. This is the single biggest correctness risk.
|
||||
|
||||
2. **Library / dependency friction (SparkplugNet ↔ MQTTnet version + net10).** SparkplugNet pins **MQTTnet 4.3.x** and has **no explicit net10 TFM** yet; a direct MQTTnet-5 reference (needed for the plain-mode transport + net10 support) can clash with SparkplugNet's transitive 4.x, and central-package-version pinning in this repo is already known to be fragile (see the Roslyn transitive-pinning memory). Mitigation: prototype the version matrix early; be ready to drop SparkplugNet and hand-decode the Tahu proto over a single MQTTnet-5 client (the proto is small/stable), which also removes the net10 TFM concern entirely.
|
||||
|
||||
Secondary risks: **passive-browse coverage gaps** (silent topics invisible in the observation window — mitigated by rebirth-forcing + manual entry, §4); **unbounded address space** from wildcard auto-provisioning (mitigated by authored-only runtime discovery); **write has no ack** (accepted, modelled as optimistic-Good self-correcting like Galaxy, §2.4); **broker security** (enforce TLS + real auth, never ship public-broker defaults).
|
||||
|
||||
---
|
||||
|
||||
## Appendix — project layout (mirrors existing drivers)
|
||||
|
||||
```
|
||||
src/Drivers/
|
||||
ZB.MOM.WW.OtOpcUa.Driver.Mqtt/ # MqttDriver : IDriver, ITagDiscovery,
|
||||
# ISubscribable, IReadable,
|
||||
# IHostConnectivityProbe, IRediscoverable
|
||||
# (+ IWritable in phase 3)
|
||||
MqttDriver.cs, MqttDriverProbe.cs, MqttDriverFactoryExtensions.cs
|
||||
Sparkplug/ (SparkplugDecoder, AliasTable, BirthCache, RebirthRequester)
|
||||
ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/ # MqttDriverOptions, MqttTagDefinition,
|
||||
# MqttEquipmentTagParser
|
||||
ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/ # MqttDriverBrowser, MqttBrowseSession
|
||||
|
||||
src/Server/.../AdminUI/
|
||||
Uns/TagEditors/TagConfigEditorMap.cs # + ["Mqtt"] = MqttTagConfigEditor
|
||||
Uns/TagEditors/TagConfigValidator.cs # + Mqtt validation
|
||||
Components/Shared/Uns/TagEditors/MqttTagConfigEditor.razor
|
||||
EndpointRouteBuilderExtensions.cs # + AddSingleton<IDriverBrowser, MqttDriverBrowser>
|
||||
|
||||
tests/Drivers/.../Docker/docker-compose.yml # mosquitto + sparkplug-sim + json-publisher
|
||||
```
|
||||
|
||||
DriverType string: **`"Mqtt"`** (single canonical string across AdminUI page, probe, factory, editor map, browser — heed the Modbus `"ModbusTcp"` vs `"Modbus"` mismatch lesson; pick one and use it everywhere).
|
||||
@@ -0,0 +1,408 @@
|
||||
# MTConnect (Agent-first) Driver — Research & Design
|
||||
|
||||
**Status:** Research / design proposal. No code written yet.
|
||||
**Author:** research pass, 2026-07-15.
|
||||
**Scope:** A new standard **Equipment-kind driver** (`DriverType = "MTConnect"`) exposing an
|
||||
MTConnect Agent's data under the OtOpcUa unified address space — the same shape as
|
||||
Modbus / S7 / AbCip / TwinCAT / FOCAS / OpcUaClient.
|
||||
|
||||
---
|
||||
|
||||
## 0. TL;DR
|
||||
|
||||
- **Browseable: YES.** MTConnect `/probe` returns a fully self-describing device model
|
||||
(Device → Components → DataItems) — a natural `IDriverBrowser` tree, closest in spirit to the
|
||||
OpcUaClient browser.
|
||||
- **Recommended library: TrakHound `MTConnect.NET` (client packages), MIT-licensed.** It is
|
||||
mature (v6.9.0.2, Oct 2025, 500k+ NuGet downloads), targets **netstandard2.0** (so it loads on
|
||||
.NET 10) plus net6/7/8/9, and covers MTConnect versions up to 2.5. Hand-rolling is a viable
|
||||
fallback but not recommended for v1.
|
||||
- **Read-only v1** (Discover + Read + Subscribe). No Write. MTConnect *Interfaces* (request/response
|
||||
write-back) exist but are rare, optional, and out of scope for v1.
|
||||
- **Top risks:** (1) MTConnect data is *loosely typed* — DataItems are strings with `type`/`units`
|
||||
metadata, so the OPC UA data-type mapping is a heuristic, not a guarantee; (2) `CONDITION`
|
||||
category has no clean scalar OPC UA analog and needs a modelling decision (fold to string vs.
|
||||
native Part 9 alarm).
|
||||
|
||||
---
|
||||
|
||||
## 1. Protocol summary + .NET library options
|
||||
|
||||
### 1.1 The two MTConnect layers
|
||||
|
||||
MTConnect ([standard docs](https://docs.mtconnect.org/), [SysML model v2.5](https://model.mtconnect.org/Version2.5/Fundamentals/))
|
||||
has two wire layers:
|
||||
|
||||
| Layer | Transport | Device model? | Role |
|
||||
|---|---|---|---|
|
||||
| **Agent** (primary) | HTTP REST, XML (or JSON) | **Yes** — self-describing | The queryable server. What apps talk to. |
|
||||
| **Adapter / SHDR** | Raw pipe-delimited TCP (default `:7878`) | **No** | Feeds an Agent from a machine; no model, no discovery. |
|
||||
|
||||
We target the **Agent** as the primary source. SHDR is a possible phase-2 ingest mode (§7) but
|
||||
loses auto-discovery.
|
||||
|
||||
### 1.2 Agent REST verbs
|
||||
|
||||
The Agent exposes three request types (MTConnect Standard Part 1, §5.4 / §8):
|
||||
|
||||
- **`/probe`** (optionally `/{device}/probe`) → an **`MTConnectDevices`** response document: the
|
||||
Device Information Model — every `Device`, its nested `Components`, and each `DataItem`
|
||||
definition (`id`, `type`, `category`, `subType`, `units`, `nativeUnits`, `name`).
|
||||
*This is the discovery + browse surface.*
|
||||
- **`/current`** (optionally `?at=<sequence>`) → an **`MTConnectStreams`** response document: a
|
||||
snapshot of the latest `Observation` for every DataItem at the moment of the request. *This is the
|
||||
Read surface.*
|
||||
- **`/sample?from=<seq>&count=<n>&interval=<ms>`** → an **`MTConnectStreams`** document containing a
|
||||
*time-ordered set* of observations since sequence `from`. *This is the Subscribe surface.*
|
||||
|
||||
**Streaming / long-poll.** When `interval` is supplied, the Agent holds the HTTP connection open and
|
||||
pushes successive `MTConnectStreams` chunks as a **`multipart/x-mixed-replace`** boundary stream —
|
||||
each chunk is one batch of new observations. Each response `Header` carries `nextSequence`; a poller
|
||||
issues the next `/sample` with `from=nextSequence` to get a contiguous, gap-free stream. The Agent's
|
||||
ring buffer has a finite `bufferSize`; if a consumer falls behind past the buffer, it must re-`/current`
|
||||
to re-baseline. (Sources:
|
||||
[MTConnect.NET README](https://github.com/TrakHound/MTConnect.NET/blob/master/README.md),
|
||||
[cppagent README](https://github.com/mtconnect/cppagent/blob/master/README.md),
|
||||
[Part 1 Overview](https://docs.mtconnect.org/MTC_Part_1_Overview+V1.2.pdf).)
|
||||
|
||||
### 1.3 The information model (probe)
|
||||
|
||||
`Device → Component(s) → DataItem(s)`. A `DataItem` has three **categories** (from the
|
||||
[SysML model](https://model.mtconnect.org/Version2.5/Fundamentals/)):
|
||||
|
||||
- **SAMPLE** — continuous numeric measurement over time. Types e.g. `Position`, `Temperature`,
|
||||
`SpindleSpeed`, `PathFeedrate`, `Load`, `Acceleration`. Carries `units`/`nativeUnits`.
|
||||
- **EVENT** — discrete state / value change. Types e.g. `Availability` (AVAILABLE/UNAVAILABLE),
|
||||
`Execution` (READY/ACTIVE/INTERRUPTED/STOPPED), `ControllerMode` (AUTOMATIC/MANUAL/...),
|
||||
`Program`, `EmergencyStop`, `Block`. Values are typically controlled vocabularies (enums) or free
|
||||
strings/ints.
|
||||
- **CONDITION** — health/alarm state. An observation is reported as one of `Normal` / `Warning` /
|
||||
`Fault` / `Unavailable`, with optional `nativeCode`, `nativeSeverity`, `qualifier`, and message
|
||||
text. Subtypes: `Actuator`, `Communications`, `System`, `Temperature`, `LogicProgram`, etc.
|
||||
|
||||
An observation in an `MTConnectStreams` document references its definition by **`dataItemId`** (and
|
||||
carries a `sequence`, `timestamp`, and value). This `dataItemId` is the stable per-tag address (§3).
|
||||
|
||||
### 1.4 .NET library options
|
||||
|
||||
**Recommended: TrakHound `MTConnect.NET` client packages (MIT).**
|
||||
|
||||
| Fact | Value | Source |
|
||||
|---|---|---|
|
||||
| Latest version | 6.9.0.2 (published 2025-10-16) | [NuGet MTConnect.NET-HTTP](https://www.nuget.org/packages/MTConnect.NET-HTTP/) |
|
||||
| License | **MIT** (per latest NuGet package metadata) | [NuGet MTConnect.NET-HTTP](https://www.nuget.org/packages/MTConnect.NET-HTTP/) |
|
||||
| TFMs | netstandard2.0, net6.0–net9.0, net48 (**runs on .NET 10 via netstandard2.0**) | [README](https://github.com/TrakHound/MTConnect.NET/blob/master/README.md) |
|
||||
| MTConnect versions | up to 2.5; auto-strips data not valid for requested version | [README](https://github.com/TrakHound/MTConnect.NET/blob/master/README.md) |
|
||||
| Maturity | 500k+ NuGet downloads; actively maintained | [README](https://github.com/TrakHound/MTConnect.NET/blob/master/README.md) |
|
||||
|
||||
Packages we would consume (client side only — **not** the Agent/Adapter host packages):
|
||||
|
||||
- **`MTConnect.NET-Common`** — model types (`IDevice`, `IComponent`, `IDataItem`, `IObservation`),
|
||||
the `IMTConnectClient` / `IMTConnectEntityClient` interfaces.
|
||||
- **`MTConnect.NET-HTTP`** — `MTConnectHttpClient`: probe/current/sample with polling **and**
|
||||
streaming, gzip compression, XML **and** JSON. Exposes events for received documents
|
||||
(`OnProbeReceived`, `OnCurrentReceived`, `OnSampleReceived`, plus a per-observation callback) —
|
||||
a clean fit for pushing into `ISubscribable.OnDataChange`.
|
||||
- (`MTConnect.NET-XML` / `-JSON` are pulled transitively for serialization.)
|
||||
|
||||
> ⚠️ **License due-diligence caveat:** older release notes and the repo copyright header have shown
|
||||
> mixed "MIT" / "Apache-2.0" / "© TrakHound, All Rights Reserved" strings across versions. The
|
||||
> *current* NuGet package metadata for 6.9.0.2 declares **MIT**. Before taking the dependency,
|
||||
> pin a specific version and confirm that exact version's embedded `LICENSE` / package license
|
||||
> expression (a legal-review checkbox, not a blocker).
|
||||
|
||||
**Fallback: hand-roll a minimal XML client.** The Agent protocol is just three HTTP GETs returning
|
||||
XML. A hand-rolled client (`HttpClient` + `System.Xml.Linq` for probe, a `multipart/x-mixed-replace`
|
||||
boundary reader for sample) is ~300–500 LoC and removes a third-party dependency + its transitive
|
||||
`System.Text.Json` version constraints. **Recommendation: use the library for v1** (it handles version
|
||||
negotiation, the multipart framing, buffer-overflow re-baselining, and JSON/XML dual-format — all
|
||||
fiddly to reimplement correctly), and keep hand-roll as a contingency if the license review fails.
|
||||
|
||||
---
|
||||
|
||||
## 2. Capability mapping to the Equipment-kind seams
|
||||
|
||||
The driver implements the composable capability interfaces in
|
||||
`src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/`. Mapping:
|
||||
|
||||
| OtOpcUa seam | MTConnect mechanism | Notes |
|
||||
|---|---|---|
|
||||
| `IDriver` (required) | Construct `MTConnectHttpClient` from config, hold `/probe` model | `InitializeAsync` does one probe; `GetHealth` reflects last successful poll + Agent `instanceId` |
|
||||
| `ITagDiscovery.DiscoverAsync` | Walk the `/probe` DeviceModel, stream `Device→Component→DataItem` into `IAddressSpaceBuilder` | `RediscoverPolicy = Once` (probe is synchronous & complete). Redeploy on Agent `instanceId` change (see `IRediscoverable`, §below) |
|
||||
| `IReadable.ReadAsync(fullRefs)` | `/current` snapshot, index observations by `dataItemId`, return one `DataValueSnapshot` per requested ref | Reads are idempotent (matches `IReadable` contract). Per-ref miss → `Bad`-coded snapshot, not a throw |
|
||||
| `ISubscribable.SubscribeAsync` | Start the `MTConnectHttpClient` **sample stream** (`from`/`interval`); fan each received observation to `OnDataChange` keyed by `dataItemId` | One shared stream per driver instance (not per tag) — the Agent streams the whole device; filter to subscribed refs. Fire initial values from `/current` on subscribe (OPC UA convention) |
|
||||
| `IWritable` | **NOT implemented (v1)** | See verdict below |
|
||||
| `IRediscoverable.OnRediscoveryNeeded` | Raise when the Agent's `Header/@instanceId` or `@assetBufferSize` changes (Agent restarted / model changed) → Host rebuilds address space | Mirrors Galaxy's `DeployWatcher` pattern |
|
||||
| `IHostConnectivityProbe` | Cheap periodic `/probe` HEAD or `/current` to flip Running↔Stopped | Mirrors `ModbusProbeOptions` |
|
||||
| `IAlarmSource` | *(optional, phase 1.5)* map `CONDITION` Fault/Warning → Part 9 native alarm | See §2.3 |
|
||||
|
||||
### 2.1 Write verdict: **read-only for v1**
|
||||
|
||||
Justification:
|
||||
1. The mainstream MTConnect surface (`/probe`, `/current`, `/sample`) is **strictly read-only** by
|
||||
design — MTConnect was conceived as a read-only telemetry standard.
|
||||
2. Write-back exists only via **MTConnect *Interfaces*** (a request/response handshake modelled as
|
||||
its own DataItem categories — `Request`/`Response` events) which is **optional, rarely deployed**,
|
||||
and semantically a state-machine handshake, not a simple "set value." Modelling it as OPC UA
|
||||
`IWritable` would be misleading.
|
||||
3. The Equipment-kind seams are composable — a driver implements only what its backend supports
|
||||
(`IDriver` doc-comment). Omitting `IWritable` is idiomatic (Galaxy's write path is even
|
||||
fire-and-forget; several drivers are read-mostly). Nodes materialize without the
|
||||
`AccessLevels.CurrentWrite` bit.
|
||||
|
||||
Revisit Interfaces write-back in a later phase only if a concrete deployment needs it.
|
||||
|
||||
### 2.2 Data-type mapping (MTConnect → `DriverDataType` → OPC UA)
|
||||
|
||||
MTConnect DataItems are **weakly typed on the wire** (values arrive as strings). The mapping is
|
||||
inferred from `category` + `type` + `units`, and stored per-tag in the TagConfig (§3) so a wrong
|
||||
inference can be corrected by the author without a code change.
|
||||
|
||||
| MTConnect | Inferred `DriverDataType` | Rationale |
|
||||
|---|---|---|
|
||||
| `SAMPLE` (numeric, has `units`) | `Float64` | Continuous analog; `Double` is the safe superset |
|
||||
| `SAMPLE` w/ `representation="TIME_SERIES"` | `Float64` array (ValueRank=1) | Time-series sample bursts |
|
||||
| `EVENT` controlled-vocab (Execution, ControllerMode, Availability, …) | `String` | Enum-like; keep the vocab string. (Optional: OPC UA enum modelling later) |
|
||||
| `EVENT` numeric (e.g. `PartCount`, `Line`) | `Int64` | Integer counters |
|
||||
| `EVENT` free text (`Program`, `Block`, `Message`) | `String` | |
|
||||
| `CONDITION` | `String` (v1: the state word `Normal`/`Warning`/`Fault`/`Unavailable`) **or** native alarm (§2.3) | No clean scalar analog |
|
||||
| `timestamp` on every observation | → OPC UA `SourceTimestamp` | Not a separate node |
|
||||
|
||||
`DataValueSnapshot.StatusCode` mapping: an observation value of `UNAVAILABLE` (MTConnect's explicit
|
||||
"no data" sentinel) → OPC UA `Bad`/`Uncertain` quality rather than a literal string, so downstream
|
||||
sees proper quality. Empty CONDITION or missing dataItem → `Bad`.
|
||||
|
||||
### 2.3 CONDITION modelling (decision point)
|
||||
|
||||
`CONDITION` is the awkward one. Two options:
|
||||
- **v1 simple:** expose each CONDITION DataItem as a `String` variable node whose value is the current
|
||||
condition state (`Fault`/`Warning`/`Normal`/`Unavailable`), optionally suffixed with `nativeCode`.
|
||||
Zero alarm plumbing.
|
||||
- **v1.5 native:** treat a Fault/Warning CONDITION as an OtOpcUa **native alarm** by emitting a
|
||||
TagConfig `alarm` object and implementing `IAlarmSource` — matching the Galaxy/Phase-B native-alarm
|
||||
pattern (route on the authored dotted reference, per the alarms memory). Higher value, more work.
|
||||
|
||||
Recommend **v1 simple, v1.5 native** as a fast-follow.
|
||||
|
||||
---
|
||||
|
||||
## 3. TagConfig JSON shape
|
||||
|
||||
Consistent with the Equipment-kind model: **connection-level settings live in the driver config**
|
||||
(agent base URL, auth, poll interval); **per-tag addressing lives in `TagConfig`**, and the platform
|
||||
`FullName` binds the tag to a driver-side reference. For MTConnect the natural stable reference is the
|
||||
**`dataItemId`** (globally unique within an Agent), so `FullName = "<dataItemId>"` (or, for
|
||||
readability, the driver can also accept a `device/component/dataItemName` path and resolve it to the id
|
||||
at discovery time).
|
||||
|
||||
### 3.1 Driver config (per driver instance) — `MTConnectDriverOptions`
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"AgentUri": "https://demo.mtconnect.org", // Agent base URL (http or https)
|
||||
"DeviceName": "", // optional: scope to one device (else all devices)
|
||||
"PreferJson": false, // request application/json instead of XML if Agent supports it
|
||||
"SampleIntervalMs": 1000, // long-poll interval passed to /sample
|
||||
"SampleCount": 500, // max observations per sample chunk
|
||||
"HeartbeatMs": 10000, // Agent keep-alive heartbeat
|
||||
"RequestTimeoutMs": 30000,
|
||||
"AllowUntrustedTls": false, // dev/on-prem self-signed agents
|
||||
"Probe": { "Enabled": true, "IntervalMs": 5000 } // IHostConnectivityProbe
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 Per-tag `TagConfig` (authored on the /uns Tags tab)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"FullName": "avail", // <-- MTConnect dataItemId; the driver-side reference
|
||||
"mtDevice": "OKUMA.Lathe", // optional human context (device name from probe)
|
||||
"mtComponent": "Controller", // optional
|
||||
"mtCategory": "EVENT", // SAMPLE | EVENT | CONDITION (from probe; drives typing)
|
||||
"mtType": "AVAILABILITY", // MTConnect DataItem type
|
||||
"mtSubType": null,
|
||||
"dataType": "String", // resolved DriverDataType (overridable by author)
|
||||
"units": null, // e.g. "CELSIUS" for a SAMPLE
|
||||
"writable": false // always false for MTConnect v1
|
||||
}
|
||||
```
|
||||
|
||||
A picked SAMPLE example:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"FullName": "Xact",
|
||||
"mtDevice": "OKUMA.Lathe",
|
||||
"mtComponent": "Linear[X]",
|
||||
"mtCategory": "SAMPLE",
|
||||
"mtType": "POSITION",
|
||||
"mtSubType": "ACTUAL",
|
||||
"dataType": "Float64",
|
||||
"units": "MILLIMETER",
|
||||
"writable": false
|
||||
}
|
||||
```
|
||||
|
||||
The runtime driver resolves reads/subscriptions by `FullName` (= `dataItemId`) against the observation
|
||||
index it maintains from `/current` and `/sample` — exactly the "bind by `TagConfig.FullName`" pattern
|
||||
the other drivers use (`EquipmentTagRefResolver<TDef>`). Only `FullName` is load-bearing at runtime;
|
||||
the `mt*`/`units` fields are author-facing metadata + type inference inputs preserved across edits.
|
||||
|
||||
---
|
||||
|
||||
## 4. Browseability verdict + browse design
|
||||
|
||||
### 4.1 Verdict: **YES — fully browseable.**
|
||||
|
||||
`/probe` returns the complete `MTConnectDevices` model in one call — a static, self-describing tree.
|
||||
This is the *cleanest possible* browse source (better than Galaxy, which needs per-node expansion;
|
||||
comparable to a single-shot OPC UA browse). It maps directly onto `IDriverBrowser`/`IBrowseSession`.
|
||||
|
||||
### 4.2 New project: `ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Browser`
|
||||
|
||||
Mirror `OpcUaClient.Browser` layout exactly:
|
||||
|
||||
```
|
||||
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Browser/
|
||||
MTConnectDriverBrowser.cs // IDriverBrowser: DriverType="MTConnect"; OpenAsync(configJson) -> session
|
||||
MTConnectBrowseSession.cs // IBrowseSession over the fetched probe model
|
||||
ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Browser.csproj
|
||||
```
|
||||
|
||||
Register in AdminUI DI alongside the others
|
||||
(`src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs`, ~line 49-50):
|
||||
|
||||
```csharp
|
||||
services.AddSingleton<IDriverBrowser, MTConnectDriverBrowser>();
|
||||
```
|
||||
|
||||
### 4.3 Session behaviour (map to `IBrowseSession`)
|
||||
|
||||
Unlike OPC UA (live one-level-per-call), MTConnect probe is fetched **once** in
|
||||
`OpenAsync` and cached in the session; expansion is served from the in-memory tree (no further network
|
||||
calls). This is simpler and faster than the OpcUaClient browser.
|
||||
|
||||
| Method | Returns |
|
||||
|---|---|
|
||||
| `OpenAsync(configJson)` | Deserialize `MTConnectDriverOptions`, `GET {AgentUri}/probe`, hold the parsed `IDevice[]` model in the session |
|
||||
| `RootAsync()` | One `BrowseNode(Kind=Folder)` per **Device** (or, if `DeviceName` is scoped, the top-level **Components** of that device) |
|
||||
| `ExpandAsync(nodeId)` | Children of a Device/Component: nested **Components** → `Folder` nodes; **DataItems** → `Leaf` nodes. `NodeId` encodes the path (e.g. `dev:OKUMA.Lathe`, `comp:<componentId>`, `di:<dataItemId>`) |
|
||||
| `AttributesAsync(nodeId)` | For a DataItem leaf, one-or-few `AttributeInfo` rows describing it (`Name=dataItemName`, `DriverDataType=<inferred>`, `IsArray=<TIME_SERIES?>`, `SecurityClass`, `IsAlarm = (category==CONDITION)`). For Device/Component folders, empty. This drives the picker's side-panel and lets a CONDITION pre-fill a native-alarm TagConfig (like Galaxy alarm attrs) |
|
||||
|
||||
- `BrowseNode.NodeId` for a DataItem leaf is `di:<dataItemId>` → the picker commits it, and the
|
||||
TagModal maps it to `TagConfig.FullName = <dataItemId>` plus the `mt*` metadata read from the cached
|
||||
probe model.
|
||||
- `BrowseNodeKind.Leaf` = DataItem (terminal, commit-on-select); `Folder` = Device/Component.
|
||||
- `HasChildrenHint = true` for Devices/Components that have child components or DataItems.
|
||||
|
||||
### 4.4 Picked-DataItem → TagConfig
|
||||
|
||||
When the picker commits a `di:<dataItemId>` leaf, the AdminUI builds the TagConfig in §3.2 from the
|
||||
cached probe DataItem: `FullName=id`, `mtCategory/mtType/mtSubType/units` copied verbatim, and
|
||||
`dataType` set from the §2.2 inference table (author can override in the typed editor).
|
||||
|
||||
---
|
||||
|
||||
## 5. Typed tag-editor (AdminUI)
|
||||
|
||||
To avoid the raw-JSON fallback, add a driver-typed editor (mirrors the driver-typed tag-editor pattern):
|
||||
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/MTConnectTagConfigModel.cs`
|
||||
(pure `FromJson`/`ToJson`/`Validate`, preserves unknown keys) — copy the Modbus template.
|
||||
- `.../Components/Shared/Uns/TagEditors/MTConnectTagConfigEditor.razor` — a thin shell:
|
||||
fields for `FullName` (dataItemId), read-only `mtCategory/mtType`, and a `dataType` override dropdown.
|
||||
- Register `["MTConnect"] = typeof(...MTConnectTagConfigEditor)` in
|
||||
`Uns/TagEditors/TagConfigEditorMap.cs`, and add validation in `TagConfigValidator`.
|
||||
|
||||
Because most tags are authored via the **browse picker** (which fills the model), the editor is mostly
|
||||
a confirm/override surface.
|
||||
|
||||
---
|
||||
|
||||
## 6. Test-fixture strategy
|
||||
|
||||
Ranked by value:
|
||||
|
||||
1. **Public demo Agents (fastest smoke path).**
|
||||
- `https://demo.mtconnect.org/` — the MTConnect Institute reference demo (probe/current/sample).
|
||||
- `http://mtconnect.mazakcorp.com/` — Mazak demo agents.
|
||||
- NIST **smstestbed** publishes real agent configs + `Devices.xml`
|
||||
([usnistgov/smstestbed](https://github.com/usnistgov/smstestbed/blob/master/mtconnect/agent/Devices.xml)).
|
||||
These give a live, real-shape model for browse + read + streaming verification with zero infra.
|
||||
(Caveat: public agents are internet-dependent and not reproducible in CI.)
|
||||
|
||||
2. **Dockerized reference C++ Agent (reproducible integration fixture).**
|
||||
The MTConnect reference Agent ([mtconnect/cppagent](https://github.com/mtconnect/cppagent)) has an
|
||||
official Docker image. Add a fixture under `tests/.../Docker/docker-compose.yml` with the
|
||||
`project: lmxopcua` label (per the repo's Docker workflow), seeded with a canned `Devices.xml` and
|
||||
an SHDR simulator or the built-in agent adapter, exposed on the shared docker host `10.100.0.35`.
|
||||
This is the integration-test analog of the Modbus/S7 sims.
|
||||
|
||||
3. **Canned probe/current/sample XML fixtures (unit tests).**
|
||||
Capture one `MTConnectDevices` (probe) and a couple of `MTConnectStreams` (current + sample) XML
|
||||
documents from a demo agent into `tests/.../MTConnect.Tests/Fixtures/`. Drive the parser,
|
||||
type-inference, browse-tree, and observation-indexing logic with **no network** — the bulk of unit
|
||||
coverage. This is where the tricky bits (type inference, `nextSequence` paging, `UNAVAILABLE`→Bad,
|
||||
CONDITION handling, multipart chunk framing) get pinned.
|
||||
|
||||
Recommended: **#3 for unit CI + #2 for the env-gated integration suite + #1 for manual live smoke.**
|
||||
|
||||
---
|
||||
|
||||
## 7. Effort / risk / phasing
|
||||
|
||||
### Phase 1 — Agent read + subscribe + browse (the MVP)
|
||||
- Projects: `Driver.MTConnect` (+ `.Contracts` for `MTConnectDriverOptions`/DTOs),
|
||||
`Driver.MTConnect.Browser`, `MTConnect.Tests`.
|
||||
- Implement `IDriver` + `ITagDiscovery` (probe→builder) + `IReadable` (current) +
|
||||
`ISubscribable` (sample stream) + `IHostConnectivityProbe` + `IRediscoverable` (instanceId change).
|
||||
- `IDriverProbe` for the AdminUI Test-Connect button (probe reachability).
|
||||
- `IDriverBrowser`/`IBrowseSession` from cached probe model; register in AdminUI DI.
|
||||
- Typed tag editor + `TagConfigEditorMap`/`TagConfigValidator` entries.
|
||||
- Register the factory in the Host bootstrapper (mirror `ModbusDriverFactoryExtensions.Register`).
|
||||
- CONDITION as `String` (simple).
|
||||
- **Effort estimate:** ~1–1.5 weeks with the TrakHound library (it removes the protocol grind).
|
||||
~2.5–3 weeks hand-rolled.
|
||||
|
||||
### Phase 1.5 (fast-follow)
|
||||
- CONDITION → native OPC UA Part 9 alarms via `IAlarmSource` (Galaxy native-alarm pattern).
|
||||
- `TIME_SERIES` SAMPLE arrays; EVENT controlled-vocab → OPC UA enumerations.
|
||||
|
||||
### Phase 2 — SHDR adapter ingest (second source mode)
|
||||
- Add an optional `SourceMode: "Agent" | "Shdr"` to the driver config. In SHDR mode the driver opens
|
||||
the raw pipe-delimited TCP socket (default `:7878`) and parses `<timestamp>|<key>|<value>` lines.
|
||||
- **Loses auto-discovery**: SHDR has no device model, so `ITagDiscovery` cannot self-populate — tags
|
||||
must be **manually authored** (like Modbus), and the browser is unavailable in SHDR mode. Type
|
||||
inference falls back to author-supplied `dataType`.
|
||||
- Value: connect directly to a machine adapter with no Agent deployed. Niche; do only on demand.
|
||||
|
||||
### Risk register
|
||||
| Risk | Sev | Mitigation |
|
||||
|---|---|---|
|
||||
| Weak wire typing → wrong OPC UA type inference | Med | Store resolved `dataType` in TagConfig; author-overridable; unit-test the inference table against real probe XML |
|
||||
| CONDITION has no scalar analog | Med | v1 = String state; v1.5 = native alarm |
|
||||
| TrakHound license string inconsistent across versions | Low-Med | Pin a version; confirm its embedded MIT license in legal review before merge |
|
||||
| Public demo agents unreliable for CI | Low | Docker cppagent fixture + canned XML for CI; demo agents only for manual smoke |
|
||||
| Agent ring-buffer overflow if consumer stalls | Low | Library re-baselines via `/current` on sequence gap; verify + test the fall-behind path |
|
||||
| `netstandard2.0`-only lib on .NET 10 | Low | netstandard2.0 loads on net10.0; validate no trim/AOT issues at build |
|
||||
|
||||
---
|
||||
|
||||
## 8. Source references
|
||||
|
||||
- MTConnect Standard Part 1 (Overview & Protocol): https://docs.mtconnect.org/MTC_Part_1_Overview+V1.2.pdf
|
||||
- MTConnect SysML Model v2.5 (Fundamentals — Device/Component/DataItem, categories): https://model.mtconnect.org/Version2.5/Fundamentals/
|
||||
- TrakHound MTConnect.NET (README, client API, TFMs, versions): https://github.com/TrakHound/MTConnect.NET/blob/master/README.md
|
||||
- NuGet MTConnect.NET-HTTP (license=MIT, v6.9.0.2 2025-10-16, TFMs, deps): https://www.nuget.org/packages/MTConnect.NET-HTTP/
|
||||
- MTConnect reference C++ Agent (Docker fixture): https://github.com/mtconnect/cppagent/blob/master/README.md
|
||||
- NIST smstestbed agent config + Devices.xml (real fixtures): https://github.com/usnistgov/smstestbed/blob/master/mtconnect/agent/Devices.xml
|
||||
- Public demo agent: https://demo.mtconnect.org/ · Mazak demo agents: http://mtconnect.mazakcorp.com/
|
||||
|
||||
### OtOpcUa code seams referenced
|
||||
- Capability interfaces: `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/` (`IDriver`, `ITagDiscovery`, `IReadable`, `ISubscribable`, `IWritable`, `IRediscoverable`, `IHostConnectivityProbe`, `IDriverFactory`, `DriverDataType`).
|
||||
- Browse seam: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/` (`IDriverBrowser`, `IBrowseSession`, `BrowseNode`, `AttributeInfo`).
|
||||
- Browser template: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/`.
|
||||
- Driver template: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/` (+ `.Contracts`), factory `ModbusDriverFactoryExtensions`.
|
||||
- AdminUI browser DI registration: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs:49-50`.
|
||||
- Typed tag editors: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs` + `TagConfigValidator.cs`.
|
||||
- Browse design doc: `docs/plans/2026-05-28-driver-browsers-design.md`.
|
||||
@@ -0,0 +1,361 @@
|
||||
# Omron PLC Driver — Research & Implementation Design
|
||||
|
||||
**Status:** Research / design proposal (no code yet).
|
||||
**Author context:** Standard **Equipment-kind driver** for the OtOpcUa server, same shape as
|
||||
Modbus / S7 / AbCip / TwinCAT / FOCAS — an in-process `IDriver` implementing the composable
|
||||
capability interfaces (`IReadable` / `IWritable` / `ISubscribable` / `ITagDiscovery` /
|
||||
`IHostConnectivityProbe`, optionally `IDriverBrowser` in AdminUI). Points are ordinary equipment
|
||||
`Tag`s bound to the driver via `TagConfig.FullName`, authored on the `/uns` Tags tab.
|
||||
|
||||
---
|
||||
|
||||
## 0. TL;DR recommendation
|
||||
|
||||
- **Support both transports, CIP-first.** Ship an **Omron CIP (EtherNet/IP)** path for modern
|
||||
**NJ/NX/NY (Sysmac)** controllers as Phase 1, then an **Omron FINS** path for legacy
|
||||
**CJ/CS/CP/CV** as Phase 2. They cover disjoint hardware generations; neither subsumes the other.
|
||||
- **Library:** `libplctag` (via `libplctag.NET`, MPL-2.0) for the CIP path — **reuses almost the
|
||||
entire AbCip wire layer** (`plc=omron-njnx`). For the FINS path, **hand-roll** the framing
|
||||
(it is a small, well-documented binary protocol) rather than take `HslCommunication` (see §1.3).
|
||||
- **Browseability:** **CIP NJ/NX is only *conditionally* browsable, and NOT via libplctag's
|
||||
`@tags` walker** — that is the single most important finding. **FINS is never browsable** (flat
|
||||
memory banks, no symbol table). Details in §4.
|
||||
|
||||
---
|
||||
|
||||
## 1. Transport decision
|
||||
|
||||
Omron exposes two entirely different Ethernet protocols depending on controller generation.
|
||||
|
||||
### 1.1 The two transports and what they cover
|
||||
|
||||
| Transport | Port / framing | Controller families | Addressing model | Browsable? |
|
||||
|---|---|---|---|---|
|
||||
| **EtherNet/IP CIP** | TCP/UDP **44818** (EtherNet/IP), CIP explicit messaging | **NJ / NX / NY (Sysmac)**, and CJ2/CP-family with a CIP-capable EtherNet/IP unit | **Named variables** (symbolic tags), like Rockwell Logix | **Conditionally** — see §4 |
|
||||
| **FINS** | **FINS/TCP** and **FINS/UDP** on port **9600** | **CJ / CS / CP / CV**, NSJ; NJ/NX also support *FINS-over-EtherNet/IP* for back-compat | **Memory areas** (CIO/WR/HR/AR/DM/EM banks) + word/bit offset | **Never** (flat memory, no symbol table) |
|
||||
|
||||
Key architectural fact: **NJ/NX are CIP-native**. Their "variables" are named tags reached by CIP
|
||||
explicit messaging using a symbolic-segment request path — the same mechanism ControlLogix uses,
|
||||
which is why AbCip's libplctag layer already speaks it (`plc=omron-njnx`). Sources:
|
||||
[Omron store KB — Using CIP to Read/Write Network Variables with NJ/NX](https://store.omron.com.au/knowledge-base/1807412-using-cip-to-read-write-network-variables-with-nj-nx),
|
||||
[NJ/NX CPU Built-in EtherNet/IP Port User's Manual (W506)](https://files.omron.eu/downloads/latest/manual/en/w506_nj_nx-series_cpu_unit_built-in_ethernet_ip_port_users_manual_en.pdf).
|
||||
|
||||
**FINS** is the classic protocol: a compact binary header (ICF/RSV/GCT, destination & source
|
||||
net/node/unit addresses, SID) followed by a 2-byte command code and its parameters. **Memory Area
|
||||
Read = `0x0101`**, **Memory Area Write = `0x0102`**. FINS/TCP prepends a 16-byte "FINS" TCP header
|
||||
(the ASCII magic `46 49 4E 53` = "FINS", a length, and a command/error field) and requires a small
|
||||
handshake frame to obtain a node address before the first command. Sources:
|
||||
[Omron W227 FINS Commands Reference Manual](https://www.myomron.com/downloads/1.Manuals/Networks/W227E12_FINS_Commands_Reference_Manual.pdf),
|
||||
[Wireshark FINS dissector](https://github.com/wireshark/wireshark/blob/master/epan/dissectors/packet-omron-fins.c),
|
||||
[FieldServer Omron FINS driver sheet](https://cdn.chipkin.com/assets/uploads/2016/jan/PDS_Omron_FINS.pdf).
|
||||
|
||||
### 1.2 FINS memory-area codes (for the TagConfig addressing model)
|
||||
|
||||
FINS memory-area codes are **access-width-specific** (a different code for word vs. bit access of
|
||||
the same bank). Representative word-access codes:
|
||||
|
||||
| Bank | Word code | Bit code | Notes |
|
||||
|---|---|---|---|
|
||||
| CIO (Core I/O) | `0xB0` | `0x30` | word + bit addressable |
|
||||
| WR (Work) | `0xB1` | `0x31` | word + bit addressable |
|
||||
| HR (Holding) | `0xB2` | `0x32` | word + bit addressable |
|
||||
| AR (Auxiliary) | `0xB3` | `0x33` | word + bit addressable |
|
||||
| DM (Data Memory) | `0x82` | `0x02` | primary bulk data bank; word addressable |
|
||||
| EM (Extended, bank 0) | `0xA0` | `0x20` | banked; code varies per EM bank (`0xA0`+bank) |
|
||||
|
||||
(Exact codes vary slightly by CPU family — the authoritative table is per-controller in W227 /
|
||||
the CS/CJ comms manual. The driver must treat the code table as CPU-family-parameterised.) Source:
|
||||
[Omron memory-area discussion](https://community.oxmaint.com/discussion-forum/omron-plc-memory-areas-understanding-ci-hr-ar-lr-dm-wr-em-extended-em-usage),
|
||||
[W227 reference](https://www.myomron.com/downloads/1.Manuals/Networks/W227E12_FINS_Commands_Reference_Manual.pdf).
|
||||
|
||||
### 1.3 .NET library options — license analysis
|
||||
|
||||
| Library | Transports | License | Maturity / .NET 10 | Verdict |
|
||||
|---|---|---|---|---|
|
||||
| **`libplctag.NET`** (wraps native `libplctag`) | **CIP** (`plc=omron-njnx`); **also Modbus** | **native core dual-licensed MPL-2.0 / LGPL-2.1; .NET wrapper MPL-2.0** — permissive, commercial-OK, already vetted & shipped in this repo for AbCip | Mature, actively maintained; **already a proven dependency** (AbCip runs on it, net10.0) | **CIP path: adopt.** Zero new license review — same dep AbCip already carries. |
|
||||
| **`HslCommunication`** | FINS **and** CIP (`OmronFinsNet`, `OmronCipNet`) | **The maintained/commercial builds are NOT free** — the original author moved HSL to a **paid commercial license**; only a frozen old community fork ([`HslCommunication-Community`](https://github.com/HslCommunication-Community/HslCommunication-Community)) remains under the last free (MIT-era) snapshot. The "MIT" claim in casual sources refers to that **stale fork**, not current releases. | Broad but the free fork is unmaintained | **Do NOT take a dependency.** License risk + the free build is abandoned. Use only as a *protocol reference* to cross-check hand-rolled framing. |
|
||||
| **Hand-rolled FINS** | FINS/TCP + FINS/UDP | our own code, no third-party license | n/a — FINS framing is ~200 lines | **FINS path: hand-roll.** Framing is simple and fully documented (W227 + Wireshark dissector); mirrors how `ModbusTcpTransport` is hand-rolled in this repo. |
|
||||
| `Sres.Net.EEIP` | EtherNet/IP (generic CIP) | permissive | Focused on generic CIP/assembly, not Omron symbolic variable access; weaker than libplctag for named-tag read/write | Not needed — libplctag already covers CIP. |
|
||||
| `Plc.Omron.Standard` (NuGet) | FINS | permissive but tiny/low-adoption | Immature | Reference only. |
|
||||
|
||||
**Recommendation:** **libplctag.NET for CIP** (reuse AbCip's proven stack), **hand-rolled FINS
|
||||
transport** for the memory-area path. This keeps the dependency surface identical to what the repo
|
||||
already ships and dodges the HslCommunication license trap.
|
||||
|
||||
---
|
||||
|
||||
## 2. Capability mapping
|
||||
|
||||
The driver implements the same capability set as AbCip/Modbus. Both transports map cleanly onto the
|
||||
composable interfaces in `Core.Abstractions`.
|
||||
|
||||
### 2.1 CIP path (NJ/NX) — reuses AbCip machinery
|
||||
|
||||
- **Connect / `InitializeAsync`:** libplctag Forward-Open per device. Canonical host address form
|
||||
mirroring AbCip's `ab://gateway[:port]/cip-path`, e.g. `omron://gateway/path` — but for NJ/NX the
|
||||
libplctag attribute string is `protocol=ab-eip&gateway=<ip>&path=<cip-path>&plc=omron-njnx&name=<tag>`.
|
||||
(Community-confirmed: NJ/NX needs `plc=omron-njnx` and a CIP-bridging `path`, e.g. `18,<ip>`.)
|
||||
Source: [libplctag Omron NJ/NX group thread](https://groups.google.com/g/libplctag/c/bU5MVQrPsOw).
|
||||
- **Read (`IReadable`):** direct reuse of AbCip's per-tag runtime pattern — create tag handle, `ReadAsync`,
|
||||
`GetStatus`, decode. Batch-read + per-host resilience (`IPerCallHostResolver`) reuse verbatim.
|
||||
- **Write (`IWritable`):** full read/write. NJ/NX BOOL-in-word and array handling map onto AbCip's
|
||||
existing `EncodeValue` / bit-RMW code paths.
|
||||
- **Subscribe (`ISubscribable`):** **poll-based** via the shared `PollGroupEngine` (identical to
|
||||
AbCip/Modbus — Omron CIP has no native subscription/unsolicited push for explicit messaging).
|
||||
- **Discover (`ITagDiscovery`):** emit pre-declared tags always; controller-side browse is the
|
||||
**caveat** — see §4 (libplctag's `@tags` walker does **not** work on Omron).
|
||||
- **Health/Probe (`IHostConnectivityProbe`):** reuse AbCip's probe-loop pattern (cheap tag read at
|
||||
interval → `HostState` transitions).
|
||||
|
||||
**Reuse estimate: ~70–80% of AbCip's driver body is directly reusable for the CIP path.** The
|
||||
honest way to capture this is to **extract a shared CIP core** (tag runtime, poll wiring, status
|
||||
mapping, host-address parsing, resilience seam) that both AbCip and OmronCip consume — rather than
|
||||
copy-paste. The Omron-specific deltas are: the `plc=omron-njnx` attribute string, Omron's data-type
|
||||
code set (see §2.3), string encoding differences, and the browse story.
|
||||
|
||||
### 2.2 FINS path (CJ/CS/CP) — new transport, Modbus-shaped
|
||||
|
||||
- **Connect:** open TCP to `:9600`, send the FINS/TCP node-address-request handshake, cache the
|
||||
assigned client node number. (FINS/UDP variant skips the handshake.)
|
||||
- **Read:** build a `0x0101` Memory Area Read frame (area code + 3-byte address + word count),
|
||||
send, parse the 2-byte end-code + payload. Word-oriented; multi-word reads for 32/64-bit types.
|
||||
- **Write:** `0x0102` Memory Area Write, symmetric. Bit writes use the bit-access area codes; word
|
||||
writes use the word codes. RMW not required for FINS bit writes (bit codes address bits directly).
|
||||
- **Subscribe:** poll-based via `PollGroupEngine` (FINS has no subscription).
|
||||
- **Discover:** **pre-declared tags only** — there is no symbol table to enumerate (§4).
|
||||
- **Probe:** cheap DM-word read at interval.
|
||||
|
||||
The FINS transport is structurally a sibling of `ModbusTcpTransport` (`IModbusTransport`): a thin
|
||||
framed request/response codec behind an `IFinsTransport` seam, unit-testable against a byte-level
|
||||
fake.
|
||||
|
||||
### 2.3 Data-type mapping
|
||||
|
||||
Omron CIP (NJ/NX) uses IEC 61131 variable types that align closely with the existing
|
||||
`AbCipDataType` / `DriverDataType` set; FINS is raw words that the TagConfig must type explicitly.
|
||||
|
||||
| Omron type | CIP (NJ/NX) | FINS (word interpretation) | OPC UA / `DriverDataType` |
|
||||
|---|---|---|---|
|
||||
| BOOL | native BOOL | bit-area read, or bit N of a word | Boolean |
|
||||
| BYTE/USINT/SINT | 1-byte | low byte of word | Byte / SByte |
|
||||
| WORD/UINT/INT | 2-byte | 1 word | UInt16 / Int16 |
|
||||
| DWORD/UDINT/DINT | 4-byte | 2 words (endianness matters) | UInt32 / Int32 |
|
||||
| LWORD/ULINT/LINT | 8-byte | 4 words | UInt64 / Int64 |
|
||||
| REAL | IEEE-754 32 | 2 words | Float |
|
||||
| LREAL | IEEE-754 64 | 4 words | Double |
|
||||
| STRING | CIP string | word-packed ASCII, fixed length | String |
|
||||
| DATE_AND_TIME/TIME | Sysmac time types | banked words | DateTime (best-effort) |
|
||||
|
||||
**Word/byte-order caveat (FINS):** Omron stores multi-word values with a specific word order that
|
||||
differs from naive concatenation; the FINS codec must expose a per-tag or per-device
|
||||
byte/word-swap option (same class of concern the S7/Modbus drivers already handle). CIP via
|
||||
libplctag handles endianness internally.
|
||||
|
||||
---
|
||||
|
||||
## 3. TagConfig JSON shape
|
||||
|
||||
`TagConfig.FullName` (or a JSON blob under it) carries the driver-specific addressing. Following the
|
||||
AbCip equipment-tag parser convention (`AbCipEquipmentTagParser`: a leading `{` marks a TagConfig
|
||||
blob; camelCase property names; strict enum reads via `TagConfigJson.TryReadEnumStrict`), the two
|
||||
transports need distinct shapes discriminated by a `transport` field.
|
||||
|
||||
### 3.1 CIP (NJ/NX) — named-variable addressing
|
||||
|
||||
```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`. The CIP path
|
||||
reuses AbCip's `isArray`/`arrayLength`/`writable`/`dataType` semantics verbatim.)
|
||||
|
||||
### 3.2 FINS — memory-area addressing
|
||||
|
||||
```json
|
||||
{
|
||||
"transport": "Fins",
|
||||
"deviceHostAddress": "fins://10.100.0.41:9600/0.1.0",
|
||||
"memoryArea": "DM",
|
||||
"address": 100,
|
||||
"bit": null,
|
||||
"dataType": "Int16",
|
||||
"wordSwap": false,
|
||||
"isArray": false,
|
||||
"arrayLength": 1,
|
||||
"writable": true
|
||||
}
|
||||
```
|
||||
|
||||
- `memoryArea` ∈ `{CIO, WR, HR, AR, DM, EM}` (+ optional `emBank` for extended memory); the driver
|
||||
maps the pair `(memoryArea, bit==null?word:bit)` to the correct FINS area code (§1.2).
|
||||
- `address` = word offset; `bit` = 0–15 for bit access (null ⇒ word access).
|
||||
- `deviceHostAddress` encodes the FINS destination `net.node.unit` triple.
|
||||
- `wordSwap` handles Omron multi-word ordering for 32/64-bit types.
|
||||
|
||||
---
|
||||
|
||||
## 4. BROWSEABILITY VERDICT (transport-dependent) — the critical finding
|
||||
|
||||
### 4.1 FINS — **NOT browsable. Full stop.**
|
||||
FINS addresses flat memory banks with no symbol/metadata table on the wire. There is nothing to
|
||||
enumerate. The AdminUI must offer only manual entry (memory-area dropdown + word/bit offset). No
|
||||
`IDriverBrowser` for the FINS transport.
|
||||
|
||||
### 4.2 CIP (NJ/NX) — **browsable in principle, but NOT via libplctag's `@tags` walker.**
|
||||
|
||||
This is the finding to flag loudly and to **coordinate with the AbCip-browseability audit**:
|
||||
|
||||
- The AbCip browse mechanism relies on libplctag's `@tags` pseudo-tag (CIP Symbol Object class
|
||||
`0x6B`) to walk the controller symbol table. **libplctag's tag-listing works only on
|
||||
ControlLogix / CompactLogix — it does NOT work on Omron NJ/NX**, returning `ErrorUnsupported` /
|
||||
`PLCTAG_ERR_NOT_FOUND`. Confirmed upstream:
|
||||
[libplctag #466 "Support for Omron NX/NJ variable listing"](https://github.com/libplctag/libplctag/issues/466),
|
||||
[libplctag.NET #371 "List all tags in Omron NJ: ErrorUnsupported"](https://github.com/libplctag/libplctag.NET/issues/371).
|
||||
- So the naive assumption "CIP controller-tag enumeration IS browsable, reuse the AbCip walker"
|
||||
is **only half true for Omron**: the *protocol capability* exists (published variables are
|
||||
reachable), but Omron NJ/NX exposes the tag list through a **different CIP object/service than
|
||||
Rockwell** and libplctag hasn't implemented the Omron variant.
|
||||
- **Additional gate:** on NJ/NX a variable is only reachable over the network at all if the
|
||||
programmer set its **Network Publish** attribute (`Publish Only`) in **Sysmac Studio**. Tags
|
||||
without Network Publish are invisible to any external client. And when *no* user variables are
|
||||
published, an enumeration returns only the reserved system variables (names starting with `_`).
|
||||
Source: [Omron NJ/NX EtherNet/IP User's Manual (W506)](https://files.omron.eu/downloads/latest/manual/en/w506_nj_nx-series_cpu_unit_built-in_ethernet_ip_port_users_manual_en.pdf),
|
||||
[Omron store KB](https://store.omron.com.au/knowledge-base/1807412-using-cip-to-read-write-network-variables-with-nj-nx).
|
||||
|
||||
**Verdict:** CIP-Omron browse is achievable but requires **new protocol work, not free reuse**.
|
||||
Two viable routes, in preference order:
|
||||
|
||||
1. **Import the Sysmac Studio tag export** (offline). NJ/NX projects export the published global
|
||||
variable table (CSV/tag file). An AdminUI importer that parses this into candidate TagConfigs is
|
||||
the *lowest-risk, highest-value* browse experience and needs no online CIP enumeration at all.
|
||||
2. **Implement the Omron CIP tag-list service directly** (online `IBrowseSession`). Omron NJ/NX
|
||||
supports reading the published-variable list via a CIP service Rockwell-differently; this means
|
||||
hand-writing the request/response against the NJ/NX EtherNet/IP object model (not going through
|
||||
libplctag's `@tags`). This is a research-grade effort — feasible (third-party tools like Kepware
|
||||
discover Omron published tags) but non-trivial.
|
||||
|
||||
### 4.3 Browse-seam design (CIP path only)
|
||||
|
||||
If/when online CIP browse is implemented, it fits the existing seam exactly (mirroring
|
||||
`OpcUaClient.Browser` / `Galaxy.Browser`, which live as separate `*.Browser` projects registered in
|
||||
AdminUI DI and indexed by `DriverType`):
|
||||
|
||||
- New project `ZB.MOM.WW.OtOpcUa.Driver.OmronCip.Browser` implementing `IDriverBrowser`
|
||||
(`DriverType = "OmronCip"`), `OpenAsync(configJson)` → `IBrowseSession`.
|
||||
- `IBrowseSession`: `RootAsync` = one folder per device; `ExpandAsync` = published global variables
|
||||
(and struct-member fan-out for UDT-typed variables, reusing AbCip's template-decode pattern);
|
||||
`AttributesAsync` = data type / access / array shape side-panel.
|
||||
- **Share a CIP-browse core with AbCip *only if* the enumeration transport is unified.** Since
|
||||
Omron's tag-list service differs from Rockwell's, the honest boundary is: **share the
|
||||
`BrowseNode`/`IBrowseSession` plumbing and struct-fan-out logic; do NOT expect to share the
|
||||
wire-level enumerator.** Recommend a common `Cip.Browsing` helper library the AbCip browser (if
|
||||
built) and OmronCip browser both consume for tree-shaping, with transport-specific enumerators
|
||||
underneath. **Coordinate this boundary with the AbCip-browseability audit** so the two efforts
|
||||
agree on the shared-core seam.
|
||||
|
||||
Note: the driver is still fully usable **without** a browser — like every driver, unmapped types
|
||||
fall back to the raw-JSON TagConfig editor, and pre-declared tags always work. Browse is a
|
||||
convenience layer, not a correctness dependency.
|
||||
|
||||
---
|
||||
|
||||
## 5. Test-fixture strategy
|
||||
|
||||
Consistent with the repo's Docker-fixture model (driver sims on the Linux host `10.100.0.35`,
|
||||
controlled via `lmxopcua-fix`, compose files under `tests/.../Docker/`).
|
||||
|
||||
### 5.1 FINS
|
||||
- **Hand-rolled byte-level fake** for unit tests (mirror the Modbus `IModbusTransport` fake) — the
|
||||
primary and most valuable test surface; deterministic, no container.
|
||||
- **Open-source FINS simulators** for an integration fixture:
|
||||
[`hiroeorz/omron-fins-simulator`](https://github.com/hiroeorz/omron-fins-simulator) (Ruby, UDP
|
||||
9600) and [`ahmadfarisfs/fins_simulator_omron`](https://github.com/ahmadfarisfs/fins_simulator_omron).
|
||||
Neither ships a Docker image → wrap in a small `Dockerfile` under
|
||||
`tests/Drivers/.../Omron/Docker/` and register with the `project=lmxopcua` label. Both are
|
||||
UDP-oriented; FINS/TCP handshake coverage stays on the hand-rolled fake.
|
||||
- Omron's own **CX-Simulator** speaks FINS but is Windows-only, license-gated (bundled in CX-One,
|
||||
not separately purchasable) → **not** a CI fixture; reserve for a manual/live gate against real
|
||||
hardware if available.
|
||||
|
||||
### 5.2 CIP (NJ/NX)
|
||||
- **No credible free NJ/NX CIP simulator exists.** libplctag's own test harness targets Rockwell.
|
||||
Options, in order:
|
||||
- **Hand-rolled `IAbCipTagRuntime`-style fake** (Omron variant) for read/write/decode unit tests —
|
||||
the same seam AbCip uses (`IAbCipTagRuntime` / factory injection) makes the driver fully
|
||||
unit-testable without hardware. This is the primary CI surface.
|
||||
- **Live gate against real NJ/NX hardware** (env-gated `Category=LiveIntegration`, skips cleanly
|
||||
when the env var is absent — the pattern the HistorianGateway live suite already uses). This is
|
||||
the only way to truly validate the `plc=omron-njnx` libplctag path, Network-Publish behavior,
|
||||
and Omron string/array quirks. Flag as an **infra-gated known limitation** until hardware is on
|
||||
the bench.
|
||||
|
||||
---
|
||||
|
||||
## 6. Effort / risk / phasing
|
||||
|
||||
**Recommended order: CIP-first, then FINS.**
|
||||
|
||||
Rationale: (a) CIP targets the modern/current Omron line (NJ/NX/NY) most likely in new installs;
|
||||
(b) the CIP path **reuses 70–80% of the already-shipped, already-hardened AbCip stack** (libplctag,
|
||||
poll engine, resilience, status mapping), so it is the fastest path to a working read/write driver;
|
||||
(c) FINS, while simpler as a protocol, is a *net-new* transport codec with no reuse and targets
|
||||
legacy hardware.
|
||||
|
||||
### Phase 1 — Omron CIP read/write (NJ/NX) · *Low–Medium effort*
|
||||
- Extract a shared CIP core from AbCip (or, pragmatically, copy AbCip and specialise) →
|
||||
`Driver.OmronCip` + `Driver.OmronCip.Contracts`, `DriverType="OmronCip"`, `plc=omron-njnx`.
|
||||
- Capabilities: Connect/Read/Write/Subscribe(poll)/Probe; pre-declared tags via `ITagDiscovery`.
|
||||
- Typed tag editor: copy `AbCipTagConfigModel`/editor, register in `TagConfigEditorMap` +
|
||||
`TagConfigValidator`.
|
||||
- Tests: hand-rolled runtime fake + env-gated live gate.
|
||||
- **Risk:** libplctag Omron string/array edge cases (community-reported); word/UDT quirks. Medium.
|
||||
|
||||
### Phase 2 — Omron FINS read/write (CJ/CS/CP) · *Medium effort*
|
||||
- New `IFinsTransport` codec (TCP+UDP, `0x0101`/`0x0102`, area-code table, word-swap) behind the
|
||||
same `IDriver` shell; memory-area TagConfig shape (§3.2); manual-entry editor (no browse).
|
||||
- Tests: byte-level fake (primary) + open-source simulator container (secondary).
|
||||
- **Risk:** per-CPU-family area-code/word-order variance. Medium. No dependency/license risk (hand-rolled).
|
||||
|
||||
### Phase 3 — CIP online browse (NJ/NX) · *High effort / research-grade*
|
||||
- Sysmac Studio tag-export importer first (cheap, high value), then optional online Omron CIP
|
||||
tag-list `IBrowseSession` (`Driver.OmronCip.Browser`) — **not** libplctag `@tags`.
|
||||
- **Coordinate the shared CIP-browse-core boundary with the AbCip-browseability audit.**
|
||||
- **Risk:** High — Omron's tag-list service is undocumented-in-libplctag and Rockwell-different.
|
||||
|
||||
### Top risks (summary)
|
||||
1. **CIP browse is not free reuse.** libplctag's tag-list walker does not work on Omron NJ/NX
|
||||
(`ErrorUnsupported`); online browse needs net-new Omron-specific CIP work, and even then only
|
||||
Network-Published variables are visible. Mitigate by shipping the offline Sysmac-export importer
|
||||
first and treating online browse as a later research phase.
|
||||
2. **No free NJ/NX CIP simulator** → CIP correctness for the real wire (string/array/UDT/Network-
|
||||
Publish semantics) can only be proven on live hardware behind an env-gated `LiveIntegration`
|
||||
suite; treat as an infra-gated known limitation until an NJ/NX is on the bench.
|
||||
3. **(Secondary) FINS per-family variance** in memory-area codes and multi-word ordering — contain
|
||||
with a CPU-family-parameterised code table + per-tag `wordSwap`.
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
- [Omron store KB — Using CIP to Read/Write Network Variables with NJ/NX](https://store.omron.com.au/knowledge-base/1807412-using-cip-to-read-write-network-variables-with-nj-nx)
|
||||
- [Omron NJ/NX CPU Built-in EtherNet/IP Port User's Manual (W506)](https://files.omron.eu/downloads/latest/manual/en/w506_nj_nx-series_cpu_unit_built-in_ethernet_ip_port_users_manual_en.pdf)
|
||||
- [libplctag #466 — Support for Omron NX/NJ variable listing](https://github.com/libplctag/libplctag/issues/466)
|
||||
- [libplctag.NET #371 — List all tags in Omron NJ: ErrorUnsupported](https://github.com/libplctag/libplctag.NET/issues/371)
|
||||
- [libplctag Omron NJ/NX group thread (plc=omron-njnx, CIP path)](https://groups.google.com/g/libplctag/c/bU5MVQrPsOw)
|
||||
- [Omron W227 FINS Commands Reference Manual](https://www.myomron.com/downloads/1.Manuals/Networks/W227E12_FINS_Commands_Reference_Manual.pdf)
|
||||
- [Omron NX-series CPU Unit FINS Function User's Manual (W596)](https://files.omron.eu/downloads/latest/manual/en/w596_nx-series_-_cpu_unit_fins_function_users_manual_en.pdf)
|
||||
- [Wireshark FINS dissector (framing reference)](https://github.com/wireshark/wireshark/blob/master/epan/dissectors/packet-omron-fins.c)
|
||||
- [FieldServer Omron FINS protocol driver sheet](https://cdn.chipkin.com/assets/uploads/2016/jan/PDS_Omron_FINS.pdf)
|
||||
- [Omron memory-area overview (CIO/HR/AR/DM/WR/EM)](https://community.oxmaint.com/discussion-forum/omron-plc-memory-areas-understanding-ci-hr-ar-lr-dm-wr-em-extended-em-usage)
|
||||
- [HslCommunication — commercial-license fork situation](https://github.com/HslCommunication-Community/HslCommunication-Community)
|
||||
- [omron-fins-simulator (open-source FINS sim)](https://github.com/hiroeorz/omron-fins-simulator)
|
||||
- [fins_simulator_omron (open-source FINS sim)](https://github.com/ahmadfarisfs/fins_simulator_omron)
|
||||
</content>
|
||||
</invoke>
|
||||
@@ -0,0 +1,470 @@
|
||||
# SQL Poll driver — design research
|
||||
|
||||
> **Status:** research / not-yet-approved. Author: driver research sweep, 2026-07-15.
|
||||
> **Scope:** a new **`SqlPoll`** Equipment-kind driver that reads values from arbitrary SQL
|
||||
> databases (MES/ERP staging tables, historians, custom app DBs) and surfaces them as OPC UA
|
||||
> variable nodes — same shape as the existing `Modbus` / `S7` / `OpcUaClient` drivers.
|
||||
> **Grounding:** `CLAUDE.md`, `IDriver.cs` + capability neighbours, `Driver.Modbus/` (polling
|
||||
> template), `Commons/Browsing/` (browse seam), `docs/plans/2026-05-28-driver-browsers-design.md`.
|
||||
|
||||
---
|
||||
|
||||
## 0. TL;DR verdicts
|
||||
|
||||
| Question | Verdict |
|
||||
|---|---|
|
||||
| **Browseable?** | **YES.** SQL is self-describing via `INFORMATION_SCHEMA` (databases/schemas → tables/views → columns + types). A full `IDriverBrowser` / `IBrowseSession` is natural. |
|
||||
| **Provider ship-first?** | **SQL Server** (`Microsoft.Data.SqlClient` — already in the dependency graph at 6.1.1), behind an ADO.NET `DbProviderFactory` abstraction. PostgreSQL (`Npgsql`) second, ODBC (`System.Data.Odbc`) as the universal fallback, MySQL/Oracle later. |
|
||||
| **Write in v1?** | **NO — read-only v1.** Writing back to MES/ERP staging is operationally risky; ship an *optional*, opt-in, per-tag parameterized-UPSERT write mode as a later phase (design sketched in §2.4). |
|
||||
| **Top risks** | (1) **SQL injection** — every identifier and value must be parameterized / allow-listed, never string-concatenated; (2) **secret handling** — connection-string credentials must come from env/secret store, never committed (mirror `ServerHistorian__ApiKey`). |
|
||||
|
||||
---
|
||||
|
||||
## 1. Design summary + provider strategy
|
||||
|
||||
### 1.1 Shape
|
||||
|
||||
`SqlPoll` is a standard Equipment-kind driver: a `SqlPollDriver : IDriver, ITagDiscovery,
|
||||
IReadable, ISubscribable, IHostConnectivityProbe` living in
|
||||
`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.SqlPoll/`, with a `.Contracts` project for the options +
|
||||
equipment-tag parser, and a `.Browser` project for the AdminUI address picker. It has **no native
|
||||
push model** — subscriptions overlay a polling loop via the shared `PollGroupEngine`
|
||||
(`src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/PollGroupEngine.cs`), exactly as Modbus/S7/AB-CIP do.
|
||||
`IWritable` and `IAlarmSource`/`IHistoryProvider` are **out of scope for v1** (writes deferred;
|
||||
alarms/history aren't expressible from a poll query).
|
||||
|
||||
Points are ordinary equipment `Tag`s bound to the driver via `TagConfig.FullName`; reads/subscribes
|
||||
resolve `FullName` to a tag definition through the shared `EquipmentTagRefResolver<TDef>`
|
||||
(`Core.Abstractions/EquipmentTagRefResolver.cs`) — same two-model bridge Modbus uses (an authored
|
||||
tag-table entry by name **or** an equipment tag whose reference is its raw `TagConfig` JSON,
|
||||
parsed once and cached).
|
||||
|
||||
### 1.2 Provider abstraction
|
||||
|
||||
Use ADO.NET's provider-agnostic base types (`System.Data.Common`: `DbConnection`,
|
||||
`DbCommand`, `DbParameter`, `DbDataReader`) obtained through a `DbProviderFactory`. In .NET
|
||||
(Core/5+) there is no `machine.config`/GAC provider registry, so factories are registered in code
|
||||
via `DbProviderFactories.RegisterFactory(invariantName, factory.Instance)` and each provider exposes
|
||||
a static `Instance` singleton (`SqlClientFactory.Instance`, `NpgsqlFactory.Instance`,
|
||||
`OdbcFactory.Instance`, `OracleClientFactory.Instance`, `MySqlConnectorFactory.Instance`)
|
||||
([MS Learn: Obtain a SqlClientFactory](https://learn.microsoft.com/en-us/sql/connect/ado-net/obtain-sqlclientfactory),
|
||||
[MS Learn: Obtaining a DbProviderFactory](https://learn.microsoft.com/en-us/dotnet/framework/data/adonet/obtaining-a-dbproviderfactory)).
|
||||
|
||||
**Recommended approach — a small provider enum mapped to a factory**, not open-ended invariant
|
||||
strings, because two provider-specific concerns leak past the ADO.NET base types and *must* be
|
||||
dispatched per-provider:
|
||||
|
||||
1. **Identifier quoting** for the browse layer and any generated SQL —
|
||||
`[SqlServer]` / `"Postgres"` / `` `MySql` `` / `"Oracle"`.
|
||||
2. **Parameter marker syntax** — SQL Server & MySQL use `@p`, Npgsql uses `@p` or `:p`, Oracle
|
||||
uses `:p`, ODBC uses positional `?`. A `SqlDialect` abstraction owns quoting + parameter
|
||||
naming + the metadata-catalog query (see §4).
|
||||
|
||||
```csharp
|
||||
public enum SqlProvider { SqlServer, Postgres, MySql, Odbc, Oracle }
|
||||
|
||||
internal interface ISqlDialect
|
||||
{
|
||||
DbProviderFactory Factory { get; }
|
||||
string QuoteIdentifier(string ident); // [x] / "x" / `x`
|
||||
string ParameterMarker(int ordinal); // @p0 / :p0 / ?
|
||||
string ListColumnsSql(string? catalog); // metadata catalog query (browse)
|
||||
string ListTablesSql(string? catalog);
|
||||
}
|
||||
```
|
||||
|
||||
**Ship-first recommendation: SQL Server only.** `Microsoft.Data.SqlClient` is already a repo
|
||||
dependency (`Directory.Packages.props` line 53, `6.1.1`), the shared central test SQL Server is
|
||||
already on the docker host (§5), and SQL Server is the dominant MES/historian staging backend in
|
||||
this AVEVA/Wonderware estate (the Galaxy Repository and the HistorianGateway both sit on SQL Server).
|
||||
Phase 2 adds **PostgreSQL** (`Npgsql`) and **ODBC** (`System.Data.Odbc`, the universal fallback that
|
||||
reaches any DSN — DB2, Oracle via the Oracle ODBC driver, Access, etc.). MySQL/MariaDB
|
||||
(`MySqlConnector`, whose `MySqlConnectorFactory` is fully async-correct, unlike Oracle's official
|
||||
`MySql.Data`) and native Oracle (`Oracle.ManagedDataProvider`) are later, demand-driven additions.
|
||||
|
||||
`Npgsql`, `System.Data.Odbc`, and `Oracle.ManagedDataAccess.Core` are **not** yet in
|
||||
`Directory.Packages.props` — each is a new `PackageVersion` entry gated behind its phase, so the
|
||||
ship-first SQL-Server slice adds **zero** new NuGet dependencies.
|
||||
|
||||
---
|
||||
|
||||
## 2. Capability mapping
|
||||
|
||||
### 2.1 Connect — `InitializeAsync`
|
||||
|
||||
- Deserialize `SqlPollDriverOptions` from `DriverConfig` JSON (via the factory extensions, mirroring
|
||||
`ModbusDriverFactoryExtensions.CreateInstance`). Resolve the connection string (§6 — from env/secret,
|
||||
never the committed JSON). Select the `ISqlDialect` from `options.Provider`.
|
||||
- **Validate** by opening one `DbConnection` and running a cheap liveness query
|
||||
(`SELECT 1` — portable across SQL Server/Postgres/MySQL; Oracle needs `SELECT 1 FROM DUAL`, so the
|
||||
liveness probe is a dialect method). Set `DriverHealth` = `Healthy` on success, `Faulted` on failure
|
||||
(same state machine as `ModbusDriver.InitializeAsync`).
|
||||
- **Pooling:** rely on ADO.NET's built-in connection pooling (keyed by exact connection-string text).
|
||||
Do **not** hold one long-lived connection; open-use-dispose per poll pass so the pool manages
|
||||
lifetime and a dropped server recovers transparently. Expose `Max Pool Size` etc. only via the
|
||||
connection string.
|
||||
- **`ReinitializeAsync`** = teardown + init (Modbus pattern). No live sockets to preserve.
|
||||
|
||||
### 2.2 Read / Subscribe — poll queries, **one query per table/group**
|
||||
|
||||
The whole efficiency story is **batching by query-group, not per-tag**. A naive "one SELECT per
|
||||
tag" hammers the DB; instead the driver coalesces tags that share a source query and issues **one
|
||||
round-trip per group per poll**, then slices the result back to per-tag snapshots — structurally the
|
||||
same idea as `ModbusDriver.ReadCoalescedAsync` (group → single PDU → slice back).
|
||||
|
||||
`IReadable.ReadAsync(fullReferences)` groups the referenced tags by their **query group key**
|
||||
(model-dependent, see §2.5), executes each group's query once, and maps rows/columns back to the
|
||||
`DataValueSnapshot[]` in input order. `ISubscribable` delegates to `PollGroupEngine` with the same
|
||||
`ReadAsync` as the reader delegate — the engine owns the loop, the 100 ms interval floor, capped-
|
||||
exponential failure backoff, and change-diffing. Route poll-loop failures to `DriverHealth` via the
|
||||
engine's `onError` sink + `PollBackoffCap` (Modbus/S7 precedent, 05/STAB-8/9).
|
||||
|
||||
**Poll interval** is driver-level (`DefaultPollInterval`, default e.g. 5 s) with an optional per-tag
|
||||
or per-group override; the OPC UA subscription's requested publishing interval is clamped up to the
|
||||
group's configured floor by `PollGroupEngine`.
|
||||
|
||||
**Per-call deadline (mandatory).** Every `DbCommand` must set `CommandTimeout` **and** run under a
|
||||
linked `CancellationTokenSource` bounded by an `OperationTimeout` option. This is the SQL analogue of
|
||||
the R2-01 S7 blackhole finding: a frozen DB server (network partition, lock wait, slow query) must
|
||||
surface `BadTimeout`/`Degraded` and let the poll loop back off — **not** wedge the poll thread
|
||||
forever. Prefer `DbCommand.ExecuteReaderAsync(ct)` with the linked token so cancellation is real, and
|
||||
still set `CommandTimeout` as a server-side backstop.
|
||||
|
||||
### 2.3 Discover — offline / config-driven
|
||||
|
||||
Like Modbus, SQL has **no runtime discovery obligation**: `DiscoverAsync` materializes exactly the
|
||||
authored tags (the `Tags` table in options, plus equipment tags contributed at deploy time). It does
|
||||
**not** enumerate the DB schema at discovery — schema enumeration is the *browse* concern (§4), an
|
||||
interactive AdminUI operation, not a deploy-time one. `RediscoverPolicy = Once`.
|
||||
|
||||
### 2.4 Write — verdict: **read-only v1**, optional write mode later
|
||||
|
||||
**v1 does not implement `IWritable`.** Rationale: MES/ERP/historian staging tables are frequently
|
||||
downstream-of-record systems where an unexpected `UPDATE`/`INSERT` can corrupt a batch record,
|
||||
double-post a transaction, or trip a trigger with side effects. A read-only integration is the safe,
|
||||
common case and removes an entire class of risk from the first ship.
|
||||
|
||||
**Optional Phase-N write mode** (opt-in per driver *and* per tag): a tag may carry a
|
||||
`writeCommand` describing a **parameterized** statement — never generated by string concatenation:
|
||||
|
||||
```jsonc
|
||||
"write": {
|
||||
"mode": "Update", // Update | Insert | Upsert | StoredProc
|
||||
"sql": "UPDATE dbo.Setpoints SET value=@value, ts=@ts WHERE tag_name=@key",
|
||||
"keyParam": "@key", "keyValue": "Line1.Speed",
|
||||
"valueParam": "@value"
|
||||
}
|
||||
```
|
||||
|
||||
Writes route through `IWritable.WriteAsync` → the tag's parameterized command with the OPC UA
|
||||
write value bound to `@value` (typed via the tag's declared type). Gate at three layers: (a) the OPC
|
||||
UA node's `WriteOperate` role (standard `NodeWriteRouter`), (b) a driver-level `AllowWrites` master
|
||||
switch (default `false`), (c) `TagConfig.writable`. Writes are **not** auto-retried unless the tag is
|
||||
flagged `WriteIdempotent` (an `UPDATE ... WHERE key=` set-value is idempotent; an `INSERT` / counter
|
||||
`UPDATE x=x+1` is not — mirror `DriverAttributeInfo.WriteIdempotent`). Because a failed inbound
|
||||
device write reverts the node to its prior value (the write-outcome self-correction path,
|
||||
master `1d797c1c`), SQL writes — unlike Galaxy's fire-and-forget — *can* surface a genuine failure,
|
||||
which is the correct, honest behaviour.
|
||||
|
||||
### 2.5 Tag→value mapping models
|
||||
|
||||
Support **two** models in v1 (both cover the vast majority of MES/ERP staging shapes), plus an
|
||||
escape hatch:
|
||||
|
||||
1. **Key-value / EAV table** *(primary — ship first).* A table of `(tagname, value, timestamp)`
|
||||
rows; a tag names a `keyColumn`+`keyValue`, a `valueColumn`, and an optional `timestampColumn`.
|
||||
**Group key = the table.** One `SELECT keyColumn, valueColumn, timestampColumn FROM table WHERE
|
||||
keyColumn IN (@k0,@k1,…)` per poll returns every subscribed tag on that table in one round-trip;
|
||||
the driver indexes rows by key and slices back. (Values `IN (@params)` — never interpolated.)
|
||||
|
||||
2. **Wide row** *(ship first, cheap).* One "latest status" row, many columns, each column a tag. A
|
||||
tag names a `table`, a `columnName`, and a row selector (`WHERE id=@id`, or "top 1 by timestamp").
|
||||
**Group key = (table, row-selector).** One `SELECT col_a, col_b, … FROM table WHERE …` per poll
|
||||
maps each selected column to its tag.
|
||||
|
||||
3. **Arbitrary parameterized SELECT** *(escape hatch, per named query group).* Named query definitions
|
||||
at the driver level (`queries: { "q1": { sql, params } }`); a tag references a query by name +
|
||||
a projection (which output column / which row-key). **Group key = the named query.** This covers
|
||||
joins, views, and computed projections the two structured models can't express. The SQL is authored
|
||||
by an operator with `ConfigEditor` rights and stored server-side — it is *config*, not user input —
|
||||
but it still binds only parameters, never interpolated tag values.
|
||||
|
||||
### 2.6 Type mapping (SQL → OPC UA) + timestamps
|
||||
|
||||
Map the column's provider metadata (`DbColumn.DataType` from `DbDataReader.GetColumnSchema()`, or the
|
||||
`INFORMATION_SCHEMA` `DATA_TYPE`) to the driver-agnostic `DriverDataType`
|
||||
(`Core.Abstractions/DriverDataType.cs`), which the address-space layer maps to OPC UA built-ins:
|
||||
|
||||
| SQL type (family) | CLR (`DbDataReader`) | `DriverDataType` |
|
||||
|---|---|---|
|
||||
| `bit` / `boolean` | `bool` | `Boolean` |
|
||||
| `tinyint`/`smallint` | `byte`/`short` | `Int16` |
|
||||
| `int`/`integer` | `int` | `Int32` |
|
||||
| `bigint` | `long` | `Int64` |
|
||||
| `real`/`float(24)` | `float` | `Float32` |
|
||||
| `float`/`double`/`decimal`/`numeric`/`money` | `double`/`decimal`→`double` | `Float64` |
|
||||
| `char`/`varchar`/`nvarchar`/`text` | `string` | `String` |
|
||||
| `date`/`datetime`/`datetime2`/`timestamptz` | `DateTime`/`DateTimeOffset` | `DateTime` |
|
||||
| `uniqueidentifier`/`uuid` | `Guid` | `String` |
|
||||
|
||||
`decimal`/`numeric` collapse to `Float64` (v1) with a documented precision-loss caveat; a tag may
|
||||
override the inferred type explicitly (`"type": "Int32"`) for cases where the operator knows better
|
||||
than the column metadata (e.g. a `varchar` column holding a number). `NULL` → `DataValueSnapshot`
|
||||
with `Value = null` and a `Good`/`Uncertain` status per an option (`NullIsBad`, default treat as
|
||||
`GoodNoData`-ish `Uncertain`).
|
||||
|
||||
**Source timestamp:** if a tag declares a `timestampColumn`, that column's value becomes the
|
||||
`DataValueSnapshot.SourceTimestamp`; otherwise the poll-read wall-clock is used for both source and
|
||||
server timestamps (the Modbus behaviour). A source timestamp is what makes SQL-poll data honest about
|
||||
staleness — strongly recommend authoring one wherever the staging table has it.
|
||||
|
||||
---
|
||||
|
||||
## 3. TagConfig + driver-config JSON shape
|
||||
|
||||
### 3.1 Driver config (the `DriverConfig` blob)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"provider": "SqlServer",
|
||||
// Connection string is resolved at Initialize from an env/secret ref — NOT stored here.
|
||||
// Option A (recommended): a named ref the Host resolves from env:
|
||||
"connectionStringRef": "SqlPoll_MesStaging", // => env SqlPoll__ConnectionStrings__MesStaging
|
||||
"defaultPollInterval": "00:00:05",
|
||||
"operationTimeout": "00:00:15", // per-query wall-clock deadline (BadTimeout)
|
||||
"commandTimeout": "00:00:10", // server-side CommandTimeout backstop
|
||||
"allowWrites": false, // master write kill-switch (v1: always false)
|
||||
"probe": { "enabled": true, "interval": "00:00:10" },
|
||||
|
||||
// Optional pre-declared tag table (analogous to Modbus "Tags"); equipment tags may also
|
||||
// contribute their address as raw TagConfig JSON at deploy time.
|
||||
"tags": [ /* see 3.2 */ ],
|
||||
|
||||
// Optional named queries for the arbitrary-SELECT model.
|
||||
"queries": {
|
||||
"activeBatch": {
|
||||
"sql": "SELECT line, speed, temp, ts FROM dbo.LineStatus WHERE active=@active",
|
||||
"params": { "@active": true }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 Per-tag TagConfig — **key-value model**
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"driver": "SqlPoll",
|
||||
"model": "KeyValue",
|
||||
"table": "dbo.TagValues",
|
||||
"keyColumn": "tag_name",
|
||||
"keyValue": "Line1.Speed",
|
||||
"valueColumn": "num_value",
|
||||
"timestampColumn": "sample_ts",
|
||||
"type": "Float64", // optional explicit override of inferred type
|
||||
"writable": false
|
||||
}
|
||||
```
|
||||
Group key for batching = `("dbo.TagValues", keyColumn, valueColumn, timestampColumn)`; all such tags
|
||||
fold into one `… WHERE tag_name IN (@k0,@k1,…)` per poll.
|
||||
|
||||
### 3.3 Per-tag TagConfig — **wide-row model**
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"driver": "SqlPoll",
|
||||
"model": "WideRow",
|
||||
"table": "dbo.LatestStatus",
|
||||
"columnName": "oven_temp",
|
||||
"rowSelector": { "whereColumn": "station_id", "whereValue": 7 },
|
||||
// or "rowSelector": { "topByTimestamp": "sample_ts" } // newest row
|
||||
"type": "Float64",
|
||||
"writable": false
|
||||
}
|
||||
```
|
||||
Group key = `("dbo.LatestStatus", rowSelector)`; every wide-row tag on the same row folds into one
|
||||
`SELECT oven_temp, <other cols>, … FROM dbo.LatestStatus WHERE station_id=@id`.
|
||||
|
||||
### 3.4 Per-tag TagConfig — **named-query model**
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"driver": "SqlPoll",
|
||||
"model": "Query",
|
||||
"query": "activeBatch",
|
||||
"rowKey": { "column": "line", "value": "Line1" }, // which row this tag reads
|
||||
"column": "speed", // which projected column is the value
|
||||
"timestampColumn": "ts"
|
||||
}
|
||||
```
|
||||
|
||||
The equipment-tag parser (`SqlPollEquipmentTagParser`, in `.Contracts`) recognizes the blob by a
|
||||
leading `{` + a `"driver":"SqlPoll"` (or a `"model"` discriminator) and maps it to a transient
|
||||
`SqlPollTagDefinition` whose `Name` == the reference string — exactly the `ModbusEquipmentTagParser`
|
||||
pattern, so the value the driver publishes keys the forward router correctly.
|
||||
|
||||
---
|
||||
|
||||
## 4. Browseability verdict + browse design
|
||||
|
||||
### 4.1 Verdict: **YES — SQL is browseable via its metadata catalog.**
|
||||
|
||||
Relational databases are self-describing. The picker walks the catalog three levels deep:
|
||||
|
||||
- **`RootAsync`** → databases / schemas (Folder nodes). SQL Server: databases (`sys.databases`) then
|
||||
schemas; Postgres: databases then schemas; MySQL: schemas (== databases). For SQLite: the single
|
||||
main database (Folder).
|
||||
- **`ExpandAsync(schemaNodeId)`** → tables + views in that schema (Folder nodes).
|
||||
- **`ExpandAsync(tableNodeId)`** → columns (Leaf nodes).
|
||||
- **`AttributesAsync(columnNodeId)`** → column data type + nullability + (for key-value tables) a
|
||||
hint panel. This mirrors Galaxy's two-stage attribute side-panel: pick a table (Folder), then pick
|
||||
the column/key that becomes the tag.
|
||||
|
||||
### 4.2 Portability caveat — the metadata query is dialect-specific
|
||||
|
||||
`INFORMATION_SCHEMA` is the SQL-92 standard catalog and is supported by **SQL Server, PostgreSQL, and
|
||||
MySQL/MariaDB** — so `SELECT TABLE_SCHEMA, TABLE_NAME FROM INFORMATION_SCHEMA.TABLES` and
|
||||
`… COLUMN_NAME, DATA_TYPE, IS_NULLABLE FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME=@t` are portable
|
||||
across those three. But **two important backends do not implement `INFORMATION_SCHEMA`:**
|
||||
|
||||
- **Oracle** uses `ALL_TABLES` / `ALL_TAB_COLUMNS` (and `USER_`/`DBA_` variants) instead
|
||||
([Oracle: ALL_TAB_COLUMNS](https://docs.oracle.com/en/database/oracle/oracle-database/19/refrn/ALL_TAB_COLUMNS.html),
|
||||
[dataedo: what supports INFORMATION_SCHEMA](https://dataedo.com/kb/databases/all/information_schema)).
|
||||
- **SQLite** uses `sqlite_schema`/`sqlite_master` + `PRAGMA table_info(<table>)`
|
||||
([SQLite: the schema table](https://sqlite.org/schematab.html)).
|
||||
|
||||
So the catalog query **belongs on `ISqlDialect`** (`ListTablesSql` / `ListColumnsSql`), not hardcoded.
|
||||
The ship-first SQL-Server dialect uses `INFORMATION_SCHEMA`; Postgres/MySQL reuse it; the SQLite
|
||||
fixture dialect (§5) uses `PRAGMA`; Oracle (later) uses `ALL_TAB_COLUMNS`. A cleaner but slower
|
||||
alternative that avoids catalog SQL entirely for the *column* level is
|
||||
`DbDataReader.GetColumnSchema()` on a `SELECT * … WHERE 1=0` (`SchemaOnly`) command — but it still
|
||||
needs catalog SQL for the table list, so the dialect method stays.
|
||||
|
||||
### 4.3 Picked-column → TagConfig
|
||||
|
||||
On column pick, the browser emits a `BrowseNode.NodeId` that encodes
|
||||
`provider|schema.table|column`, and the picker body composes the `TagConfig` blob (§3.2/3.3):
|
||||
DisplayName defaults to `table.column`, the inferred `DriverDataType` from `AttributesAsync` prefills
|
||||
`type`, and the operator chooses the model (key-value vs wide-row) + fills the key/row selector.
|
||||
`AttributeInfo.DriverDataType` carries the SQL type; `SecurityClass` = `ViewOnly` in read-only v1.
|
||||
|
||||
### 4.4 New `.Browser` project layout
|
||||
|
||||
Follow the exact `OpcUaClient.Browser` template (`docs/plans/2026-05-28-driver-browsers-design.md`):
|
||||
|
||||
| Path | Purpose |
|
||||
|---|---|
|
||||
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.SqlPoll.Browser/SqlPollDriverBrowser.cs` | `IDriverBrowser`, `DriverType => "SqlPoll"`; opens a `DbConnection` from form JSON (transient) |
|
||||
| `src/Drivers/…SqlPoll.Browser/SqlPollBrowseSession.cs` | `IBrowseSession`; `RootAsync`/`ExpandAsync`/`AttributesAsync` over the dialect's catalog queries; `SemaphoreSlim` gate (ADO.NET connection not concurrent) |
|
||||
| register in `AdminUI/EndpointRouteBuilderExtensions.cs` | `services.AddSingleton<IDriverBrowser, SqlPollDriverBrowser>();` (next to the OpcUaClient/Galaxy lines, `:49-50`) |
|
||||
| `AdminUI/Uns/TagEditors/TagConfigEditorMap.cs` | add `["SqlPoll"] = typeof(…SqlPollTagConfigEditor)` + `TagConfigValidator` entry (typed editor; else it falls to the raw-JSON textarea like Galaxy) |
|
||||
|
||||
The browser reuses the AdminUI `BrowseSessionRegistry` + reaper + 2-min idle TTL + per-call timeout
|
||||
unchanged. Form JSON contains the connection secret (same trust boundary as `TestDriverConnect` and
|
||||
the OpcUaClient browser) — deserialize → build connection → release, never cache the JSON.
|
||||
|
||||
---
|
||||
|
||||
## 5. Test-fixture strategy
|
||||
|
||||
- **SQLite in-memory / file** — the *unit-test* fixture. `Microsoft.Data.Sqlite` (already in-repo at
|
||||
10.0.7) needs no container; seed a `(tag_name, num_value, sample_ts)` key-value table and a
|
||||
wide-row `LatestStatus` table in-process. Covers read/subscribe mapping, type mapping, group-batch
|
||||
slicing, the poll engine wiring, and the browser over `PRAGMA table_info`. Fast, offline, CI-safe on
|
||||
macOS — the primary suite.
|
||||
- **Central SQL Server on the docker host** (`10.100.0.35,14330`, always-on per `CLAUDE.md`) — the
|
||||
*integration* fixture. A seeded `SqlPollFixture` database with the same two sample tables validates
|
||||
the real `Microsoft.Data.SqlClient` path, `INFORMATION_SCHEMA` browse, `CommandTimeout`/cancellation,
|
||||
and connection pooling. Env-gated (`SQLPOLL_TEST_ENDPOINT`) so it skips cleanly offline, like the
|
||||
other `*.IntegrationTests`. Deploy the seed via the docker rig; label the stack `project=lmxopcua`.
|
||||
- **Postgres/ODBC fixtures** land with their respective phases (a `postgres:16` container on the
|
||||
docker host for Npgsql; an ODBC DSN against the same SQL Server for the `System.Data.Odbc` path).
|
||||
- **Blackhole / timeout live-gate** (mirrors R2-01 S7): `docker pause` the SQL container mid-poll and
|
||||
assert the read surfaces `BadTimeout` + the driver degrades + the poll loop backs off, *not* a wedged
|
||||
thread. This is the single most important integration test given the frozen-peer risk class.
|
||||
|
||||
---
|
||||
|
||||
## 6. Security — secret handling
|
||||
|
||||
Connection-string credentials are the crown jewels and **must never be committed**. Mirror the
|
||||
repo's existing pattern for `ServerHistorian:ApiKey` (supplied via env `ServerHistorian__ApiKey`,
|
||||
appsettings carries only a `_comment` reminder) and the ConfigDb connection string (env-overridable):
|
||||
|
||||
- The `DriverConfig` JSON stores a **`connectionStringRef`** (a logical name), **not** the connection
|
||||
string. The Host resolves it from configuration/env at `InitializeAsync`
|
||||
(`SqlPoll__ConnectionStrings__<ref>`), so the secret lives in the process environment / secret store,
|
||||
never in the config DB row or any committed file.
|
||||
- If an inline `connectionString` is permitted at all (dev convenience), the AdminUI must redact it
|
||||
in display/logging and it must be flagged as dev-only. **Never log the resolved connection string**
|
||||
(Serilog); log only the provider + server host + database name.
|
||||
- Prefer integrated/managed auth where the estate supports it (`Integrated Security=true` /
|
||||
Azure AD / Kerberos) so no password transits config at all.
|
||||
- The `.Browser` path receives the secret in form JSON over the authenticated Blazor circuit (same
|
||||
boundary as every other driver browser + `TestDriverConnect`); it is used to build one transient
|
||||
connection and then released — no `_lastConfigJson` field anywhere.
|
||||
|
||||
---
|
||||
|
||||
## 7. Effort, risk, phasing
|
||||
|
||||
### 7.1 Key risks
|
||||
|
||||
1. **SQL injection (critical).** Every value is a bound `DbParameter`; identifiers (table/column/
|
||||
schema) that must appear in SQL text come **only** from `INFORMATION_SCHEMA`-validated,
|
||||
dialect-`QuoteIdentifier`'d names or an allow-list derived from browse — never from free-form tag
|
||||
input concatenated into a query. The named-query model stores operator-authored SQL as *config*
|
||||
(ConfigEditor-gated), and even there only parameters bind runtime values. Treat any code path that
|
||||
builds SQL by string concatenation of a tag field as a defect.
|
||||
2. **Secret handling (critical).** Per §6 — env/secret-store only, redact in UI/logs, never commit.
|
||||
3. **Frozen-peer / timeout wedge (high).** A slow/blocked DB must time out per-query and back off
|
||||
(the R2-01 lesson) — `CommandTimeout` + linked-CTS `OperationTimeout` on every command; assert it
|
||||
in the blackhole live-gate.
|
||||
4. **Connection pooling / exhaustion (medium).** Open-use-dispose per poll; never leak a `DbConnection`
|
||||
or `DbDataReader` (always `await using`); a group query that fans out to many tables must not open
|
||||
an unbounded number of concurrent connections — cap group-parallelism (Modbus/S7 precedent).
|
||||
5. **Provider portability (medium).** `INFORMATION_SCHEMA` covers SQL Server/Postgres/MySQL but *not*
|
||||
Oracle/SQLite (§4.2); parameter markers and identifier quoting differ. The `ISqlDialect` seam
|
||||
contains all of it — do not let dialect assumptions leak into the driver core.
|
||||
6. **Type / precision (low-medium).** `decimal`→`Float64` precision loss; `NULL` semantics; timezone
|
||||
handling on `datetime` vs `datetimeoffset`. Documented + per-tag `type` override.
|
||||
|
||||
### 7.2 Suggested phasing
|
||||
|
||||
1. **Phase 1 — SQL Server read-only, key-value + wide-row.** `.Contracts` (options + equipment-tag
|
||||
parser), `SqlPollDriver` (`IDriver`/`ITagDiscovery`/`IReadable`/`ISubscribable` via `PollGroupEngine`),
|
||||
`SqlServerDialect`, factory registration, `SqlPollTagConfigEditor` + validator, SQLite unit fixture,
|
||||
SQL-Server integration fixture (env-gated) + blackhole live-gate. No new NuGet deps.
|
||||
2. **Phase 2 — Browse.** `SqlPoll.Browser` project (`IDriverBrowser`/`IBrowseSession` over
|
||||
`INFORMATION_SCHEMA`), AdminUI picker body + registry wire-up, column→TagConfig commit.
|
||||
3. **Phase 3 — Named-query model + PostgreSQL + ODBC.** `PostgresDialect`/`OdbcDialect`, add `Npgsql`
|
||||
+ `System.Data.Odbc` `PackageVersion`s, Postgres container fixture.
|
||||
4. **Phase 4 (optional) — Write mode.** `IWritable` with per-tag parameterized UPSERT/StoredProc,
|
||||
triple-gated (`WriteOperate` role + `AllowWrites` + `TagConfig.writable`), `WriteIdempotent` retry
|
||||
policy, write-failure revert (already provided by the runtime).
|
||||
5. **Phase 5 (demand-driven) — MySQL/MariaDB (`MySqlConnector`) + native Oracle** (`ALL_TAB_COLUMNS`
|
||||
dialect).
|
||||
|
||||
### 7.3 Effort estimate
|
||||
|
||||
Phase 1 ≈ the Modbus driver minus the wire-protocol codec complexity (no byte-order/register math) but
|
||||
plus the dialect seam and result-set slicing — **medium**, most of it reusable from Modbus (poll engine,
|
||||
resolver, health state machine, factory shape are all shared). Phase 2 (browse) is **small** — a near-
|
||||
mechanical clone of `OpcUaClient.Browser` with a different one-level expansion. The provider/dialect
|
||||
work (Phase 3+) is where the long tail of portability effort lives; keeping it behind `ISqlDialect`
|
||||
from day one is what makes the ship-first SQL-Server slice cheap and the rest additive.
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
- [MS Learn — Obtain a SqlClientFactory (RegisterFactory in .NET Core, `SqlClientFactory.Instance`)](https://learn.microsoft.com/en-us/sql/connect/ado-net/obtain-sqlclientfactory)
|
||||
- [MS Learn — Obtaining a DbProviderFactory (`DbProviderFactories`, provider `Instance` pattern)](https://learn.microsoft.com/en-us/dotnet/framework/data/adonet/obtaining-a-dbproviderfactory)
|
||||
- [Oracle — ALL_TAB_COLUMNS (Oracle has no INFORMATION_SCHEMA)](https://docs.oracle.com/en/database/oracle/oracle-database/19/refrn/ALL_TAB_COLUMNS.html)
|
||||
- [dataedo — What is INFORMATION_SCHEMA and which databases support it](https://dataedo.com/kb/databases/all/information_schema)
|
||||
- [SQLite — The Schema Table (`sqlite_schema`/`PRAGMA table_info`)](https://sqlite.org/schematab.html)
|
||||
- In-repo grounding: `IDriver.cs`, `ISubscribable.cs`, `PollGroupEngine.cs`, `DriverDataType.cs`,
|
||||
`Driver.Modbus/ModbusDriver.cs` + `.Contracts`, `Commons/Browsing/*`,
|
||||
`Driver.OpcUaClient.Browser/*`, `AdminUI/EndpointRouteBuilderExtensions.cs`,
|
||||
`AdminUI/Uns/TagEditors/TagConfigEditorMap.cs`, `docs/plans/2026-05-28-driver-browsers-design.md`,
|
||||
`Directory.Packages.props` (Microsoft.Data.SqlClient 6.1.1, Microsoft.Data.Sqlite 10.0.7).
|
||||
Reference in New Issue
Block a user