Files
lmxopcua/docs/plans/2026-07-24-mtconnect-driver.md
T

62 KiB
Raw Blame History

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, IRediscoverablenot 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 PackageReferences). Any fail ⇒ hand-roll fallback: HttpClient + System.Xml.Linq for probe/current + a multipart/x-mixed-replace boundary reader for sample (~300500 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:

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.0net9.0, net461net472, 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 PackageReferences 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 BCLHttpClient + 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 613 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 ~2992 pattern).

Verify:

dotnet build src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.csproj   # expect: PASS (empty compile)
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):

[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();
}
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):

[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);
}
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 ComponentDataItems 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:

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:

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?). MTConnectStreamsResultlong 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):

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:

[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
}
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.xmlMTConnectStreamsResult (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):

[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();
}
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 UNAVAILABLEnew(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):

[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();
}
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:

[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);
}
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:

[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
}
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):

[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
}
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 Devicebuilder.Folder(name, name) → child builder;
  • each nested Component → recursive Folder(name, displayName) on the parent's child builder;
  • each DataItemchild.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):

[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);
}
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:

[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();
}
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):

[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();
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:

[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", "{}"));
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):

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:

[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();
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):

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:

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):

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.
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)"

LIVE-GATE RESULT (2026-07-24) — 4 of 5 legs PASSED; the deploy leg is BLOCKED on a rig artifact, not a defect

Rig. The shared otopcua-dev rig was NOT used: three sibling driver worktrees (modbus-rtu, mqtt-sparkplug, sql-poll-driver) share its otopcua-host:dev image, and it had been up for hours. Instead an isolated stack was built from this worktree — otopcua-host:mtconnect, compose project otopcua-mtc, overlay docker-dev/docker-compose.mtconnect.yml, MAIN pair only, ports AdminUI 9220 / OPC UA 4860 / SQL 14340. Mirrors how the mqtt worktree isolated itself. The otopcua-dev rig and every sibling worktree were left untouched.

Fixture. mtconnect/agent:2.7.0.12 + the SHDR adapter deployed to /opt/otopcua-mtconnect/ on the shared docker host and brought up healthy; http://10.100.0.35:5000 serves the seeded OtFixtureCnc model with live-moving values. Note it answers in the MTConnect 2.0 namespace while the canned fixtures are 1.3 — the namespace-agnostic LocalName parsing (Task 6) is what makes both work, now demonstrated rather than argued.

Leg Result Evidence
Integration suite vs. a REAL Agent 12/12 incl. Discovery_types_an_upper_snake_numeric_event_as_Int64 (the PART_COUNT regression), Read_and_subscribe_key_on_RawPath_when_the_artifact_supplies_RawTags (the blocker fix), Subscribe_delivers_a_changed_value_from_the_live_sample_stream, Discovery_binds_FullName_to_the_DataItem_id_when_the_name_differs
1. Driver creatable in /raw MTConnect present in RawDriverTypeDialog; mtc-1 created. Proves the Host Register call is genuinely wired — the one thing no test guards (deleting it leaves the guard test green and substitutes a StubbedDriver while the deploy still seals green).
2. Typed config modal renders "Configure driver · MTConnect" with all four sections (Agent / Polling / Connectivity probe / Reconnect), not the "no typed config form" banner. This is the mutation that survived all 749 AdminUI tests — the switch in DriverConfigModal.razor compiles to BuildRenderTree and is unreachable by reflection.
3. Config persists + round-trips Stored {"agentUri":"http://10.100.0.35:5000","requestTimeoutMs":5000,…,"probe":{"enabled":true,…},"reconnect":{…}}; reopening the modal displays the saved URI (Razor binding verified — no bUnit in this repo).
4. Browse picker → commit "Connect & browse" dialled the live Agent; tree rendered Agent + OtFixtureCnc with Favail (name ≠ id) beside id-named leaves, proving browseName = Name ?? Id. Committed TagConfig is {"fullName":"fixture_asset_changed","dataType":"String"} — the fullName-not-address fix, live. Wave-0 #468 did not block this.
5. Deploy → OPC UA read/subscribe BLOCKED — rig artifact see below

Why leg 5 is blocked, and why it is not a product defect. Browser cookies are scoped to a host, not a port, so the AdminUI antiforgery cookie issued by the still-running otopcua-dev rig on localhost:9200 is sent to the isolated stack on localhost:9220, whose data-protection keys differ:

Microsoft.AspNetCore.Antiforgery.AntiforgeryValidationException: The antiforgery token could not be decrypted.

The Deploy current configuration POST is rejected before reaching any MTConnect code — no Deployment row is created, and the UI surfaces nothing. Clearing JS-visible cookies does not help (the antiforgery cookie is HttpOnly). This is a two-stacks-on-one-host collision, entirely outside the driver.

To finish leg 5, do any one of: (a) use a distinct host so cookies don't collide (a hosts entry, or bind 127.0.0.2), (b) drive :9220 from an incognito/separate browser profile, or (c) stop the otopcua-dev rig first. Then Deploy and: dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- read -u opc.tcp://localhost:4860 -n "ns=2;s=docker-dev/mtc-1/vmc/fixture_asset_changed". Note the RawPath keying is already proven at the driver seam by the live integration test above; what leg 5 adds is the DeploymentArtifact → address-space → OPC UA materialisation on top.

Incidental findings from the rig work:

  1. ClusterNode has no MaintenanceMode column, so CLAUDE.md is ahead of the migration. CORRECTED — this was my error, not a repo inconsistency. The migration 20260722125506_AddClusterNodeMaintenanceMode exists on master, along with AkkaPort/GrpcPort. This branch is 39 commits behind master (base 963eec1b, master 28c28667) and simply predates them, so the isolated stack's schema lacked the column and I reduced the seeded topology by deleting the SITE-A/SITE-B rows instead. CLAUDE.md is accurate. Rebase onto master before merging — and once rebased, MaintenanceMode = 1 on the site nodes is the correct, documented hatch for a partial-topology live gate (it is what the SQL-poll driver's gate used), in place of the row deletion I resorted to.
  2. sqlcmd against this schema needs -I (SET QUOTED_IDENTIFIER ON); without it every DML statement fails with Msg 1934 because of the filtered/computed indexes.
  3. Status-code parity is pre-verified against master's new guard. Master gained StatusCodeParityTests, which reflects over every ZB.MOM.WW.OtOpcUa.Driver.*.dll in its bin for status-shaped const uint fields — including private const on internal types — and checks each against Opc.Ua.StatusCodes. Task 16 added the Driver.MTConnect ProjectReference to that same test project, so this driver's constants are in scope. All eight were verified against the pinned SDK assembly directly: BadCommunicationError 0x80050000, BadNoCommunication 0x80310000, BadNodeIdUnknown 0x80340000, BadNotConnected 0x808A0000, BadNotSupported 0x803D0000, BadOutOfRange 0x803C0000, BadTypeMismatch 0x80740000, BadWaitingForInitialData 0x803200000 mismatches. Note the guard only sees named constants; an inline status literal at a call site is invisible to it, so keep hoisting them.
  4. The separately-tracked BadTypeMismatch defect in FOCAS/TwinCAT/AbLegacy/AbCip is already fixed on master (issue #497 grew it to 16 wrong constants across 6 drivers). This driver independently chose the correct 0x80740000 and pinned it with a mutation test, so the two agree; no action.

Teardown when done:

docker compose -p otopcua-mtc -f docker-dev/docker-compose.yml -f docker-dev/docker-compose.mtconnect.yml down -v
ssh dohertj2@10.100.0.35 'cd /opt/otopcua-mtconnect && docker compose down'

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.

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.