feat(mtconnect): read-only MTConnect Agent driver (P1) — 23 tasks + 5-leg live gate #506
Reference in New Issue
Block a user
Delete Branch "feat/mtconnect-driver"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Adds a read-only MTConnect Agent driver — the first of the seven curated next-drivers
(
docs/plans/2026-07-24-driver-expansion-tracking.md). Executesdocs/plans/2026-07-24-mtconnect-driver.mdend to end: all 23 tasks, plus a full 5-leg livegate against a real MTConnect Agent.
Equipment-kind, no COM gateway. Capabilities:
IReadable,ISubscribable,ITagDiscovery,IHostConnectivityProbe,IRediscoverable. Deliberately notIWritable— MTConnectwrite-back is a separate protocol surface, deferred with a note in
docs/drivers/MTConnect.md.What's here
Driver.MTConnect) —/probe,/current, and amultipart/x-mixed-replace/samplelong-poll pump, hand-rolled overSystem.Xml.Linq.Namespace-agnostic
LocalNamematching, fail-loudInvalidDataException, ring-buffersequence-gap detection with re-baseline, heartbeat watchdog.
Driver.MTConnect.Contracts) — one pureMTConnectDataTypeInference.Infershared by discovery and runtime, pinned by a golden table test.
UNAVAILABLE→BadNoCommunication, CONDITION worst-of(Fault > Warning > Normal > UNAVAILABLE).
TagConfigEditorMap,TagConfigValidator,RawDriverTypeDialogandDriverConfigModal.mtconnect/agent:2.7.0.12+ SHDR-adapterdocker fixture, with an env-gated integration suite.
Testing
StatusCodeParityTests,DriverTypeNamesGuardTests)StatusCodeParityTests(new on master) reports 9 MTConnect constants in scope, all matching thepinned SDK.
Live gate — all 5 legs passed
Run on an isolated stack (
otopcua-host:mtconnect, projectotopcua-mtc, ports9220/4860/14340) so the shared
otopcua-devrig and three sibling driver worktrees sharingotopcua-host:devwere untouched. Fixture at10.100.0.35:5000.Driver creatable in
/raw✅ · typed config modal renders ✅ · config persists and round-trips ✅ ·browse picker commits
fullName✅ · deploy → OPC UA read + subscribe ✅.Read/subscribe verified over
opc.tcp://localhost:4860across all three type paths — Float64SAMPLE (sinusoid), Int64 EVENT (monotonic), String EVENT (
READY → INTERRUPTED) — all Good,streaming live from the
/samplepump.Worth noting: the live fixture answers in the MTConnect 2.0 namespace while the canned unit
fixtures are 1.3. The namespace-agnostic parsing is what makes both work — now demonstrated
rather than argued.
Defects the gate caught that tests did not
Several of these would have shipped green. Recording them because the class recurs:
RawTagsignored — the driver keyed its index bydataItemIdwhile the runtime routes byRawPath, andMTConnectDriverConfigDtohad noRawTagsmember soSystem.Text.Jsonsilentlydropped the injected array. Every tag read
BadNodeIdUnknown; no value could ever reach aclient in production.
not create the driver:
RawDriverTypeDialog's hardcoded type list omitted it andDriverConfigModalhad nocase. Identical to the gap the SQL-poll driver hit, so this is now aconfirmed repeat pattern rather than a one-off;
RawDriverTypeDialogCoverageTestsguards it.The
switchinDriverConfigModal.razorcompiles intoBuildRenderTreeand is unreachable byreflection — this mutation survived all 800 AdminUI tests.
/samplestream end — three normal-return paths while the interface promised "rununtil cancelled"; a non-multipart response yielded one document and stopped. Now typed exceptions.
Healthy with a permanently silent subscription and no log.
PartCountcase-insensitively,but
/probedeclaresPART_COUNT; underscores defeat case-insensitivity, soPART_COUNTtypedas String. Fixed with a separator-insensitive comparer.
Out of scope — pre-existing, verified against current master, NOT fixed here
MTConnect is not affected — verified live.
IRediscoverable/IHostConnectivityProbeevents have no consumer anywhere insrc/.CLAUDE.md's Change Detection section claims
DriverHostconsumes the signal; it does not.Bad/Uncertainsub-code reaches an OPC UA client.DriverInstanceActor.QualityFromStatuscollapses the status onto a 3-stateOpcUaQuality(
statusCode >> 30) andOtOpcUaNodeManager.StatusFromQualityre-expandsBadtoStatusCodes.Bad. Deliberate per the enum's own doc comment, but the client-visible consequencelooks undocumented: #497's 16 corrected constants and the parity guard keep constants internally
right, while a client still cannot tell
BadNoCommunicationfromBadTypeMismatch. Found via atag reading
0x80000000that carried the agent's exact source timestamp — proving the publishlanded and only the status differed.
Notes for review
123ddc3f. All conflicts were one shape — this driver and the newly-mergedSQL-poll driver each appending to the same list — resolved by keeping both in eight files.
XML formatter and the streaming client exposes no injectable handler (verified by reflection and
live invocation). Client and parsers are hand-rolled;
Directory.Packages.propsis unchanged.The four pre-existing findings listed under Out of scope are now tracked:
IRediscoverable/IHostConnectivityProbeevents have no consumer insrc/; CLAUDE.md's Change Detection section is wrong.OpcUaQualitycollapse).None of them block this PR — all four are pre-existing on
masterand were verified there, not merely inherited.Important 1 - the probe-model triple was read outside _lifecycle while every write happened under it. Packed (Client, ProbeModel, ProbeModelDataItemCount) into one immutable AgentSession swapped by a single Volatile.Write, matching what _index already does, rather than locking the readers: both await the network, so taking _lifecycle would stall a shutdown for a whole RequestTimeoutMs and would widen the non-reentrancy hazard. GetOrFetch and Flush re-publish via CompareExchange so a late re-cache cannot undo a concurrent teardown/re-init, and a client disposed mid-fetch now surfaces as the same "not connected" InvalidOperationException browse already handles instead of a raw ObjectDisposedException. Important 2 - HasConfigBody decided emptiness by matching the literals "{}" / "[]", so "{ }", "{\n}" and every pretty-printed empty document fell through to ParseOptions, failed the required-AgentUri check, and turned a semantically empty config into a cold-start fault. Now parsed: an object/array with no elements (or a bare null) is empty; malformed text is deliberately NOT empty so ParseOptions produces the real quoted error rather than silently starting on stale options. Important 3 - documented the _lifecycle non-reentrancy hazard in the code, on both the semaphore and StopSampleStreamAsync, naming Task 11's re-baseline as the specific path that would deadlock and giving the CurrentAsync-directly pattern that avoids it. Important 4 - removed the Initialize/Reinitialize asymmetry rather than documenting it. Both now share one rule via ResolveIncomingOptions: an unreadable config document never destroys working state, and faults only a driver that had none. InitializeAsync previously tore down before parsing, so a bad document on a live instance destroyed a healthy client - the exact outcome ReinitializeAsync was written to avoid. Minors: SafeDispose traces the swallowed disposal fault at Debug (a client that cannot be released is how a handle leak starts); the RequirePositive test is a Theory over all four timing knobs, not just RequestTimeoutMs (arch-review 01/S-6); the footprint test no longer overclaims "is zero before initialize". CannedAgentClient gains a one-shot ProbeGate so a lifecycle change landing mid-request is deterministic - no timers. 346/346 (329 + 17). Six mutations verified: fetch-CAS, disposed-translation, flush retired-session guard, literal HasConfigBody, teardown-before-parse, and two dropped RequirePositive calls each fail only their own tests.Two review findings on Task 14's MTConnectDriverProbe, both red-first: - CancelAfter(effectiveTimeout) sat before the try block with only a low-end floor on a non-positive timeout, never a high-end cap. A pathological timeout (e.g. TimeSpan.FromDays(1000)) threw an uncaught ArgumentOutOfRangeException straight out of CancelAfter's ~49.7-day legal range, violating the probe's own "Never throws" contract. Not reachable through today's only caller (AdminOperationsActor clamps to [1,60]s and wraps the call), but a landmine for any future caller without that clamp. Fixed by clamping effectiveTimeout to a new MaxTimeout (10 minutes). - The final `catch (Exception ex) { return new(false, ex.Message, null); }` forwarded ex.Message verbatim for any exception type not enumerated above it, breaking the redaction discipline every other catch was audited for (AgentUri may carry userinfo credentials). Not exploitable by any currently-reachable exception type, but an open door for a future one. Fixed to build a message from the already-redacted safeUri + the exception's type name instead of its message. Also: a genuine non-2xx stub-handler test (prior HttpRequestException coverage was connection-refused only), a credentialed-agentUri + successful-probe redaction test, and a one-line doc remark on the accepted reachable-vs-deployable gap.The driver could not receive data after a real deploy. The runtime hands every driver its authored tags as a RawTags array (RawPath identity + TagConfig blob, injected by DriverDeviceConfigMerger) and then subscribes, reads, and routes published values by RawPath. MTConnectDriverConfigDto declared no RawTags property, so System.Text.Json silently dropped the array, and every reference the server passed missed an index keyed by DataItem id: every value came back BadNodeIdUnknown, with the whole suite green because every test handed the driver its own Tags array directly. - MTConnectDriverOptions gains RawTags; the config DTO binds it (verbatim — a bad blob is skipped per-tag at binding time, never fails the deployment). - A new TagBinding, derived from the options and swapped with them in one write, carries RawPath -> dataItemId, its one-to-many reverse, and the definition set the observation index coerces against. Subscribe/Read resolve through it; the fan-out expands each touched dataItemId into every RawPath bound to it and publishes under THAT reference — the line the blocker turned on. - Coercion type precedence: the blob's driverDataType/dataType (both spellings — the AdminUI's MTConnectTagConfigModel writes the latter), else a Tags entry for the same DataItem, else the Agent's own /probe declaration via MTConnectDataTypeInference (what makes a browse-committed {"address": id} blob publish as a number), else String. A present-but-invalid type rejects that tag. - An unmapped reference falls through unchanged, so the dataItemId-keyed CLI surface and every existing test keep working, and an unclaimed RawPath reports Bad rather than throwing. 15 new tests build their config through the real DriverDeviceConfigMerger; all four mutations (publish the dataItemId, drop the RawTags binding, drop the derived coercion type, drop the reverse-map expansion) are caught.Registers the MTConnect typed tag editor in TagConfigEditorMap / TagConfigValidator (keyed off DriverTypeNames.MTConnect, never a literal) and adds the razor shell over Task 17's MTConnectTagConfigModel. The shell stays trivial: the /probe-sourced mtCategory/mtType/mtSubType/units row is rendered disabled + readonly with NO binding of any kind, and no inferred-type hint is shown — MTConnectDataTypeInference.Infer() also keys on the DataItem `representation`, which the tag-config model does not carry, so calling it here would agree with the browse picker for ordinary items and silently disagree for exactly the DATA_SET/TABLE/TIME_SERIES ones. Also fixes the picker -> editor round trip, which was broken in both directions: - RawBrowseCommitMapper.BuildTagConfig had no MTConnect branch, so a committed tag landed as the generic {"address": "<dataItemId>"} while the model read only `fullName`. The data plane kept working (the driver accepts all three id spellings), so the only symptom was an empty DataItem-id field in the editor — and saving from that state stamped fullName:"" over the tag. - MTConnectTagConfigModel.FromJson now accepts dataItemId/address as fallbacks (fixing tags committed before this change) and ToJson normalises onto `fullName`, dropping the aliases so the two spellings cannot drift.An MTConnect driver instance could not be authored at all: RawDriverTypeDialog (the only DriverInstance creation surface) offered a hardcoded 9-entry list without it, and DriverConfigModal's default: branch renders a warning with no raw-JSON fallback — so even a hand-inserted row could not be configured and DriverConfig stayed "{}", on which ParseOptions throws "missing required AgentUri". Adds the typed MTConnectDriverForm (the consistent sibling pattern) covering agentUri / deviceName / the four polling knobs / probe / reconnect, wires the dispatch case, and offers the type in the dialog. - All decisions live in the static MTConnectDriverForm.BuildConfigJson + MTConnectFormModel (plain C#), because this project has no bUnit. - The wire shape mirrors the driver's private MTConnectDriverConfigDto, NOT MTConnectDriverOptions: the latter's TimeSpan probe knobs would serialise as "00:00:05" and never bind to the driver's integer intervalMs. - arch-review 01/S-6: a non-positive timing knob is REMOVED from the document rather than written, so ParseOptions substitutes the driver's own positive default instead of the driver faulting at Initialize on RequirePositive. Removal (not just skipping) also stops a stale positive value surviving and disagreeing with what the form shows. - Unknown top-level keys survive a load/save, so the driver's hand-authored tags[] surface is not silently deleted by opening the form. - _jsonOpts carries JsonStringEnumConverter, satisfying the DriverPageJsonConverterTests fleet guard properly rather than by exemption. New RawDriverTypeDialogCoverageTests resolves the offered set through DriverTypeNames.All, so the next registered driver fails here until the dialog offers it.The first and only thing in the MTConnect workstream that exercises the driver against a real Agent; everything else runs on canned XML. Fixture (Docker/): two services, both stock images with this folder bind-mounted. - `agent` mtconnect/agent:2.7.0.12 on :5000. NOTE the repo name: the source project is mtconnect/cppagent but the PUBLISHED image is mtconnect/agent; mtconnect/cppagent does not exist on Docker Hub. - `adapter` a stdlib SHDR feeder. Required, not optional: the agent image ships only the binary + schemas/styles, so a lone Agent answers /probe and then reports every observation UNAVAILABLE forever, which can prove nothing about reads or streaming. Devices.xml seeds one DataItem per inference branch (SAMPLE+units, TIME_SERIES, PART_COUNT/LINE_NUMBER, controlled vocab, DATA_SET, CONDITION), gives every named item `name != id` (the inverse of the hand-authored unit fixtures), and leaves one EVENT permanently unfed so UNAVAILABLE -> BadNoCommunication has a live subject. Suite (12 tests) asserts STRUCTURALLY against the Agent's own /probe response, never by literal id, so it survives an edit to Devices.xml and can be pointed at a real machine tool via MTCONNECT_AGENT_ENDPOINT. It skips cleanly (12/12) when no Agent answers, and each test carries a hard [Fact(Timeout)] so a half-up fixture cannot wedge a build. Three findings from bringing the fixture up, each now pinned in a comment: - agent.cfg must be pure ASCII; one non-ASCII byte in a COMMENT makes the config parser reject the whole file with a bare "Failed / Stopped at line: N" and exit. - `sampleCount` is an attribute of a TIME_SERIES observation, not of a DataItem declaration. A real Agent meeting one on a declaration DROPS THE ENTIRE DATA ITEM from the device model, so the inference only ever sees null and a live TIME_SERIES tag is always variable-length. Asserted, not ignored. - The Agent's own <Agent> self-model publishes update-rate SAMPLEs that tick whether or not any adapter is attached. Excluding them is load-bearing: with them included the "live stream delivered a changed value" test passed with the fixture's data source deliberately stopped. MTConnectError-under-HTTP-200 is NOT covered live and cannot be: cppagent 2.7 answers an unknown device 404 and an out-of-range sequence 400, each with a well-formed error body. That shape stays canned-only, and the suite says so.7a48a6637bto813e445936