Re-ran the gate on the rebased branch against a freshly rebuilt
otopcua-host:mtconnect image, so it exercises the merged code.
The 2026-07-24 blocker (antiforgery cookie collision between the running
otopcua-dev rig on :9200 and the isolated stack on :9220) is sidestepped
rather than worked around: the headless deploy API
(POST /api/deployments + X-Api-Key) is AllowAnonymous().DisableAntiforgery()
by design, so cookie scoping is structurally irrelevant to it. No browser
needed — the recommended recipe for any future isolated-stack gate.
Both deploys sealed (DeploymentStatus.Sealed, FailureReason NULL) with both
MAIN nodes acking. Read + subscribe verified over opc.tcp://localhost:4860
across all three type paths: Float64 SAMPLE (sinusoid), Int64 EVENT
(monotonic), String EVENT (READY -> INTERRUPTED). This closes the last
unproven link — driver seam was already covered by the live integration
suite; leg 5 adds DeploymentArtifact -> address space -> OPC UA publish.
Two findings recorded in the plan:
- A bad authored dataType degrades to exactly ONE skipped tag, loudly
(per-tag warning naming the expected keys, BadNodeIdUnknown, "every other
tag is unaffected") while the rest keep streaming — the intended
fail-isolated behaviour, demonstrated rather than argued. Correcting it
took effect with no restart, showing MTConnect is not subject to the
separately-tracked config-edits-discarded defect.
- fixture_asset_changed reading 0x80000000 is NOT an MTConnect defect. The
driver maps the agent's UNAVAILABLE to BadNoCommunication correctly; the
shared publish path collapses every status to a 3-state OpcUaQuality enum
(DriverInstanceActor.QualityFromStatus, statusCode >> 30) and re-expands
Bad to StatusCodes.Bad (OtOpcUaNodeManager.StatusFromQuality). Deliberate
per the enum's own doc comment, but the client-visible consequence appears
undocumented: no driver's Bad/Uncertain sub-code ever reaches a client.
Tracked separately; out of scope here.
Also records the rebase onto master 123ddc3f and confirms StatusCodeParityTests
now covers 9 MTConnect constants, all passing.
68 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)"
LIVE-GATE RESULT (2026-07-24, completed 2026-07-27) — ALL 5 LEGS PASSED
Rig. The shared otopcua-dev rig was NOT used: three sibling driver worktrees
(modbus-rtu, mqtt-sparkplug, sql-poll-driver) share its otopcua-host:dev image, and it had
been up for hours. Instead an isolated stack was built from this worktree —
otopcua-host:mtconnect, compose project otopcua-mtc, overlay
docker-dev/docker-compose.mtconnect.yml, MAIN pair only, ports AdminUI 9220 / OPC UA 4860
/ SQL 14340. Mirrors how the mqtt worktree isolated itself. The otopcua-dev rig and every
sibling worktree were left untouched.
Fixture. mtconnect/agent:2.7.0.12 + the SHDR adapter deployed to
/opt/otopcua-mtconnect/ on the shared docker host and brought up healthy; http://10.100.0.35:5000
serves the seeded OtFixtureCnc model with live-moving values. Note it answers in the MTConnect
2.0 namespace while the canned fixtures are 1.3 — the namespace-agnostic LocalName parsing
(Task 6) is what makes both work, now demonstrated rather than argued.
| Leg | Result | Evidence |
|---|---|---|
| Integration suite vs. a REAL Agent | ✅ 12/12 | incl. Discovery_types_an_upper_snake_numeric_event_as_Int64 (the PART_COUNT regression), Read_and_subscribe_key_on_RawPath_when_the_artifact_supplies_RawTags (the blocker fix), Subscribe_delivers_a_changed_value_from_the_live_sample_stream, Discovery_binds_FullName_to_the_DataItem_id_when_the_name_differs |
1. Driver creatable in /raw |
✅ | MTConnect present in RawDriverTypeDialog; mtc-1 created. Proves the Host Register call is genuinely wired — the one thing no test guards (deleting it leaves the guard test green and substitutes a StubbedDriver while the deploy still seals green). |
| 2. Typed config modal renders | ✅ | "Configure driver · MTConnect" with all four sections (Agent / Polling / Connectivity probe / Reconnect), not the "no typed config form" banner. This is the mutation that survived all 749 AdminUI tests — the switch in DriverConfigModal.razor compiles to BuildRenderTree and is unreachable by reflection. |
| 3. Config persists + round-trips | ✅ | Stored {"agentUri":"http://10.100.0.35:5000","requestTimeoutMs":5000,…,"probe":{"enabled":true,…},"reconnect":{…}}; reopening the modal displays the saved URI (Razor binding verified — no bUnit in this repo). |
| 4. Browse picker → commit | ✅ | "Connect & browse" dialled the live Agent; tree rendered Agent + OtFixtureCnc with Favail (name ≠ id) beside id-named leaves, proving browseName = Name ?? Id. Committed TagConfig is {"fullName":"fixture_asset_changed","dataType":"String"} — the fullName-not-address fix, live. Wave-0 #468 did not block this. |
| 5. Deploy → OPC UA read/subscribe | ✅ PASSED (2026-07-27, post-rebase) | see "Leg 5" below |
Leg 5 — PASSED 2026-07-27, on the rebased branch
Re-run after the rebase onto master (123ddc3f), against a freshly rebuilt otopcua-host:mtconnect
image, so this gate exercises the merged code and not the pre-rebase tree.
The cookie blocker was sidestepped, not worked around. Rather than moving hosts or stopping the
otopcua-dev rig, the deploy went through the headless deploy API —
POST /api/deployments with X-Api-Key (DeployApiEndpoints, Security:DeployApiKey). It is
AllowAnonymous().DisableAntiforgery() by design ("machine endpoint, not a browser form post"), so
the antiforgery cookie collision is structurally irrelevant to it. This is the better recipe for any
future isolated-stack gate — it needs no browser at all:
curl -s -X POST http://localhost:9220/api/deployments \
-H "X-Api-Key: docker-dev-deploy-key" -H "Content-Type: application/json" \
-d '{"CreatedBy":"mtconnect-live-gate"}'
# {"outcome":"Accepted","deploymentId":"…","revisionHash":"…"} HTTP 202
Both deployments sealed Status = 2 (DeploymentStatus.Sealed) with FailureReason = NULL — i.e.
both MAIN nodes acked. No MaintenanceMode hatch was needed: the isolated stack's topology is
MAIN-only with both central nodes enabled and running.
Tag set. Leg 4's AdminUI-committed tag (fixture_asset_changed) is an AssetChanged EVENT that
never moves, so three live-changing tags were added directly via SQL (deliberately bypassing the
typed editor — see the finding below) to exercise all three type paths.
Results — read (Client.CLI read, opc.tcp://localhost:4860):
NodeId (ns=2, Raw realm) |
Value | Status |
|---|---|---|
s=mtc-1/vmc/fixture_partcount |
48099 (Int64) |
0x00000000 Good |
s=mtc-1/vmc/fixture_x_pos |
96.4416 (Float64) |
0x00000000 Good |
s=mtc-1/vmc/fixture_execution |
ACTIVE (String) |
0x00000000 Good |
s=mtc-1/vmc/fixture_asset_changed |
(null) | 0x80000000 — see finding 6 |
The RawPath is mtc-1/vmc/<dataItemId> (driver/device/tag; no folder prefix), confirmed by
browse -r, which rendered all four as [Variable] under the vmc device object.
Results — subscribe (Client.CLI subscribe -r over the whole device, 500 ms): all four monitored;
live updates flowed continuously from the /sample long-poll pump through to OPC UA — fixture_x_pos
tracing its sinusoid (103.66 → 123.18 → 141.90 → 155.27 → 159.99 → 154.93 → 141.32 → 122.48 → 103.03 → 87.75 → 80.35), fixture_partcount incrementing monotonically (48128 → 48129 → 48130), and
fixture_execution transitioning READY → INTERRUPTED. This closes the last unproven link: the
driver seam was already covered by the live integration suite, and leg 5 adds DeploymentArtifact →
address-space materialisation → OPC UA publish on top of it.
Additional findings from leg 5:
-
A bad
dataTypedegrades to exactly one skipped tag, loudly — verified by accident. The hand-written SQL used"dataType":"Double"; the vocabulary isDriverDataType, whose member isFloat64. The driver logged, per tag:could not map the raw tag mtc-1/vmc/fixture_x_pos to an Agent DataItem; it names no readable id (expected 'fullName', 'dataItemId' or 'address') or declares an unrecognised driverDataType. The tag is skipped and reports BadNodeIdUnknown; every other tag is unaffected.…and the other three tags kept streaming. That is the intended fail-isolated behaviour, demonstrated live rather than argued. Correcting the row to
Float64and redeploying turned the tag Good with no container restart — which incidentally shows MTConnect is not subject to the separately-tracked config-edits-silently-discarded defect that affects Modbus/FOCAS/OpcUaClient. Note also that the typed AdminUI editor would have prevented the mistake outright (it offers aDriverDataTypedropdown); the error was an artifact of authoring straight into SQL. -
fixture_asset_changedreading0x80000000is NOT an MTConnect defect — it is a pre-existing, deliberate fidelity gap on the shared publish path. The agent does return the tag in/currentas<AssetChanged …>UNAVAILABLE</AssetChanged>, and the driver maps it correctly toBadNoCommunication(0x80310000,MTConnectObservationIndex.cs:255). The node nevertheless reports genericBad(0x80000000) — while carrying the agent's exact source timestamp, which proves the publish landed and only the status differs. Cause, verified by reading the source:DriverInstanceActor.QualityFromStatus(DriverInstanceActor.cs:1032-1041) projects the driver'suintonto the 3-stateOpcUaQualityenum using only the top two severity bits (statusCode >> 30), andOtOpcUaNodeManager.StatusFromQuality(OtOpcUaNodeManager.cs:3318-3322) re-expandsBadtoStatusCodes.Bad. It is intentional —OpcUaQuality's own doc comment says "Real SDK has finer-grained codes; the engine actors only need this 3-state classification."The consequence appears undocumented and is client-visible: no driver's Bad/Uncertain sub-code ever reaches an OPC UA client. Issue #497's 16 corrected constants and the new
StatusCodeParityTestsguard keep the constants internally right, but a client cannot tellBadNoCommunicationfromBadTypeMismatchfromBadNotSupported. (The one survivor isBadWaitingForInitialData, written directly at the node atOtOpcUaNodeManager.cs:1806/1901, bypassing the projection.) Tracked separately; out of scope for this plan.
Historical — why leg 5 was blocked on 2026-07-24 (superseded by the API-key recipe above).
Browser cookies are scoped to a
host, not a port, so the AdminUI antiforgery cookie issued by the still-running otopcua-dev
rig on localhost:9200 is sent to the isolated stack on localhost:9220, whose data-protection
keys differ:
Microsoft.AspNetCore.Antiforgery.AntiforgeryValidationException: The antiforgery token could not be decrypted.
The Deploy current configuration POST is rejected before reaching any MTConnect code — no
Deployment row is created, and the UI surfaces nothing. Clearing JS-visible cookies does not help
(the antiforgery cookie is HttpOnly). This is a two-stacks-on-one-host collision, entirely outside
the driver.
The three browser-side remedies originally listed (distinct host / incognito profile / stop the
otopcua-dev rig) all remain valid, but none was needed — the headless deploy API above avoids the
browser entirely and is the recommended recipe.
Incidental findings from the rig work:
CORRECTED — this was my error, not a repo inconsistency. The migrationClusterNodehas noMaintenanceModecolumn, so CLAUDE.md is ahead of the migration.20260722125506_AddClusterNodeMaintenanceModeexists on master, along withAkkaPort/GrpcPort. This branch is 39 commits behind master (base963eec1b, master28c28667) and simply predates them, so the isolated stack's schema lacked the column and I reduced the seeded topology by deleting the SITE-A/SITE-B rows instead. CLAUDE.md is accurate. DONE 2026-07-27 — rebased onto master123ddc3f(67 commits by then, not 39). Conflicts were all of one shape — this driver and the newly-merged SQL-poll driver each adding their entry to the same list — resolved by keeping both in:ZB.MOM.WW.OtOpcUa.slnx,DriverTypeNames.cs,DriverFactoryBootstrap.cs,Core.Abstractions.Tests.csproj,TagConfigEditorMap.cs,TagConfigValidator.cs,DriverConfigModal.razor,RawDriverTypeDialog.razor. Post-rebase: solution build 0 errors, MTConnect 491/491, Core.Abstractions 256/256, AdminUI 800/800. For a future partial-topology gate,MaintenanceMode = 1is now available and is the correct hatch (it is what the SQL-poll driver's gate used), in place of the row deletion I resorted to.sqlcmdagainst this schema needs-I(SET QUOTED_IDENTIFIER ON); without it every DML statement fails withMsg 1934because of the filtered/computed indexes.- Status-code parity is pre-verified against master's new guard. Master gained
StatusCodeParityTests, which reflects over everyZB.MOM.WW.OtOpcUa.Driver.*.dllin its bin for status-shapedconst uintfields — includingprivate constoninternaltypes — and checks each againstOpc.Ua.StatusCodes. Task 16 added theDriver.MTConnectProjectReference to that same test project, so this driver's constants are in scope. All eight were verified against the pinned SDK assembly directly:BadCommunicationError 0x80050000,BadNoCommunication 0x80310000,BadNodeIdUnknown 0x80340000,BadNotConnected 0x808A0000,BadNotSupported 0x803D0000,BadOutOfRange 0x803C0000,BadTypeMismatch 0x80740000,BadWaitingForInitialData 0x80320000— 0 mismatches. Note the guard only sees named constants; an inline status literal at a call site is invisible to it, so keep hoisting them. Confirmed after the rebase: the guard reports 9 MTConnect constants in scope (the 8 above plusGood), all passing — the pre-verification held. - The separately-tracked
BadTypeMismatchdefect in FOCAS/TwinCAT/AbLegacy/AbCip is already fixed on master (issue #497 grew it to 16 wrong constants across 6 drivers). This driver independently chose the correct0x80740000and pinned it with a mutation test, so the two agree; no action.
Teardown when done:
docker compose -p otopcua-mtc -f docker-dev/docker-compose.yml -f docker-dev/docker-compose.mtconnect.yml down -v
ssh dohertj2@10.100.0.35 'cd /opt/otopcua-mtconnect && docker compose down'
Task 22: Docs + deferred-writeback note; update the tracking doc
Classification: trivial Estimated implement time: ~5 min Parallelizable with: none Files:
- Create:
docs/drivers/MTConnect.md(or a short section — mirror an existing driver doc) - Modify:
docs/plans/2026-07-24-driver-expansion-tracking.md(mark MTConnect P1 done + link this plan)
Document the P1 Agent MVP: config keys, browse-via-universal, the UNAVAILABLE→BadNoCommunication semantics, CONDITION-as-String, and the fixture recipe. Record write-back (MTConnect Interfaces) as deferred — point at docs/plans/2026-07-15-mtconnect-driver-design.md §3.6 + §9 (P1.5 native-alarm CONDITION, P2 SHDR ingest are also deferred there). Update the driver-expansion tracking doc's Wave-2 row.
git add -A && git commit -m "docs(mtconnect): P1 driver guide + tracking-doc update, defer write-back (Task 22)"
Deferred (out of P1 — pointers, not scope)
- Write-back (MTConnect Interfaces) — design §3.6: read-only Agent surface; revisit only on a concrete deployment need.
- CONDITION → native Part-9 alarms (
IAlarmSource),TIME_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.