Files
lmxopcua/docs/plans/2026-07-24-mtconnect-driver.md
T
Joseph Doherty cf43f8d0ab feat(mtconnect): current/sample parse + sequence-gap detection (Task 7)
Adds the /current and /sample legs to MTConnectAgentClient:

- MTConnectStreamsParser: hand-rolled System.Xml.Linq parse of an
  MTConnectStreams document into MTConnectStreamsResult. Observations are
  every element child of <Samples>/<Events>/<Condition>, keyed by
  dataItemId (the element NAME is the data item's type in PascalCase, so
  a fixed name set would drop observations). A CONDITION's value is its
  element name, not its text - <Normal/> is empty - and <Unavailable/>
  normalizes onto the same UNAVAILABLE sentinel a Sample/Event carries as
  text, so Task 8's no-comms mapping sees one token. Timestamps are
  normalized to DateTimeKind.Utc explicitly.
- IsSequenceGap(requestedFrom, chunk) => chunk.FirstSequence >
  requestedFrom, so Task 11's pump can re-baseline after a ring-buffer
  overflow. Equality is NOT a gap.
- SampleAsync: ResponseHeadersRead + a hand-rolled
  multipart/x-mixed-replace frame reader (Content-length fast path,
  boundary-scan fallback), bounded by a heartbeat watchdog derived from
  HeartbeatMs so a silent Agent faults instead of wedging the pump - the
  S7 frozen-peer shape (arch-review R2-01). The long poll gets its own
  HttpClient with an infinite Timeout so the unary /probe + /current
  deadline stays bounded.

Task 0 package decision, option (c): both TrakHound PackageReferences and
their PackageVersions are dropped. MTConnectHttpClientStream takes a URL
and dials its own socket (no handler/stream seam, so it cannot serve a
socket-free unit suite), and it emits already-parsed documents through the
formatter lookup Task 6 proved is missing - framing-only reuse was never
actually on offer. The driver now depends on nothing but the BCL. Recorded
in the plan's Task 0 CORRECTION block.

Also folds in two Task 6 review carry-overs: the stale
IMTConnectAgentClient doc comment claiming a TrakHound-backed
implementation, and a probe test constructing a mixed-namespace vendor
extension (<x:Pump> with default-namespace <DataItems> children) that
backs the parser's ignore-the-namespace claim with a test.

187/187 green.
2026-07-27 13:57:46 -04:00

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

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.