# 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=`) → 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=&count=&interval=`** → 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 = ""` (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`). 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(); ``` ### 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:`, `di:`) | | `AttributesAsync(nodeId)` | For a DataItem leaf, one-or-few `AttributeInfo` rows describing it (`Name=dataItemName`, `DriverDataType=`, `IsArray=`, `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:` → the picker commits it, and the TagModal maps it to `TagConfig.FullName = ` 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:` 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 `||` 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`.