5ba9f1be6f
Code review of c46540ae approved the framing algorithm but moved the risk onto the contract around it. C1 (critical). SampleAsync returned normally on three different events: EOF, the closing --boundary--, and a response that was not multipart at all (where it yielded one parsed snapshot and finished). The seam documents the stream as yielding until ct is cancelled, so a Task 11 pump written to that contract would silently drop its subscription or hot-loop reconnecting; the non-multipart leg reported a configuration error as a healthy finished stream, which is the #485 quiet-successful-termination shape aimed at the very next task. Every non-cancellation end now throws: MTConnectStreamEndedException (ConnectionClosed / ClosingBoundary - transient) or MTConnectStreamNotSupportedException (configuration - reconnecting reproduces it forever), sharing one catchable base. The non-multipart body is still parsed first so an Agent's own MTConnectError text wins, but the document is NOT yielded. I1. IsSequenceGap moved to IMTConnectAgentClient (static) and its first parameter is now expectedFrom. The old name and doc were wrong for every chunk after the first - the correct comparand is the PREVIOUS chunk's NextSequence, and comparing against the opening "from" argument reports a gap on every chunk once the ring buffer rolls, i.e. an endless /current re-baseline storm. Both doc blocks also now record that cppagent answers from < firstSequence with an OUT_OF_RANGE MTConnectError under HTTP 200, which surfaces as InvalidDataException and NOT as a gap - so Task 11 must treat that parse failure as a re-baseline trigger too. I2. Every multipart test served the whole body from one buffer, so every framing test completed in a single ReadAsync - the split-boundary path, the split-header path and the multi-fill loop were correct by inspection only. A ChunkedStream double (N bytes per read, N = 1/3/7) now drives the fixtures through a split transport, plus a >16 KB document that forces a buffer resize. Falsifiability: removing either scan overlap, or collapsing the fill loop, fails ONLY the new chunked tests. I3. Disposal mid-enumeration surfaces as OperationCanceledException via an internal dispose-linked token, not a raw ObjectDisposedException that Task 9's re-init would inflict on an enumerating pump. I5. HeartbeatMs, SampleIntervalMs and SampleCount join RequestTimeoutMs in construction-time positive-value validation. All three reach the query string and an Agent answers heartbeat=0 with an HTTP-200 MTConnectError that names no config key. I4/M2. The no-Content-length framing fallback logs a one-shot warning (the client takes an optional ILogger); a multipart response with no boundary parameter fails fast instead of degrading into a watchdog timeout. M5/M6. Shared element/attribute reading rules extracted to MTConnectXml; the probe parser documents why it alone does not trim BOM/whitespace. The literal U+FEFF in source became a '' escape. Also, from Task 8: MTConnectObservation gained IsStructured (default false), set from element.HasElements. A DATA_SET/TABLE observation's <Entry> children concatenate through element.Value to nonsense ("12" for two entries) that the index would publish as Good, and only the parser can tell that apart from a legitimate space-bearing Message. False for CONDITION observations - their value comes from the element name, so child content cannot corrupt it. 275/275 green in this suite's own files.
919 lines
56 KiB
Markdown
919 lines
56 KiB
Markdown
# MTConnect Agent Driver Implementation Plan
|
||
|
||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (or subagent-driven-development) to implement this plan task-by-task.
|
||
|
||
**Goal:** Ship a read-only, browsable `DriverType = "MTConnect"` Equipment-kind driver (P1 Agent MVP) that surfaces an MTConnect Agent's `/probe` device model as OPC UA nodes and serves live values via `/current` (read) + `/sample` long-poll (subscribe), with browse coming free from the Wave-0 universal discovery browser.
|
||
|
||
**Architecture:** One `MTConnectDriver` per instance implements `IDriver`, `ITagDiscovery` (`SupportsOnlineDiscovery=true`, `RediscoverPolicy=Once`), `IReadable`, `ISubscribable`, `IHostConnectivityProbe`, `IRediscoverable` — **not** `IWritable` (Agent surface is read-only). A thin `IMTConnectAgentClient` seam wraps the HTTP/XML transport (probe/current/sample) so the whole driver is unit-tested against canned XML with no network. A per-instance `MTConnectObservationIndex` holds `dataItemId → latest DataValueSnapshot`, updated by `/current` and the `/sample` pump; `UNAVAILABLE → BadNoCommunication`. Browse is served by the already-shipped `DiscoveryDriverBrowser` — this driver ships **no browser code**, only the `SupportsOnlineDiscovery` opt-in.
|
||
|
||
**Tech Stack:** `HttpClient` behind the `IMTConnectAgentClient` seam — **TrakHound MTConnect.NET (`-Common` + `-HTTP`, MIT, netstandard2.0) pending the Task 0 verification, else a hand-rolled `System.Xml.Linq` + multipart boundary-reader fallback (drop-in behind the same seam)**; `System.Text.Json` for config DTOs (enum-as-name, `JsonStringEnumConverter` on the probe, `ParseEnum<T>` on the factory); the Wave-0 universal browser seam (`ITagDiscovery.SupportsOnlineDiscovery`).
|
||
|
||
**Source design:** docs/plans/2026-07-15-mtconnect-driver-design.md
|
||
**Program context:** docs/plans/2026-07-15-driver-expansion-program-design.md (§3 shared contract, §4 two-tier browse, §7 fixtures)
|
||
**Wave-0 dependency:** docs/plans/2026-07-15-universal-discovery-browser-design.md — the browse picker live-verify (Task 21) is gated on the Wave-0 universal-browser live gate (Gitea #468) being closed.
|
||
|
||
---
|
||
|
||
## Cross-cutting constraints (apply to every task)
|
||
|
||
- **Per-op deadline (R2-01 frozen-peer lesson).** Every agent HTTP call carries a bounded deadline: `/probe` + `/current` wrap in a `CancellationTokenSource(RequestTimeoutMs)` linked to the caller's `ct` AND set `HttpClient.Timeout`; the `/sample` long-poll cannot use `HttpClient.Timeout`, so it runs under a heartbeat watchdog (§7 of the design). No unbounded waits.
|
||
- **Enum-serialization trap (project-wide MEMORY).** Enums on any config/tag surface serialize as **name strings**: the AdminUI model writes them via `TagConfigJson.Set` (name), the probe parses with `new JsonStringEnumConverter()`, and the factory keeps enum-carrying DTO fields `string?` + parses via `ParseEnum<T>` (no converter in the factory `JsonOptions`). A numerically-serialized enum faults the driver at deploy.
|
||
- **Ctor is connection-free.** The `MTConnectDriver` constructor opens no sockets — every connect happens in `InitializeAsync`. The universal browser's throwaway-instance `CanBrowse` pattern depends on this.
|
||
- **Never throw across a capability boundary.** `IReadable` reports per-ref failures as Bad-coded snapshots (throws only if the driver itself is unreachable); `IDriverProbe.ProbeAsync` never throws (returns `Ok=false`).
|
||
- **Secrets from env/secret-refs.** No agent URL creds committed or logged.
|
||
- **`Float64`/`Int64` are the real `DriverDataType` member names** (verified in `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverDataType.cs`).
|
||
|
||
---
|
||
|
||
## Task 0: Verify TrakHound MTConnect.NET pin (or record the hand-roll decision)
|
||
|
||
**Classification:** small
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** none (gates the `.csproj` package refs in Task 1)
|
||
**Files:**
|
||
- Modify: `docs/plans/2026-07-24-mtconnect-driver.md` (record the decision inline under this task)
|
||
|
||
The design (§2 "Library verification checklist") flags three research-sourced facts as unverifiable offline. Verify against real NuGet before committing to the dependency:
|
||
|
||
1. `dotnet package search MTConnect.NET-Common --exact-match` (and `-HTTP`) — confirm the pinned version (`6.9.0.2` or then-current) **exists on nuget.org**.
|
||
2. In a throwaway project, `dotnet add package MTConnect.NET-Common -v <pin>` + `dotnet restore` — confirm it **restores** and its TFM set includes `netstandard2.0` (loads on net10).
|
||
3. Open the restored package's embedded `LICENSE` — confirm the *pinned version* is **MIT** (older releases carried mixed MIT/Apache/"all rights reserved").
|
||
|
||
**Decision rule:** all three pass ⇒ TrakHound path (Task 1 adds the two `PackageReference`s). Any fail ⇒ **hand-roll fallback**: `HttpClient` + `System.Xml.Linq` for probe/current + a `multipart/x-mixed-replace` boundary reader for sample (~300–500 LoC behind the same `IMTConnectAgentClient` seam; the driver above the seam is byte-identical either way). Record the chosen path and the observed version/TFM/license as a one-paragraph **DECISION** note appended to this task in the plan file.
|
||
|
||
**No test / no commit** for this task (it's a decision + a doc edit). Commit the plan edit with the next task, or standalone:
|
||
|
||
```bash
|
||
git add docs/plans/2026-07-24-mtconnect-driver.md
|
||
git commit -m "docs(mtconnect): record TrakHound-vs-hand-rolled client decision (Task 0)"
|
||
```
|
||
|
||
**DECISION (verified 2026-07-24): TrakHound path.** All three checks passed against real nuget.org.
|
||
(1) `dotnet package search MTConnect.NET-Common --exact-match` and `...-HTTP --exact-match` both list
|
||
versions `3.2.0` through `6.9.0.2` on `nuget.org`, confirming `6.9.0.2` is the current top-of-list
|
||
version for both package ids (no version mismatch between the two). (2) A throwaway `net10.0` console
|
||
project in the scratchpad (outside the repo, so unaffected by this repo's `packageSourceMapping`) ran
|
||
`dotnet add package MTConnect.NET-Common -v 6.9.0.2` + `...-HTTP -v 6.9.0.2` + `dotnet restore` — both
|
||
restored cleanly with no source-mapping or compatibility errors; `project.assets.json` resolved
|
||
`MTConnect.NET-Common`, `MTConnect.NET-HTTP`, and the transitive `MTConnect.NET-TLS` (all `6.9.0.2`)
|
||
against the `net10.0` target framework, and each package's `lib/` folder contains a `netstandard2.0`
|
||
build (alongside `net6.0`–`net9.0`, `net461`–`net472`, `net47`, `net48`), so net10.0 resolves via the
|
||
in-box compatibility mapping. (3) The pinned version carries **no embedded LICENSE file** in the
|
||
`.nupkg` (`unzip -l` shows no `LICENSE*` entry in either package) — instead both nuspecs declare the
|
||
modern SPDX license-expression form `<license type="expression">MIT</license>` +
|
||
`<licenseUrl>https://licenses.nuget.org/MIT</licenseUrl>`, which nuget.org validates against the SPDX
|
||
list at push time and surfaces on the package page (confirmed live: the nuget.org page for
|
||
`MTConnect.NET-Common` `6.9.0.2` displays "MIT license" linked to `licenses.nuget.org/MIT`) — a
|
||
structurally stronger guarantee than a free-text embedded file for this specific version, so the
|
||
absence of a physical `LICENSE` file is not a fail. Task 1 adds
|
||
`PackageReference MTConnect.NET-Common 6.9.0.2` + `PackageReference MTConnect.NET-HTTP 6.9.0.2` to
|
||
`Driver.MTConnect.csproj`; the hand-roll fallback is not needed.
|
||
|
||
**CORRECTION (Task 6, commit `bdfefadc`) — the decision above rested on incomplete evidence, and the
|
||
hand-roll fallback IS in use for parsing.** Task 0 verified the packages *exist, restore, and are MIT*;
|
||
it never verified they can actually parse an MTConnect document. As pinned, they cannot. Task 6 proved
|
||
this empirically before writing any parser code: reflection over both referenced assemblies
|
||
(`MTConnect.NET-Common`, 1297 types; `-HTTP`, 410 types) found **zero `IResponseDocumentFormatter`
|
||
implementations and zero public static `FromXml`/parse entry points**, and a live invocation of
|
||
TrakHound's own entry point (`ResponseDocumentFormatter.CreateDevicesResponseDocument("xml", stream)`)
|
||
against this repo's `Fixtures/probe.xml`, with both DLLs loaded, returned:
|
||
|
||
```
|
||
Success = False
|
||
ERROR: Document Formatter Not found for "xml"
|
||
```
|
||
|
||
TrakHound 6.x resolves formatters from loaded assemblies at runtime, and the XML formatter ships in a
|
||
**third package, `MTConnect.NET-XML`**, which Task 0 did not evaluate. Even with it added, TrakHound
|
||
exposes no socket-free `Parse(string)` entry point of the shape this plan mandates. So `/probe` parsing
|
||
is a hand-rolled `System.Xml.Linq` walk (~250 LoC, `MTConnectProbeParser`); the driver above the
|
||
`IMTConnectAgentClient` seam is unaffected, exactly as the decision rule anticipated.
|
||
|
||
**The two `PackageReference`s remain and are, so far, dead weight.** Task 6 deliberately did not remove
|
||
them: the hard remaining problem is Task 7's `multipart/x-mixed-replace` chunk framing for `/sample`,
|
||
and `MTConnect.Clients.MTConnectHttpClientStream` in `-HTTP` may be usable for framing alone. **Task 7
|
||
owns the final call:** (a) use `-HTTP` for framing only, (b) add `MTConnect.NET-XML` and reconsider
|
||
wholesale, or (c) hand-roll the boundary reader and **drop both package references** — in which case the
|
||
Task-0 rationale comment in `Directory.Packages.props` goes with them. Record the outcome here.
|
||
|
||
**OUTCOME (Task 7) — option (c): hand-rolled boundary reader, BOTH package references DROPPED.**
|
||
`MTConnect.NET-Common`, `MTConnect.NET-HTTP` and the transitive `MTConnect.NET-TLS` are gone from
|
||
`Directory.Packages.props` and from `Driver.MTConnect.csproj`; the Task-0 rationale comments went with
|
||
them (each replaced by a comment naming this block). **The driver now depends on nothing but the BCL** —
|
||
`HttpClient` + `System.Xml.Linq`. Reflection over `MTConnect.NET-HTTP` 6.9.0.2 (net9.0 lib, with
|
||
`-Common` resolved alongside) settled the one open candidate:
|
||
|
||
```
|
||
TYPE MTConnect.Clients.MTConnectHttpClientStream
|
||
.ctor(String url, String documentFormat)
|
||
Void Start(CancellationToken), Void Stop(), Task Run(CancellationToken)
|
||
event DocumentReceived / ErrorReceived / FormatError / ConnectionError / …
|
||
```
|
||
|
||
Three independent disqualifiers, any one of which is fatal:
|
||
|
||
1. **It cannot be exercised without a socket.** Its only constructor takes a *URL* and it dials the
|
||
connection itself inside `Run` — there is no `HttpMessageHandler`, no `HttpClient`, and no `Stream`
|
||
injection point. The whole `IMTConnectAgentClient` seam exists so Tasks 6–13 are unit-testable
|
||
against canned XML with no listener; adopting this type would have forced a live agent (or a real
|
||
loopback HTTP server) into the unit suite.
|
||
2. **"Framing alone" was never actually on offer.** The type does not surface raw part payloads — it
|
||
raises `DocumentReceived` with an already-parsed `IDocument`, resolved through the same
|
||
`documentFormat` formatter lookup that Task 6 proved is missing. Using it for framing would still
|
||
have required adding `MTConnect.NET-XML`, i.e. option (b) in disguise.
|
||
3. **The shape is inverted and the payload is heavy.** An event-driven `Start`/`Run` object has to be
|
||
adapted back into the `IAsyncEnumerable<MTConnectStreamsResult>` the seam declares, and `-HTTP`
|
||
vendors an entire embedded web server (`Ceen.*` types) that this driver would never touch.
|
||
|
||
The replacement is `MultipartStreamReader`, a ~200-line private nested class in
|
||
`MTConnectAgentClient.cs` that frames `multipart/x-mixed-replace` off the response stream. Two paths:
|
||
`Content-length` present (every agent in the wild writes it) → read by length and emit the chunk the
|
||
instant it completes; absent → scan to the next boundary, which necessarily emits one chunk late and
|
||
exists as a correctness fallback only. Both paths are covered by tests. Every read is bounded by a
|
||
heartbeat watchdog (`2 × HeartbeatMs + RequestTimeoutMs`) so a silent agent faults instead of wedging
|
||
the pump — the R2-01 shape — and the buffer is capped at 32 MiB.
|
||
|
||
**Also settled here: the streaming leg gets its own `HttpClient`.** `HttpClient.Timeout` is a
|
||
per-instance whole-response deadline, so the long poll cannot share the unary client. Raising the one
|
||
timeout would have un-bounded `/probe` + `/current`, which R2-01 forbids; instead `_streamHttp` runs
|
||
`Timeout.InfiniteTimeSpan` and is bounded by the request deadline on its *header* phase plus the
|
||
watchdog on its body.
|
||
|
||
**Process lesson:** "the dependency restores" is not "the dependency does the job." A library
|
||
verification checklist needs one end-to-end call against a real input, not just a restore. Task 7 adds
|
||
a second: **check the constructor for a test seam before counting a library as usable** — a type that
|
||
can only be handed a URL cannot participate in a socket-free unit suite, however good its internals.
|
||
|
||
---
|
||
|
||
## Task 1: Scaffold the two driver projects + the test project
|
||
|
||
**Classification:** small
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** none (every later task builds on these projects)
|
||
**Files:**
|
||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts.csproj`
|
||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.csproj`
|
||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests.csproj`
|
||
- Modify: `ZB.MOM.WW.OtOpcUa.slnx` (add the three `<Project Path=...>` lines)
|
||
|
||
Steps:
|
||
- Copy the `.Contracts` csproj boilerplate from `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/…csproj`: `net10.0`, `Nullable=enable`, `ImplicitUsings=enable`, `TreatWarningsAsErrors=true`. `.Contracts` references **only** `Core.Abstractions` (no backend NuGet). Add `GenerateDocumentationFile=true` (matches the fleet).
|
||
- The runtime `Driver.MTConnect.csproj` references `Core`, `Core.Abstractions`, `.Contracts`, and — **per the Task 0 decision** — either the two TrakHound packages or nothing extra (hand-roll uses only the BCL). Add `InternalsVisibleTo` the Tests project (mirror Modbus).
|
||
- The `.Tests` csproj: xUnit + Shouldly (copy `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/…csproj` package block), ProjectReferences to `Driver.MTConnect` + `.Contracts`. Mark the future `Fixtures/*.xml` as `CopyToOutputDirectory` (`<Content Include="Fixtures\**\*.xml"><CopyToOutputDirectory>PreserveNewest…`).
|
||
- Add all three to `ZB.MOM.WW.OtOpcUa.slnx` beside the Modbus entries (lines ~29–92 pattern).
|
||
|
||
Verify:
|
||
|
||
```bash
|
||
dotnet build src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.csproj # expect: PASS (empty compile)
|
||
```
|
||
|
||
```bash
|
||
git add -A && git commit -m "build(mtconnect): scaffold Contracts+Driver+Tests projects, add to slnx (Task 1)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: `MTConnectDriverOptions` + `MTConnectTagDefinition` in `.Contracts`
|
||
|
||
**Classification:** small
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** Task 3, Task 4
|
||
**Files:**
|
||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/MTConnectDriverOptions.cs`
|
||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/MTConnectTagDefinition.cs`
|
||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDriverOptionsTests.cs`
|
||
|
||
Mirror `ModbusDriverOptions`: strongly-typed record/POCO holding `AgentUri` (required), optional `DeviceName` scope, `RequestTimeoutMs`, `SampleIntervalMs`, `SampleCount`, `HeartbeatMs`, a `Probe` sub-record (mirror `ModbusProbeOptions`: `Enabled`/`Interval`/`Timeout`), and a `Reconnect` sub-record (`MinBackoffMs`/`MaxBackoffMs`/multiplier — mirror `ModbusReconnectOptions`). `MTConnectTagDefinition`: one record per authored tag — `FullName` (= `dataItemId`), `DriverDataType`, `IsArray`, `ArrayDim`, plus `mtCategory`/`mtType`/`mtSubType`/`units` metadata.
|
||
|
||
TDD — write the failing test first (defaults + required-field shape):
|
||
|
||
```csharp
|
||
[Fact]
|
||
public void Options_default_sample_and_heartbeat_are_sane()
|
||
{
|
||
var o = new MTConnectDriverOptions { AgentUri = "http://agent:5000" };
|
||
o.SampleIntervalMs.ShouldBeGreaterThan(0);
|
||
o.HeartbeatMs.ShouldBeGreaterThan(0);
|
||
o.Probe.Enabled.ShouldBeTrue();
|
||
}
|
||
```
|
||
|
||
```bash
|
||
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests --filter "FullyQualifiedName~MTConnectDriverOptionsTests" # RED, then GREEN
|
||
git add -A && git commit -m "feat(mtconnect): driver options + tag definition records (Task 2)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: `MTConnectDataTypeInference.Infer` + golden type-map test
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** Task 2, Task 4
|
||
**Files:**
|
||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/MTConnectDataTypeInference.cs`
|
||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDataTypeInferenceTests.cs`
|
||
|
||
Pure `static DriverDataType Infer(string category, string? type, string? units, string? representation)` implementing the §3.3 table. It lives in `.Contracts` so the driver, browser-commit, and the typed editor all agree. Return `(DriverDataType, bool isArray, uint? arrayDim)` (or a small record) so `TIME_SERIES` can carry `IsArray=true` + declared `sampleCount`.
|
||
|
||
Golden table (design §3.3):
|
||
|
||
| category / shape | result |
|
||
|---|---|
|
||
| `SAMPLE` numeric with `units` | `Float64` |
|
||
| `SAMPLE` `representation=TIME_SERIES` | `Float64` array (`IsArray=true`, `ArrayDim=sampleCount` or null) |
|
||
| `EVENT` numeric type (`PartCount`, `Line`) | `Int64` |
|
||
| `EVENT` controlled-vocab (`Execution`, `ControllerMode`, `Availability`) | `String` |
|
||
| `EVENT` free text (`Program`, `Block`, `Message`) | `String` |
|
||
| `CONDITION` | `String` |
|
||
|
||
TDD — one `[Theory]` covering every row (golden):
|
||
|
||
```csharp
|
||
[Theory]
|
||
[InlineData("SAMPLE", "Position", "MILLIMETER", null, DriverDataType.Float64)]
|
||
[InlineData("EVENT", "PartCount", null, null, DriverDataType.Int64)]
|
||
[InlineData("EVENT", "Execution", null, null, DriverDataType.String)]
|
||
[InlineData("EVENT", "Program", null, null, DriverDataType.String)]
|
||
[InlineData("CONDITION", "Temperature", null, null, DriverDataType.String)]
|
||
public void Infer_maps_the_v1_table(string cat, string type, string? units, string? rep, DriverDataType expected)
|
||
=> MTConnectDataTypeInference.Infer(cat, type, units, rep).DataType.ShouldBe(expected);
|
||
|
||
[Fact]
|
||
public void TimeSeries_sample_is_a_float64_array_with_declared_count()
|
||
{
|
||
var r = MTConnectDataTypeInference.Infer("SAMPLE", "Position", "MM", "TIME_SERIES", sampleCount: 8);
|
||
r.DataType.ShouldBe(DriverDataType.Float64);
|
||
r.IsArray.ShouldBeTrue();
|
||
r.ArrayDim.ShouldBe(8u);
|
||
}
|
||
```
|
||
|
||
```bash
|
||
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests --filter "FullyQualifiedName~MTConnectDataTypeInferenceTests" # RED, then GREEN
|
||
git add -A && git commit -m "feat(mtconnect): pure data-type inference table + golden test (Task 3)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: Capture the canned XML fixtures
|
||
|
||
**Classification:** small
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** Task 2, Task 3
|
||
**Files:**
|
||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/Fixtures/probe.xml`
|
||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/Fixtures/current.xml`
|
||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/Fixtures/sample.xml`
|
||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/Fixtures/sample-gap.xml`
|
||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/Fixtures/current-unavailable.xml`
|
||
|
||
These canned docs drive the bulk of unit coverage with no network (design §8.1). Capture from a demo agent (`https://demo.mtconnect.org/probe` / `/current` / `/sample`) or hand-author minimal valid MTConnect 1.x XML covering:
|
||
- `probe.xml` — one `MTConnectDevices` with a `Device` → nested `Component` → `DataItem`s spanning every §3.3 category (a `SAMPLE` w/ units, a `TIME_SERIES` w/ `sampleCount`, an `EVENT` numeric, an `EVENT` controlled-vocab, a `CONDITION`), each with a distinct `id`.
|
||
- `current.xml` — an `MTConnectStreams` with a `Header instanceId=... nextSequence=...` and one observation per data item, including at least one `<... >UNAVAILABLE</...>`.
|
||
- `sample.xml` — an `MTConnectStreams` chunk with several observations and a `Header nextSequence` that continues contiguously from `current.xml`.
|
||
- `sample-gap.xml` — a `Header` whose `firstSequence` is **newer** than the `from` the driver would request (the ring-buffer-overflow / forced-`nextSequence`-gap case Task 7 + Task 11 assert re-baseline on).
|
||
- `current-unavailable.xml` — every observation `UNAVAILABLE` (Task 8's quality-mapping fixture).
|
||
|
||
No code, no test yet — verify the files copy to bin:
|
||
|
||
```bash
|
||
dotnet build tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests # PASS; then confirm bin/.../Fixtures/*.xml exist
|
||
git add -A && git commit -m "test(mtconnect): canned probe/current/sample XML fixtures incl. forced-gap (Task 4)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: `IMTConnectAgentClient` seam + return DTOs
|
||
|
||
**Classification:** small
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** Task 2, Task 3, Task 4
|
||
**Files:**
|
||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/IMTConnectAgentClient.cs`
|
||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDtos.cs` (parsed model + observation records)
|
||
|
||
Define the seam the whole driver is tested behind:
|
||
|
||
```csharp
|
||
public interface IMTConnectAgentClient
|
||
{
|
||
Task<MTConnectProbeModel> ProbeAsync(CancellationToken ct);
|
||
Task<MTConnectStreamsResult> CurrentAsync(CancellationToken ct);
|
||
IAsyncEnumerable<MTConnectStreamsResult> SampleAsync(long from, CancellationToken ct);
|
||
}
|
||
```
|
||
|
||
`MTConnectProbeModel` — devices → components (nested) → data items (`Id`, `Name?`, `Category`, `Type`, `SubType?`, `Units?`, `Representation?`, `SampleCount?`). `MTConnectStreamsResult` — `long InstanceId`, `long NextSequence`, `long FirstSequence`, and `IReadOnlyList<MTConnectObservation>` (`DataItemId`, `Value` (string or `"UNAVAILABLE"`), `TimestampUtc`). These DTOs are the neutral shape both the TrakHound adapter and the hand-roll fallback produce, so the driver never sees TrakHound types (or `XElement`) directly.
|
||
|
||
Verify (compiles only):
|
||
|
||
```bash
|
||
dotnet build src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect # PASS
|
||
git add -A && git commit -m "feat(mtconnect): IMTConnectAgentClient seam + neutral parse DTOs (Task 5)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: `MTConnectAgentClient` — parse `/probe` into the device model
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** none
|
||
**Files:**
|
||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectAgentClient.cs` (probe leg only this task)
|
||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectProbeParseTests.cs`
|
||
|
||
Implement `ProbeAsync` — either via TrakHound's `MTConnectHttpClient` (map its `IDevice`/`IComponent`/`IDataItem` into the neutral `MTConnectProbeModel`) or the hand-roll `System.Xml.Linq` walk of `probe.xml`. The `/probe` call carries the linked-CTS deadline. To keep the parse itself unit-testable without a socket, factor the byte→model parse into an internal static (`MTConnectProbeParser.Parse(Stream|string)`) and feed it the fixture directly.
|
||
|
||
TDD:
|
||
|
||
```csharp
|
||
[Fact]
|
||
public async Task Probe_parse_yields_nested_components_and_all_dataitems()
|
||
{
|
||
var xml = await File.ReadAllTextAsync("Fixtures/probe.xml");
|
||
var model = MTConnectProbeParser.Parse(xml);
|
||
model.Devices.ShouldHaveSingleItem();
|
||
var ids = model.Devices[0].AllDataItems().Select(d => d.Id).ToList();
|
||
ids.ShouldContain("<the CONDITION dataItem id from the fixture>");
|
||
model.Devices[0].Components.ShouldNotBeEmpty(); // nesting preserved
|
||
}
|
||
```
|
||
|
||
```bash
|
||
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests --filter "FullyQualifiedName~MTConnectProbeParseTests" # RED, then GREEN
|
||
git add -A && git commit -m "feat(mtconnect): agent client /probe parse to device model (Task 6)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: `MTConnectAgentClient` — parse `/current` + `/sample`, detect the sequence gap
|
||
|
||
**Classification:** high-risk (ring-buffer / sequence paging correctness)
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** none
|
||
**Files:**
|
||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectAgentClient.cs` (add current/sample legs + `MTConnectStreamsParser`)
|
||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectStreamsParseTests.cs`
|
||
|
||
`CurrentAsync` parses `current.xml` → `MTConnectStreamsResult` (Header `instanceId`/`nextSequence`/`firstSequence` + observations). `SampleAsync(from, ct)` yields one `MTConnectStreamsResult` per multipart chunk and **exposes the gap signal**: expose a helper `bool IsSequenceGap(long requestedFrom, MTConnectStreamsResult chunk) => chunk.FirstSequence > requestedFrom` so Task 11's pump can re-baseline. Advance the caller's `from` to `chunk.NextSequence` (contiguous). Chunk framing (the multipart boundary reader) is TrakHound-internal or the hand-roll's boundary reader — either way the parser is fed one chunk's XML.
|
||
|
||
TDD (the forced-gap fixture is the load-bearing case):
|
||
|
||
```csharp
|
||
[Fact]
|
||
public void Current_parse_reads_header_sequences_and_observations()
|
||
{
|
||
var r = MTConnectStreamsParser.Parse(File.ReadAllText("Fixtures/current.xml"));
|
||
r.NextSequence.ShouldBeGreaterThan(0);
|
||
r.Observations.ShouldNotBeEmpty();
|
||
}
|
||
|
||
[Fact]
|
||
public void Sequence_gap_is_detected_when_firstSequence_exceeds_requested_from()
|
||
{
|
||
var chunk = MTConnectStreamsParser.Parse(File.ReadAllText("Fixtures/sample-gap.xml"));
|
||
MTConnectAgentClient.IsSequenceGap(requestedFrom: 1, chunk).ShouldBeTrue();
|
||
}
|
||
```
|
||
|
||
```bash
|
||
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests --filter "FullyQualifiedName~MTConnectStreamsParseTests" # RED, then GREEN
|
||
git add -A && git commit -m "feat(mtconnect): current/sample parse + sequence-gap detection (Task 7)"
|
||
```
|
||
|
||
### Task 7 review remediation (post-`c46540ae`) — three contract changes Tasks 9/11/13 must build against
|
||
|
||
Code review of `c46540ae` approved the framing algorithm but moved the risk onto the **contract**. Three
|
||
changes here are breaking relative to the sketch above; the rest are hardening.
|
||
|
||
1. **`SampleAsync` never ends normally except by cancellation.** The seam documents the stream as
|
||
yielding "indefinitely, until `ct` is cancelled", but the implementation returned normally on EOF, on
|
||
the closing `--boundary--`, and — worst — on a response that was not multipart at all, where it
|
||
yielded one parsed snapshot and finished. A pump written to the documented contract has no reason to
|
||
handle normal completion, so it would silently drop its subscription or hot-loop reconnecting; the
|
||
non-multipart leg additionally reported a **configuration error as a healthy finished stream**, which
|
||
is the #485 quiet-successful-termination shape aimed straight at Task 11. Every non-cancellation end
|
||
now throws: `MTConnectStreamEndedException` (with `MTConnectStreamEndReason.ConnectionClosed` /
|
||
`ClosingBoundary` — transient, reconnect) or `MTConnectStreamNotSupportedException` (configuration —
|
||
reconnecting reproduces it forever), sharing the base `MTConnectStreamException`. The non-multipart
|
||
body is still parsed first, so an Agent's own `MTConnectError` text still wins, but the document is
|
||
**not** yielded.
|
||
2. **`IsSequenceGap` moved to `IMTConnectAgentClient` (static) and its first parameter is now
|
||
`expectedFrom`.** The sketch's `requestedFrom` name and doc were wrong for every chunk after the
|
||
first: the correct comparand is the **previous chunk's `NextSequence`**, and comparing against the
|
||
opening `from` reports a gap on every chunk once the ring buffer rolls — an endless `/current`
|
||
re-baseline storm against a healthy stream. Rehomed onto the seam so Task 11 need not reference the
|
||
concrete client. **Task 11 must also treat an `OUT_OF_RANGE` `InvalidDataException` as a re-baseline
|
||
trigger:** cppagent answers a `from` below `firstSequence` with an `MTConnectError` under HTTP 200,
|
||
so ring-buffer overflow arrives as a *parse failure*, never as a gap-bearing chunk. `IsSequenceGap`
|
||
alone does not cover it. Documented on the seam.
|
||
3. **`MTConnectObservation` gained `IsStructured`** (default `false`), set from `element.HasElements`.
|
||
A DATA_SET/TABLE observation's `<Entry key=…>` children concatenate through `element.Value` to
|
||
nonsense (`"12"` for two entries) which the index would otherwise publish as Good, and only the
|
||
parser can tell that apart from a legitimate space-bearing `Message` — no value-shape heuristic
|
||
works. Deliberately **false for CONDITION** observations: their value comes from the element *name*,
|
||
so child content cannot corrupt it. The index maps the flag to a status; the parser does not.
|
||
|
||
Hardening in the same pass: disposal mid-enumeration now surfaces as `OperationCanceledException`
|
||
(via an internal dispose-linked token) rather than a raw `ObjectDisposedException`/`IOException` that
|
||
Task 9's re-init would otherwise inflict on an enumerating pump; `HeartbeatMs`, `SampleIntervalMs` and
|
||
`SampleCount` join `RequestTimeoutMs` in construction-time positive-value validation (all three reach
|
||
the query string, and an Agent answers `heartbeat=0` with an HTTP-200 `MTConnectError` that would name
|
||
no config key); a `multipart/*` response with no `boundary` parameter fails fast instead of degrading
|
||
into a watchdog timeout; the no-`Content-length` framing fallback logs a one-shot warning (the client
|
||
now takes an optional `ILogger`); and the shared element/attribute reading rules moved into
|
||
`MTConnectXml`, used by both parsers.
|
||
|
||
**Coverage gap closed, and worth remembering as a pattern.** Every multipart test served the whole body
|
||
from one buffer, so **every framing test completed in a single `ReadAsync`** — the split-boundary path,
|
||
the split-header path and the multi-fill loop were correct by inspection only, on the task whose
|
||
headline risk is framing. A `ChunkedStream` double returning N bytes per read (N = 1, 3, 7) now drives
|
||
the fixtures through a split transport. Falsifiability confirms the gap was real: removing the
|
||
split-boundary overlap, removing the split-header overlap, and collapsing `EnsureAsync`'s fill loop each
|
||
fail **only** the new chunked tests — every pre-existing multipart test stays green under all three.
|
||
|
||
---
|
||
|
||
## Task 8: `MTConnectObservationIndex` + `UNAVAILABLE → BadNoCommunication`
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** none
|
||
**Files:**
|
||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectObservationIndex.cs`
|
||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectObservationIndexTests.cs`
|
||
|
||
Thread-safe `dataItemId → DataValueSnapshot` map updated by `/current` and `/sample`. The snapshot builder maps a raw observation → `DataValueSnapshot`:
|
||
- value `UNAVAILABLE` → `new(null, BadNoCommunication, ts, now)` where `private const uint BadNoCommunication = 0x80310000u;` (design §3.3 — declared as a const, the Modbus `StatusBadCommunicationError` pattern; renders by name in the CLI's `SnapshotFormatter`).
|
||
- a present value → coerced to the tag's `DriverDataType` with `StatusCode = Good (0)`, `SourceTimestampUtc = observation.timestamp`.
|
||
- a `dataItemId` never seen / empty condition → `Bad`-coded snapshot.
|
||
|
||
TDD (drive off `current.xml` + `current-unavailable.xml`):
|
||
|
||
```csharp
|
||
[Fact]
|
||
public void Unavailable_observation_maps_to_BadNoCommunication_with_null_value()
|
||
{
|
||
var idx = new MTConnectObservationIndex();
|
||
idx.Apply(MTConnectStreamsParser.Parse(File.ReadAllText("Fixtures/current-unavailable.xml")));
|
||
var snap = idx.Get("<a dataItemId from the fixture>");
|
||
snap.Value.ShouldBeNull();
|
||
snap.StatusCode.ShouldBe(0x80310000u);
|
||
}
|
||
|
||
[Fact]
|
||
public void Present_value_indexes_good_with_source_timestamp()
|
||
{
|
||
var idx = new MTConnectObservationIndex();
|
||
idx.Apply(MTConnectStreamsParser.Parse(File.ReadAllText("Fixtures/current.xml")));
|
||
var snap = idx.Get("<a good dataItemId>");
|
||
snap.StatusCode.ShouldBe(0u);
|
||
snap.SourceTimestampUtc.ShouldNotBeNull();
|
||
}
|
||
```
|
||
|
||
```bash
|
||
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests --filter "FullyQualifiedName~MTConnectObservationIndexTests" # RED, then GREEN
|
||
git add -A && git commit -m "feat(mtconnect): observation index + UNAVAILABLE->BadNoCommunication (Task 8)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: `MTConnectDriver` shell — `IDriver` lifecycle
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** none
|
||
**Files:**
|
||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs` (IDriver members only this task)
|
||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDriverLifecycleTests.cs`
|
||
|
||
Ctor `MTConnectDriver(MTConnectDriverOptions options, string driverInstanceId, Func<MTConnectDriverOptions, IMTConnectAgentClient>? agentClientFactory = null, ILogger<MTConnectDriver>? logger = null)` — **connection-free**. `agentClientFactory` defaults to the real `MTConnectAgentClient`; tests inject a canned-XML fake implementing `IMTConnectAgentClient`. Implement:
|
||
- `InitializeAsync`: build the client, run one `/probe` under the deadline (cache `IDevice[]` model + `Header.instanceId`), prime the index with one `/current`; `DriverState.Healthy` on success, rethrow → Faulted on failure.
|
||
- `ReinitializeAsync`: stop the sample stream, re-Initialize (config-only unless `AgentUri`/`DeviceName` changed).
|
||
- `ShutdownAsync`: stop stream, dispose client.
|
||
- `GetHealth`: `DriverHealth(State, LastSuccessfulRead, LastError)` — `LastSuccessfulRead` = last `/current`|`/sample` chunk time.
|
||
- `GetMemoryFootprint` / `FlushOptionalCachesAsync`: footprint ≈ probe model + index; flush drops the probe/browse cache, keeps the index.
|
||
|
||
TDD with a canned-XML fake client:
|
||
|
||
```csharp
|
||
[Fact]
|
||
public async Task Initialize_primes_index_and_reports_healthy()
|
||
{
|
||
var fake = CannedAgentClient.FromFixtures(); // test helper: serves probe.xml + current.xml
|
||
var d = new MTConnectDriver(Opts(), "mt1", _ => fake);
|
||
await d.InitializeAsync("{\"agentUri\":\"http://x\"}", default);
|
||
d.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||
}
|
||
```
|
||
|
||
```bash
|
||
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests --filter "FullyQualifiedName~MTConnectDriverLifecycleTests" # RED, then GREEN
|
||
git add -A && git commit -m "feat(mtconnect): driver shell IDriver lifecycle over agent-client seam (Task 9)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: `IReadable.ReadAsync` — `/current`, ordered snapshots
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** Task 12, Task 13
|
||
**Files:**
|
||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs`
|
||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectReadTests.cs`
|
||
|
||
`ReadAsync(fullReferences, ct)`: one `/current` under the per-call deadline, index the whole-device response, return **one `DataValueSnapshot` per requested ref in order**; a ref absent from the response → a `Bad`-coded snapshot (never a throw). Batch reads cost one round-trip.
|
||
|
||
TDD:
|
||
|
||
```csharp
|
||
[Fact]
|
||
public async Task Read_returns_one_snapshot_per_ref_in_order_absent_ref_is_bad()
|
||
{
|
||
var d = await InitializedDriver();
|
||
var res = await d.ReadAsync(new[] { "<good id>", "does-not-exist" }, default);
|
||
res.Count.ShouldBe(2);
|
||
res[0].StatusCode.ShouldBe(0u);
|
||
(res[1].StatusCode & 0x80000000u).ShouldBe(0x80000000u); // Bad
|
||
}
|
||
```
|
||
|
||
```bash
|
||
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests --filter "FullyQualifiedName~MTConnectReadTests" # RED, then GREEN
|
||
git add -A && git commit -m "feat(mtconnect): IReadable via /current, ordered per-ref snapshots (Task 10)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 11: `ISubscribable` — `/sample` long-poll pump + ring-buffer re-baseline
|
||
|
||
**Classification:** high-risk (streaming state + re-baseline correctness)
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** none
|
||
**Files:**
|
||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs`
|
||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectSampleHandle.cs` (`ISubscriptionHandle`)
|
||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectSubscribeTests.cs`
|
||
|
||
- `SubscribeAsync`: on the **first** subscription, start the shared sample stream from `nextSequence`; record the subscribed ref set; immediately fire `OnDataChange` for each subscribed ref from the primed `/current` (initial-data convention); return a handle (monotonic id + `DiagnosticId`). The stream-start handshake must complete inside the invoker's Subscribe timeout; the long-lived pump then runs under the heartbeat watchdog, not that timeout.
|
||
- Pump: each chunk → for each observation whose `dataItemId ∈` subscribed set, update the index + raise `OnDataChange(handle, dataItemId, snapshot)`; advance `from = nextSequence`.
|
||
- **Ring-buffer overflow:** when `IsSequenceGap(from, chunk)` (Task 7) → re-`/current` to re-baseline, then resume from the new `nextSequence`.
|
||
- `UnsubscribeAsync`: drop the handle's refs; stop the shared stream when the set empties.
|
||
|
||
TDD — assert the initial-data callback AND that a forced gap triggers a re-baseline `/current` (drive `SampleAsync` to yield `sample-gap.xml`):
|
||
|
||
```csharp
|
||
[Fact]
|
||
public async Task Subscribe_fires_initial_data_from_current()
|
||
{
|
||
var d = await InitializedDriver();
|
||
var seen = new List<string>();
|
||
d.OnDataChange += (_, e) => seen.Add(e.FullReference);
|
||
await d.SubscribeAsync(new[] { "<good id>" }, TimeSpan.FromMilliseconds(500), default);
|
||
seen.ShouldContain("<good id>");
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Sequence_gap_triggers_recurrent_current_rebaseline()
|
||
{
|
||
var fake = CannedAgentClient.WithGapThenResume(); // sample-gap.xml then a contiguous chunk
|
||
var d = await InitializedDriver(fake);
|
||
await d.SubscribeAsync(new[] { "<good id>" }, TimeSpan.FromMilliseconds(50), default);
|
||
await fake.PumpOnce();
|
||
fake.CurrentCallCount.ShouldBeGreaterThan(1); // initial prime + re-baseline
|
||
}
|
||
```
|
||
|
||
```bash
|
||
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests --filter "FullyQualifiedName~MTConnectSubscribeTests" # RED, then GREEN
|
||
git add -A && git commit -m "feat(mtconnect): ISubscribable /sample pump + ring-buffer re-baseline (Task 11)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 12: `ITagDiscovery.DiscoverAsync` + `SupportsOnlineDiscovery=true`
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** Task 10, Task 13
|
||
**Files:**
|
||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs`
|
||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDiscoverTests.cs`
|
||
|
||
`public bool SupportsOnlineDiscovery => true;` + `public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.Once;` (probe is synchronous + complete — this one member override is the whole browse opt-in; the Wave-0 universal browser does the rest). `DiscoverAsync(builder, ct)` streams the cached/`/probe` model into the builder:
|
||
- each `Device` → `builder.Folder(name, name)` → child builder;
|
||
- each nested `Component` → recursive `Folder(name, displayName)` on the parent's child builder;
|
||
- each `DataItem` → `child.Variable(browseName, displayName, attr)` with `browseName = dataItem.Name ?? dataItem.Id`, and
|
||
`attr = new DriverAttributeInfo(FullName: dataItem.Id, DriverDataType: <Infer(...).DataType>, IsArray: rep==TIME_SERIES, ArrayDim: <declared sampleCount or null>, SecurityClass: SecurityClassification.ViewOnly, IsHistorized: false, IsAlarm: category=="CONDITION")`.
|
||
- If `DeviceName` is set, stream only that device's subtree.
|
||
|
||
**Critical:** `FullName == dataItem.Id` — the value the universal browser commits as `TagConfig.FullName` and the key read/subscribe resolve against, aligned by construction.
|
||
|
||
TDD with a capturing builder (a test double, or reuse Wave-0's `CapturingAddressSpaceBuilder` if referenceable):
|
||
|
||
```csharp
|
||
[Fact]
|
||
public async Task Discover_streams_device_component_dataitem_tree_leaf_fullname_is_dataitemid()
|
||
{
|
||
var d = await InitializedDriver();
|
||
var cap = new CapturingBuilder();
|
||
await d.DiscoverAsync(cap, default);
|
||
cap.Variables.Select(v => v.Attr.FullName).ShouldContain("<CONDITION dataItem id>");
|
||
cap.Variables.Single(v => v.Attr.IsAlarm).Attr.DriverDataType.ShouldBe(DriverDataType.String);
|
||
d.SupportsOnlineDiscovery.ShouldBeTrue();
|
||
d.RediscoverPolicy.ShouldBe(DiscoveryRediscoverPolicy.Once);
|
||
}
|
||
```
|
||
|
||
```bash
|
||
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests --filter "FullyQualifiedName~MTConnectDiscoverTests" # RED, then GREEN
|
||
git add -A && git commit -m "feat(mtconnect): ITagDiscovery streams tree + SupportsOnlineDiscovery opt-in (Task 12)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 13: `IHostConnectivityProbe` + `IRediscoverable` (instanceId watch)
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** Task 10, Task 12
|
||
**Files:**
|
||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs`
|
||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectHostAndRediscoverTests.cs`
|
||
|
||
- `IHostConnectivityProbe`: one `HostConnectivityStatus` per Agent (`HostName = AgentUri`); a cheap periodic `/probe` (or the sample-stream heartbeat) flips `Running ↔ Stopped`; raise `OnHostStatusChanged` on transition. Enable/interval from `MTConnectDriverOptions.Probe`.
|
||
- `IRediscoverable`: cache `Header.instanceId` from Initialize; on every `/current`/`/sample` chunk, a **changed** `instanceId` means the Agent restarted / its model changed → raise `OnRediscoveryNeeded(new("MTConnect agent instanceId changed", null))`. This is why `RediscoverPolicy = Once` is safe — instanceId change, not polling, drives re-discovery.
|
||
|
||
TDD:
|
||
|
||
```csharp
|
||
[Fact]
|
||
public async Task InstanceId_change_raises_rediscovery()
|
||
{
|
||
var fake = CannedAgentClient.WithInstanceIdChangeOnNextChunk();
|
||
var d = await InitializedDriver(fake);
|
||
RediscoveryEventArgs? got = null;
|
||
((IRediscoverable)d).OnRediscoveryNeeded += (_, e) => got = e;
|
||
await d.SubscribeAsync(new[] { "<good id>" }, TimeSpan.FromMilliseconds(50), default);
|
||
await fake.PumpOnce();
|
||
got.ShouldNotBeNull();
|
||
}
|
||
```
|
||
|
||
```bash
|
||
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests --filter "FullyQualifiedName~MTConnectHostAndRediscoverTests" # RED, then GREEN
|
||
git add -A && git commit -m "feat(mtconnect): host-connectivity probe + instanceId rediscover (Task 13)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 14: `MTConnectDriverProbe : IDriverProbe`
|
||
|
||
**Classification:** small
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** Task 15
|
||
**Files:**
|
||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriverProbe.cs`
|
||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDriverProbeTests.cs`
|
||
|
||
Mirror `ModbusDriverProbe`: `DriverType => "MTConnect"`; parse the config DTO with a `JsonSerializerOptions` carrying `new JsonStringEnumConverter()` (copy `ModbusDriverProbe._opts` — the enum-serialization gotcha); one-shot `GET {AgentUri}/probe` under `timeout` (linked-CTS); `Ok=true`+latency on any valid `MTConnectDevices` response; `Ok=false`+message on TCP/HTTP/timeout failure. **Never throws** (`IDriverProbe` contract).
|
||
|
||
TDD (unit — parse + never-throw; live reachability is Task 20's integration job):
|
||
|
||
```csharp
|
||
[Fact]
|
||
public async Task Probe_on_unreachable_agent_returns_not_ok_and_does_not_throw()
|
||
{
|
||
var probe = new MTConnectDriverProbe();
|
||
var r = await probe.ProbeAsync("{\"agentUri\":\"http://127.0.0.1:1/\"}", TimeSpan.FromMilliseconds(300), default);
|
||
r.Ok.ShouldBeFalse();
|
||
r.Message.ShouldNotBeNull();
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Probe_on_blank_config_returns_not_ok()
|
||
=> (await new MTConnectDriverProbe().ProbeAsync("{}", TimeSpan.FromSeconds(1), default)).Ok.ShouldBeFalse();
|
||
```
|
||
|
||
```bash
|
||
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests --filter "FullyQualifiedName~MTConnectDriverProbeTests" # RED, then GREEN
|
||
git add -A && git commit -m "feat(mtconnect): IDriverProbe one-shot /probe reachability (Task 14)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 15: `MTConnectDriverFactoryExtensions`
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** Task 14
|
||
**Files:**
|
||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriverFactoryExtensions.cs`
|
||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectFactoryTests.cs`
|
||
|
||
Copy the Modbus factory: `public const string DriverTypeName = "MTConnect";` `Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null)` → `registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory))`. `CreateInstance` deserializes an `MTConnectDriverConfigDto` (nullable-init DTO with `AgentUri`, `DeviceName`, timeouts, `Tags`, `Probe`), validates `AgentUri` present, builds `MTConnectDriverOptions`, returns `new MTConnectDriver(options, id, agentClientFactory: null, logger: loggerFactory?.CreateLogger<MTConnectDriver>())`. Factory `JsonOptions` mirror Modbus (`PropertyNameCaseInsensitive`, `ReadCommentHandling=Skip`, `AllowTrailingCommas`, **no enum converter** — enum-carrying DTO fields stay `string?` + go through `ParseEnum<T>`).
|
||
|
||
TDD:
|
||
|
||
```csharp
|
||
[Fact]
|
||
public void Factory_builds_a_driver_from_minimal_config()
|
||
{
|
||
var d = MTConnectDriverFactoryExtensions.CreateInstance("mt1", "{\"agentUri\":\"http://a:5000\"}");
|
||
d.DriverType.ShouldBe("MTConnect");
|
||
d.DriverInstanceId.ShouldBe("mt1");
|
||
}
|
||
|
||
[Fact]
|
||
public void Factory_rejects_config_without_agentUri()
|
||
=> Should.Throw<InvalidOperationException>(() => MTConnectDriverFactoryExtensions.CreateInstance("mt1", "{}"));
|
||
```
|
||
|
||
```bash
|
||
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests --filter "FullyQualifiedName~MTConnectFactoryTests" # RED, then GREEN
|
||
git add -A && git commit -m "feat(mtconnect): driver factory + config DTO (Task 15)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 16: Host registration + `DriverTypeNames.MTConnect` + guard-test parity
|
||
|
||
**Classification:** small
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** none
|
||
**Files:**
|
||
- Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs` (add `MTConnect` const + append to `All`)
|
||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs` (Register call + probe alias + `TryAddEnumerable`)
|
||
- Modify: `tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.csproj` (add ProjectReference to `Driver.MTConnect`)
|
||
|
||
Three coupled edits that **must land together** to keep `DriverTypeNamesGuardTests` green (it asserts exact-set parity between `DriverTypeNames.All` and the reflection-discovered registered factories, scanning `ZB.MOM.WW.OtOpcUa.Driver.*.dll` in the test's bin):
|
||
1. Add `public const string MTConnect = "MTConnect";` to `DriverTypeNames` and append it to `All` (design uses `DriverTypeNames.MTConnect` in the editor map/validator per the CLAUDE.md "keyed off constants" rule).
|
||
2. In `DriverFactoryBootstrap`: add `using MTConnectProbe = Driver.MTConnect.MTConnectDriverProbe;`, `Driver.MTConnect.MTConnectDriverFactoryExtensions.Register(registry, loggerFactory);` in `Register(...)`, and `services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, MTConnectProbe>());` in `AddOtOpcUaDriverProbes` (so the probe reaches admin-only nodes — the admin-pinned Test-Connect singleton; `TryAddEnumerable` prevents the fused-node double-register that would make `ToDictionary(p=>p.DriverType)` throw).
|
||
3. Add the `Driver.MTConnect` ProjectReference to the guard-test project — **without it the new assembly isn't in bin, the guard doesn't discover the factory, and the new constant fails `Every_constant_matches_a_registered_factory`.**
|
||
|
||
Verify (the guard test is the gate here):
|
||
|
||
```bash
|
||
dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests --filter "FullyQualifiedName~DriverTypeNamesGuardTests" # RED before the ProjectReference/Register land together, GREEN after
|
||
dotnet build src/Server/ZB.MOM.WW.OtOpcUa.Host # PASS
|
||
git add -A && git commit -m "feat(mtconnect): host registration + DriverTypeNames const + guard parity (Task 16)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 17: AdminUI typed model `MTConnectTagConfigModel`
|
||
|
||
**Classification:** small
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** none
|
||
**Files:**
|
||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/MTConnectTagConfigModel.cs`
|
||
- Test: `tests/Server/<AdminUI test project>/MTConnectTagConfigModelTests.cs` (mirror the Modbus model test's location)
|
||
|
||
Copy `ModbusTagConfigModel`: pure `FromJson`/`ToJson`/`Validate`, **preserves unknown keys** via the `TagConfigJson` bag. Fields: `FullName` (dataItemId, required), `MtCategory`/`MtType`/`MtSubType` (from probe — read-only in UI), `DataType` (`DriverDataType` override), `Units`, `MtDevice`/`MtComponent` (author context). `ToJson` writes enums via `TagConfigJson.Set` (**name strings** — the enum-serialization trap). `Validate()` returns an error when `FullName` is blank.
|
||
|
||
TDD:
|
||
|
||
```csharp
|
||
[Fact]
|
||
public void Roundtrip_preserves_unknown_keys_and_writes_enum_as_name()
|
||
{
|
||
var json = "{\"fullName\":\"dev1_pos\",\"dataType\":\"Float64\",\"somethingUnknown\":42}";
|
||
var m = MTConnectTagConfigModel.FromJson(json);
|
||
var outJson = m.ToJson();
|
||
outJson.ShouldContain("\"somethingUnknown\":42");
|
||
outJson.ShouldContain("\"dataType\":\"Float64\""); // name, never a number
|
||
}
|
||
|
||
[Fact]
|
||
public void Validate_blocks_blank_fullname()
|
||
=> MTConnectTagConfigModel.FromJson("{}").Validate().ShouldNotBeNull();
|
||
```
|
||
|
||
```bash
|
||
dotnet test tests/Server/<AdminUI test project> --filter "FullyQualifiedName~MTConnectTagConfigModelTests" # RED, then GREEN
|
||
git add -A && git commit -m "feat(mtconnect): typed AdminUI tag-config model (Task 17)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 18: AdminUI editor razor + map/validator registration
|
||
|
||
**Classification:** small
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** none
|
||
**Files:**
|
||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/MTConnectTagConfigEditor.razor`
|
||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs`
|
||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs`
|
||
|
||
Thin razor shell over `MTConnectTagConfigModel` (mirror `ModbusTagConfigEditor.razor`): `FullName` text, read-only `mtCategory`/`mtType`, a `DataType` override `<select>`. Because most tags arrive via the browse picker (which fills the model), the editor is mostly a confirm/override surface. Register:
|
||
- `[DriverTypeNames.MTConnect] = typeof(Components.Shared.Uns.TagEditors.MTConnectTagConfigEditor)` in `TagConfigEditorMap`.
|
||
- `[DriverTypeNames.MTConnect] = j => MTConnectTagConfigModel.FromJson(j).Validate()` in `TagConfigValidator`.
|
||
|
||
(No AdminUI `IDriverBrowser` DI line — browse is the universal browser, already registered.)
|
||
|
||
Verify (AdminUI has no bUnit — Razor binding is validated live in Task 21; here just compile):
|
||
|
||
```bash
|
||
dotnet build src/Server/ZB.MOM.WW.OtOpcUa.AdminUI # PASS
|
||
git add -A && git commit -m "feat(mtconnect): typed tag editor razor + map/validator entries (Task 18)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 19: `mtconnect/cppagent` docker fixture
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** Task 17, Task 18
|
||
**Files:**
|
||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/docker-compose.yml`
|
||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/Devices.xml`
|
||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/agent.cfg` (if the image needs it)
|
||
|
||
Compose one `mtconnect/cppagent` service, **`project: lmxopcua` label on the service** (host-side `lmxopcua-fix` convention), seeded with a canned `Devices.xml` (+ the image's built-in adapter/simulator so `/current` returns live-ish data), exposed on the shared docker host `10.100.0.35`. Deploy via `lmxopcua-fix sync mtconnect` + `lmxopcua-fix up mtconnect` (repo `Docker/` is source of truth; `/opt/otopcua-mtconnect/` is the mirror). Model the compose on the Modbus fixture's shape.
|
||
|
||
No .NET test in this task (fixture only). Verify the compose parses:
|
||
|
||
```bash
|
||
docker compose -f tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/docker-compose.yml config # PASS
|
||
git add -A && git commit -m "test(mtconnect): cppagent docker fixture + seeded Devices.xml (Task 19)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 20: Env-gated integration suite against cppagent
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** none
|
||
**Files:**
|
||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests.csproj`
|
||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/MTConnectAgentIntegrationTests.cs`
|
||
- Modify: `ZB.MOM.WW.OtOpcUa.slnx`
|
||
|
||
A `*.IntegrationTests` suite that reads the agent endpoint from an env var (e.g. `MTCONNECT_AGENT_ENDPOINT`, default `http://10.100.0.35:5000`) and **skips cleanly when unset/down** (the Modbus/S7 pattern — `Skip.If(...)` or a fixture-reachability guard). Cover the real HTTP round-trips a canned fixture can't: `MTConnectDriverProbe` reachability green, `InitializeAsync` + `DiscoverAsync` build a non-empty tree, `ReadAsync` returns live values, `SubscribeAsync` delivers at least one `OnDataChange` from the live `/sample` stream.
|
||
|
||
Verify it skips offline (safe on macOS with no fixture up):
|
||
|
||
```bash
|
||
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests # all tests SKIP when the env var/endpoint is absent
|
||
git add -A && git commit -m "test(mtconnect): env-gated cppagent integration suite (Task 20)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 21: Live `/run` verify on docker-dev — browse picker, editor, read, subscribe, deploy
|
||
|
||
**Classification:** high-risk (live; Razor + deploy-inertness bugs pass unit tests)
|
||
**Estimated implement time:** ~5 min (driving; excludes fixture spin-up)
|
||
**Parallelizable with:** none
|
||
**Files:**
|
||
- Modify: `docs/plans/2026-07-24-mtconnect-driver.md` (append a LIVE-GATE RESULT note)
|
||
|
||
> **DEPENDENCY:** this task's browse-picker leg is gated on the **Wave-0 universal-browser live gate (Gitea #468)** being closed — the picker's Browse button, `DiscoveryDriverBrowser.CanBrowse`, and `CapturedTreeBrowseSession` are Wave-0 machinery this driver only opts into via `SupportsOnlineDiscovery=true`. If #468 is still open, run the read/subscribe/deploy legs and record the browse leg as blocked-on-#468.
|
||
|
||
Bring the cppagent fixture up (`lmxopcua-fix up mtconnect`), author an MTConnect driver on docker-dev (`http://localhost:9200`, auto-authenticated admin — no login), and verify **in the running AdminUI + against `opc.tcp://localhost:4840`** (per the live-verify discipline — Razor binding bugs pass unit tests):
|
||
1. **Test Connect** on the MTConnect driver goes green (probe reaches the fixture).
|
||
2. **Browse picker** (`/uns` TagModal → Browse): the Device→Component→DataItem tree renders; a picked leaf commits `TagConfig.FullName = <dataItemId>` (Wave-0 dependency).
|
||
3. **Typed editor** shows `FullName`/read-only `mtCategory`/`DataType` override and round-trips.
|
||
4. **Deploy** the config; via Client.CLI read a picked node (`dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- read -u opc.tcp://localhost:4840 -n "ns=…;s=…"`) — value present, `UNAVAILABLE` items render `BadNoCommunication`.
|
||
5. **Subscribe** (`... subscribe ...`) — live updates flow from the `/sample` stream.
|
||
|
||
```bash
|
||
git add docs/plans/2026-07-24-mtconnect-driver.md
|
||
git commit -m "test(mtconnect): live /run gate on docker-dev — browse/read/subscribe/deploy (Task 21)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 22: Docs + deferred-writeback note; update the tracking doc
|
||
|
||
**Classification:** trivial
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** none
|
||
**Files:**
|
||
- Create: `docs/drivers/MTConnect.md` (or a short section — mirror an existing driver doc)
|
||
- Modify: `docs/plans/2026-07-24-driver-expansion-tracking.md` (mark MTConnect P1 done + link this plan)
|
||
|
||
Document the P1 Agent MVP: config keys, browse-via-universal, the `UNAVAILABLE→BadNoCommunication` semantics, CONDITION-as-String, and the fixture recipe. Record **write-back (MTConnect Interfaces) as deferred** — point at `docs/plans/2026-07-15-mtconnect-driver-design.md` §3.6 + §9 (P1.5 native-alarm CONDITION, P2 SHDR ingest are also deferred there). Update the driver-expansion tracking doc's Wave-2 row.
|
||
|
||
```bash
|
||
git add -A && git commit -m "docs(mtconnect): P1 driver guide + tracking-doc update, defer write-back (Task 22)"
|
||
```
|
||
|
||
---
|
||
|
||
## Deferred (out of P1 — pointers, not scope)
|
||
|
||
- **Write-back (MTConnect Interfaces)** — design §3.6: read-only Agent surface; revisit only on a concrete deployment need.
|
||
- **CONDITION → native Part-9 alarms** (`IAlarmSource`), **`TIME_SERIES` SAMPLE arrays** materialized as OPC UA arrays, **EVENT controlled-vocab → OPC UA enumerations** — design §9 P1.5 fast-follow.
|
||
- **SHDR adapter ingest** (`SourceMode: "Agent" | "Shdr"`, loses auto-discovery) — design §9 P2.
|