Code review of c46540ae approved the framing algorithm but moved the risk onto the contract around it. C1 (critical). SampleAsync returned normally on three different events: EOF, the closing --boundary--, and a response that was not multipart at all (where it yielded one parsed snapshot and finished). The seam documents the stream as yielding until ct is cancelled, so a Task 11 pump written to that contract would silently drop its subscription or hot-loop reconnecting; the non-multipart leg reported a configuration error as a healthy finished stream, which is the #485 quiet-successful-termination shape aimed at the very next task. Every non-cancellation end now throws: MTConnectStreamEndedException (ConnectionClosed / ClosingBoundary - transient) or MTConnectStreamNotSupportedException (configuration - reconnecting reproduces it forever), sharing one catchable base. The non-multipart body is still parsed first so an Agent's own MTConnectError text wins, but the document is NOT yielded. I1. IsSequenceGap moved to IMTConnectAgentClient (static) and its first parameter is now expectedFrom. The old name and doc were wrong for every chunk after the first - the correct comparand is the PREVIOUS chunk's NextSequence, and comparing against the opening "from" argument reports a gap on every chunk once the ring buffer rolls, i.e. an endless /current re-baseline storm. Both doc blocks also now record that cppagent answers from < firstSequence with an OUT_OF_RANGE MTConnectError under HTTP 200, which surfaces as InvalidDataException and NOT as a gap - so Task 11 must treat that parse failure as a re-baseline trigger too. I2. Every multipart test served the whole body from one buffer, so every framing test completed in a single ReadAsync - the split-boundary path, the split-header path and the multi-fill loop were correct by inspection only. A ChunkedStream double (N bytes per read, N = 1/3/7) now drives the fixtures through a split transport, plus a >16 KB document that forces a buffer resize. Falsifiability: removing either scan overlap, or collapsing the fill loop, fails ONLY the new chunked tests. I3. Disposal mid-enumeration surfaces as OperationCanceledException via an internal dispose-linked token, not a raw ObjectDisposedException that Task 9's re-init would inflict on an enumerating pump. I5. HeartbeatMs, SampleIntervalMs and SampleCount join RequestTimeoutMs in construction-time positive-value validation. All three reach the query string and an Agent answers heartbeat=0 with an HTTP-200 MTConnectError that names no config key. I4/M2. The no-Content-length framing fallback logs a one-shot warning (the client takes an optional ILogger); a multipart response with no boundary parameter fails fast instead of degrading into a watchdog timeout. M5/M6. Shared element/attribute reading rules extracted to MTConnectXml; the probe parser documents why it alone does not trim BOM/whitespace. The literal U+FEFF in source became a '' escape. Also, from Task 8: MTConnectObservation gained IsStructured (default false), set from element.HasElements. A DATA_SET/TABLE observation's <Entry> children concatenate through element.Value to nonsense ("12" for two entries) that the index would publish as Good, and only the parser can tell that apart from a legitimate space-bearing Message. False for CONDITION observations - their value comes from the element name, so child content cannot corrupt it. 275/275 green in this suite's own files.
56 KiB
MTConnect Agent Driver Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (or subagent-driven-development) to implement this plan task-by-task.
Goal: Ship a read-only, browsable DriverType = "MTConnect" Equipment-kind driver (P1 Agent MVP) that surfaces an MTConnect Agent's /probe device model as OPC UA nodes and serves live values via /current (read) + /sample long-poll (subscribe), with browse coming free from the Wave-0 universal discovery browser.
Architecture: One MTConnectDriver per instance implements IDriver, ITagDiscovery (SupportsOnlineDiscovery=true, RediscoverPolicy=Once), IReadable, ISubscribable, IHostConnectivityProbe, IRediscoverable — not IWritable (Agent surface is read-only). A thin IMTConnectAgentClient seam wraps the HTTP/XML transport (probe/current/sample) so the whole driver is unit-tested against canned XML with no network. A per-instance MTConnectObservationIndex holds dataItemId → latest DataValueSnapshot, updated by /current and the /sample pump; UNAVAILABLE → BadNoCommunication. Browse is served by the already-shipped DiscoveryDriverBrowser — this driver ships no browser code, only the SupportsOnlineDiscovery opt-in.
Tech Stack: HttpClient behind the IMTConnectAgentClient seam — TrakHound MTConnect.NET (-Common + -HTTP, MIT, netstandard2.0) pending the Task 0 verification, else a hand-rolled System.Xml.Linq + multipart boundary-reader fallback (drop-in behind the same seam); System.Text.Json for config DTOs (enum-as-name, JsonStringEnumConverter on the probe, ParseEnum<T> on the factory); the Wave-0 universal browser seam (ITagDiscovery.SupportsOnlineDiscovery).
Source design: docs/plans/2026-07-15-mtconnect-driver-design.md Program context: docs/plans/2026-07-15-driver-expansion-program-design.md (§3 shared contract, §4 two-tier browse, §7 fixtures) Wave-0 dependency: docs/plans/2026-07-15-universal-discovery-browser-design.md — the browse picker live-verify (Task 21) is gated on the Wave-0 universal-browser live gate (Gitea #468) being closed.
Cross-cutting constraints (apply to every task)
- Per-op deadline (R2-01 frozen-peer lesson). Every agent HTTP call carries a bounded deadline:
/probe+/currentwrap in aCancellationTokenSource(RequestTimeoutMs)linked to the caller'sctAND setHttpClient.Timeout; the/samplelong-poll cannot useHttpClient.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 withnew JsonStringEnumConverter(), and the factory keeps enum-carrying DTO fieldsstring?+ parses viaParseEnum<T>(no converter in the factoryJsonOptions). A numerically-serialized enum faults the driver at deploy. - Ctor is connection-free. The
MTConnectDriverconstructor opens no sockets — every connect happens inInitializeAsync. The universal browser's throwaway-instanceCanBrowsepattern depends on this. - Never throw across a capability boundary.
IReadablereports per-ref failures as Bad-coded snapshots (throws only if the driver itself is unreachable);IDriverProbe.ProbeAsyncnever throws (returnsOk=false). - Secrets from env/secret-refs. No agent URL creds committed or logged.
Float64/Int64are the realDriverDataTypemember names (verified insrc/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:
dotnet package search MTConnect.NET-Common --exact-match(and-HTTP) — confirm the pinned version (6.9.0.2or then-current) exists on nuget.org.- In a throwaway project,
dotnet add package MTConnect.NET-Common -v <pin>+dotnet restore— confirm it restores and its TFM set includesnetstandard2.0(loads on net10). - 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 (~300–500 LoC behind the same IMTConnectAgentClient seam; the driver above the seam is byte-identical either way). Record the chosen path and the observed version/TFM/license as a one-paragraph DECISION note appended to this task in the plan file.
No test / no commit for this task (it's a decision + a doc edit). Commit the plan edit with the next task, or standalone:
git add docs/plans/2026-07-24-mtconnect-driver.md
git commit -m "docs(mtconnect): record TrakHound-vs-hand-rolled client decision (Task 0)"
DECISION (verified 2026-07-24): TrakHound path. All three checks passed against real nuget.org.
(1) dotnet package search MTConnect.NET-Common --exact-match and ...-HTTP --exact-match both list
versions 3.2.0 through 6.9.0.2 on nuget.org, confirming 6.9.0.2 is the current top-of-list
version for both package ids (no version mismatch between the two). (2) A throwaway net10.0 console
project in the scratchpad (outside the repo, so unaffected by this repo's packageSourceMapping) ran
dotnet add package MTConnect.NET-Common -v 6.9.0.2 + ...-HTTP -v 6.9.0.2 + dotnet restore — both
restored cleanly with no source-mapping or compatibility errors; project.assets.json resolved
MTConnect.NET-Common, MTConnect.NET-HTTP, and the transitive MTConnect.NET-TLS (all 6.9.0.2)
against the net10.0 target framework, and each package's lib/ folder contains a netstandard2.0
build (alongside net6.0–net9.0, net461–net472, net47, net48), so net10.0 resolves via the
in-box compatibility mapping. (3) The pinned version carries no embedded LICENSE file in the
.nupkg (unzip -l shows no LICENSE* entry in either package) — instead both nuspecs declare the
modern SPDX license-expression form <license type="expression">MIT</license> +
<licenseUrl>https://licenses.nuget.org/MIT</licenseUrl>, which nuget.org validates against the SPDX
list at push time and surfaces on the package page (confirmed live: the nuget.org page for
MTConnect.NET-Common 6.9.0.2 displays "MIT license" linked to licenses.nuget.org/MIT) — a
structurally stronger guarantee than a free-text embedded file for this specific version, so the
absence of a physical LICENSE file is not a fail. Task 1 adds
PackageReference MTConnect.NET-Common 6.9.0.2 + PackageReference MTConnect.NET-HTTP 6.9.0.2 to
Driver.MTConnect.csproj; the hand-roll fallback is not needed.
CORRECTION (Task 6, commit bdfefadc) — the decision above rested on incomplete evidence, and the
hand-roll fallback IS in use for parsing. Task 0 verified the packages exist, restore, and are MIT;
it never verified they can actually parse an MTConnect document. As pinned, they cannot. Task 6 proved
this empirically before writing any parser code: reflection over both referenced assemblies
(MTConnect.NET-Common, 1297 types; -HTTP, 410 types) found zero IResponseDocumentFormatter
implementations and zero public static FromXml/parse entry points, and a live invocation of
TrakHound's own entry point (ResponseDocumentFormatter.CreateDevicesResponseDocument("xml", stream))
against this repo's Fixtures/probe.xml, with both DLLs loaded, returned:
Success = False
ERROR: Document Formatter Not found for "xml"
TrakHound 6.x resolves formatters from loaded assemblies at runtime, and the XML formatter ships in a
third package, MTConnect.NET-XML, which Task 0 did not evaluate. Even with it added, TrakHound
exposes no socket-free Parse(string) entry point of the shape this plan mandates. So /probe parsing
is a hand-rolled System.Xml.Linq walk (~250 LoC, MTConnectProbeParser); the driver above the
IMTConnectAgentClient seam is unaffected, exactly as the decision rule anticipated.
The two 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 BCL —
HttpClient + System.Xml.Linq. Reflection over MTConnect.NET-HTTP 6.9.0.2 (net9.0 lib, with
-Common resolved alongside) settled the one open candidate:
TYPE MTConnect.Clients.MTConnectHttpClientStream
.ctor(String url, String documentFormat)
Void Start(CancellationToken), Void Stop(), Task Run(CancellationToken)
event DocumentReceived / ErrorReceived / FormatError / ConnectionError / …
Three independent disqualifiers, any one of which is fatal:
- It cannot be exercised without a socket. Its only constructor takes a URL and it dials the
connection itself inside
Run— there is noHttpMessageHandler, noHttpClient, and noStreaminjection point. The wholeIMTConnectAgentClientseam exists so Tasks 6–13 are unit-testable against canned XML with no listener; adopting this type would have forced a live agent (or a real loopback HTTP server) into the unit suite. - "Framing alone" was never actually on offer. The type does not surface raw part payloads — it
raises
DocumentReceivedwith an already-parsedIDocument, resolved through the samedocumentFormatformatter lookup that Task 6 proved is missing. Using it for framing would still have required addingMTConnect.NET-XML, i.e. option (b) in disguise. - The shape is inverted and the payload is heavy. An event-driven
Start/Runobject has to be adapted back into theIAsyncEnumerable<MTConnectStreamsResult>the seam declares, and-HTTPvendors 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
.Contractscsproj boilerplate fromsrc/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/…csproj:net10.0,Nullable=enable,ImplicitUsings=enable,TreatWarningsAsErrors=true..Contractsreferences onlyCore.Abstractions(no backend NuGet). AddGenerateDocumentationFile=true(matches the fleet). - The runtime
Driver.MTConnect.csprojreferencesCore,Core.Abstractions,.Contracts, and — per the Task 0 decision — either the two TrakHound packages or nothing extra (hand-roll uses only the BCL). AddInternalsVisibleTothe Tests project (mirror Modbus). - The
.Testscsproj: xUnit + Shouldly (copytests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/…csprojpackage block), ProjectReferences toDriver.MTConnect+.Contracts. Mark the futureFixtures/*.xmlasCopyToOutputDirectory(<Content Include="Fixtures\**\*.xml"><CopyToOutputDirectory>PreserveNewest…). - Add all three to
ZB.MOM.WW.OtOpcUa.slnxbeside the Modbus entries (lines ~29–92 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— oneMTConnectDeviceswith aDevice→ nestedComponent→DataItems spanning every §3.3 category (aSAMPLEw/ units, aTIME_SERIESw/sampleCount, anEVENTnumeric, anEVENTcontrolled-vocab, aCONDITION), each with a distinctid.current.xml— anMTConnectStreamswith aHeader instanceId=... nextSequence=...and one observation per data item, including at least one<... >UNAVAILABLE</...>.sample.xml— anMTConnectStreamschunk with several observations and aHeader nextSequencethat continues contiguously fromcurrent.xml.sample-gap.xml— aHeaderwhosefirstSequenceis newer than thefromthe driver would request (the ring-buffer-overflow / forced-nextSequence-gap case Task 7 + Task 11 assert re-baseline on).current-unavailable.xml— every observationUNAVAILABLE(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?). MTConnectStreamsResult — long InstanceId, long NextSequence, long FirstSequence, and IReadOnlyList<MTConnectObservation> (DataItemId, Value (string or "UNAVAILABLE"), TimestampUtc). These DTOs are the neutral shape both the TrakHound adapter and the hand-roll fallback produce, so the driver never sees TrakHound types (or XElement) directly.
Verify (compiles only):
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.xml → MTConnectStreamsResult (Header instanceId/nextSequence/firstSequence + observations). SampleAsync(from, ct) yields one MTConnectStreamsResult per multipart chunk and exposes the gap signal: expose a helper bool IsSequenceGap(long requestedFrom, MTConnectStreamsResult chunk) => chunk.FirstSequence > requestedFrom so Task 11's pump can re-baseline. Advance the caller's from to chunk.NextSequence (contiguous). Chunk framing (the multipart boundary reader) is TrakHound-internal or the hand-roll's boundary reader — either way the parser is fed one chunk's XML.
TDD (the forced-gap fixture is the load-bearing case):
[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.
SampleAsyncnever ends normally except by cancellation. The seam documents the stream as yielding "indefinitely, untilctis 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(withMTConnectStreamEndReason.ConnectionClosed/ClosingBoundary— transient, reconnect) orMTConnectStreamNotSupportedException(configuration — reconnecting reproduces it forever), sharing the baseMTConnectStreamException. The non-multipart body is still parsed first, so an Agent's ownMTConnectErrortext still wins, but the document is not yielded.IsSequenceGapmoved toIMTConnectAgentClient(static) and its first parameter is nowexpectedFrom. The sketch'srequestedFromname and doc were wrong for every chunk after the first: the correct comparand is the previous chunk'sNextSequence, and comparing against the openingfromreports a gap on every chunk once the ring buffer rolls — an endless/currentre-baseline storm against a healthy stream. Rehomed onto the seam so Task 11 need not reference the concrete client. Task 11 must also treat anOUT_OF_RANGEInvalidDataExceptionas a re-baseline trigger: cppagent answers afrombelowfirstSequencewith anMTConnectErrorunder HTTP 200, so ring-buffer overflow arrives as a parse failure, never as a gap-bearing chunk.IsSequenceGapalone does not cover it. Documented on the seam.MTConnectObservationgainedIsStructured(defaultfalse), set fromelement.HasElements. A DATA_SET/TABLE observation's<Entry key=…>children concatenate throughelement.Valueto 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-bearingMessage— no value-shape heuristic works. Deliberately false for CONDITION observations: their value comes from the element name, so child content cannot corrupt it. The index maps the flag to a status; the parser does not.
Hardening in the same pass: disposal mid-enumeration now surfaces as OperationCanceledException
(via an internal dispose-linked token) rather than a raw ObjectDisposedException/IOException that
Task 9's re-init would otherwise inflict on an enumerating pump; HeartbeatMs, SampleIntervalMs and
SampleCount join RequestTimeoutMs in construction-time positive-value validation (all three reach
the query string, and an Agent answers heartbeat=0 with an HTTP-200 MTConnectError that would name
no config key); a multipart/* response with no boundary parameter fails fast instead of degrading
into a watchdog timeout; the no-Content-length framing fallback logs a one-shot warning (the client
now takes an optional ILogger); and the shared element/attribute reading rules moved into
MTConnectXml, used by both parsers.
Coverage gap closed, and worth remembering as a pattern. Every multipart test served the whole body
from one buffer, so every framing test completed in a single ReadAsync — the split-boundary path,
the split-header path and the multi-fill loop were correct by inspection only, on the task whose
headline risk is framing. A ChunkedStream double returning N bytes per read (N = 1, 3, 7) now drives
the fixtures through a split transport. Falsifiability confirms the gap was real: removing the
split-boundary overlap, removing the split-header overlap, and collapsing EnsureAsync's fill loop each
fail only the new chunked tests — every pre-existing multipart test stays green under all three.
Task 8: MTConnectObservationIndex + UNAVAILABLE → BadNoCommunication
Classification: standard Estimated implement time: ~5 min Parallelizable with: none Files:
- Create:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectObservationIndex.cs - Test:
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectObservationIndexTests.cs
Thread-safe dataItemId → DataValueSnapshot map updated by /current and /sample. The snapshot builder maps a raw observation → DataValueSnapshot:
- value
UNAVAILABLE→new(null, BadNoCommunication, ts, now)whereprivate const uint BadNoCommunication = 0x80310000u;(design §3.3 — declared as a const, the ModbusStatusBadCommunicationErrorpattern; renders by name in the CLI'sSnapshotFormatter). - a present value → coerced to the tag's
DriverDataTypewithStatusCode = Good (0),SourceTimestampUtc = observation.timestamp. - a
dataItemIdnever 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/probeunder the deadline (cacheIDevice[]model +Header.instanceId), prime the index with one/current;DriverState.Healthyon success, rethrow → Faulted on failure.ReinitializeAsync: stop the sample stream, re-Initialize (config-only unlessAgentUri/DeviceNamechanged).ShutdownAsync: stop stream, dispose client.GetHealth:DriverHealth(State, LastSuccessfulRead, LastError)—LastSuccessfulRead= last/current|/samplechunk 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 fromnextSequence; record the subscribed ref set; immediately fireOnDataChangefor 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 + raiseOnDataChange(handle, dataItemId, snapshot); advancefrom = nextSequence. -
Ring-buffer overflow: when
IsSequenceGap(from, chunk)(Task 7) → re-/currentto re-baseline, then resume from the newnextSequence. -
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
Device→builder.Folder(name, name)→ child builder; - each nested
Component→ recursiveFolder(name, displayName)on the parent's child builder; - each
DataItem→child.Variable(browseName, displayName, attr)withbrowseName = dataItem.Name ?? dataItem.Id, andattr = 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
DeviceNameis 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: oneHostConnectivityStatusper Agent (HostName = AgentUri); a cheap periodic/probe(or the sample-stream heartbeat) flipsRunning ↔ Stopped; raiseOnHostStatusChangedon transition. Enable/interval fromMTConnectDriverOptions.Probe. -
IRediscoverable: cacheHeader.instanceIdfrom Initialize; on every/current//samplechunk, a changedinstanceIdmeans the Agent restarted / its model changed → raiseOnRediscoveryNeeded(new("MTConnect agent instanceId changed", null)). This is whyRediscoverPolicy = Onceis 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(addMTConnectconst + append toAll) - 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 toDriver.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):
- Add
public const string MTConnect = "MTConnect";toDriverTypeNamesand append it toAll(design usesDriverTypeNames.MTConnectin the editor map/validator per the CLAUDE.md "keyed off constants" rule). - In
DriverFactoryBootstrap: addusing MTConnectProbe = Driver.MTConnect.MTConnectDriverProbe;,Driver.MTConnect.MTConnectDriverFactoryExtensions.Register(registry, loggerFactory);inRegister(...), andservices.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, MTConnectProbe>());inAddOtOpcUaDriverProbes(so the probe reaches admin-only nodes — the admin-pinned Test-Connect singleton;TryAddEnumerableprevents the fused-node double-register that would makeToDictionary(p=>p.DriverType)throw). - Add the
Driver.MTConnectProjectReference 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 failsEvery_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)inTagConfigEditorMap.[DriverTypeNames.MTConnect] = j => MTConnectTagConfigModel.FromJson(j).Validate()inTagConfigValidator.
(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, andCapturedTreeBrowseSessionare Wave-0 machinery this driver only opts into viaSupportsOnlineDiscovery=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):
- Test Connect on the MTConnect driver goes green (probe reaches the fixture).
- Browse picker (
/unsTagModal → Browse): the Device→Component→DataItem tree renders; a picked leaf commitsTagConfig.FullName = <dataItemId>(Wave-0 dependency). - Typed editor shows
FullName/read-onlymtCategory/DataTypeoverride and round-trips. - 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,UNAVAILABLEitems renderBadNoCommunication. - Subscribe (
... subscribe ...) — live updates flow from the/samplestream.
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_SERIESSAMPLE 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.