# 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` on the factory); the Wave-0 universal browser seam (`ITagDiscovery.SupportsOnlineDiscovery`). **Source design:** docs/plans/2026-07-15-mtconnect-driver-design.md **Program context:** docs/plans/2026-07-15-driver-expansion-program-design.md (§3 shared contract, §4 two-tier browse, §7 fixtures) **Wave-0 dependency:** docs/plans/2026-07-15-universal-discovery-browser-design.md — the browse picker live-verify (Task 21) is gated on the Wave-0 universal-browser live gate (Gitea #468) being closed. --- ## Cross-cutting constraints (apply to every task) - **Per-op deadline (R2-01 frozen-peer lesson).** Every agent HTTP call carries a bounded deadline: `/probe` + `/current` wrap in a `CancellationTokenSource(RequestTimeoutMs)` linked to the caller's `ct` AND set `HttpClient.Timeout`; the `/sample` long-poll cannot use `HttpClient.Timeout`, so it runs under a heartbeat watchdog (§7 of the design). No unbounded waits. - **Enum-serialization trap (project-wide MEMORY).** Enums on any config/tag surface serialize as **name strings**: the AdminUI model writes them via `TagConfigJson.Set` (name), the probe parses with `new JsonStringEnumConverter()`, and the factory keeps enum-carrying DTO fields `string?` + parses via `ParseEnum` (no converter in the factory `JsonOptions`). A numerically-serialized enum faults the driver at deploy. - **Ctor is connection-free.** The `MTConnectDriver` constructor opens no sockets — every connect happens in `InitializeAsync`. The universal browser's throwaway-instance `CanBrowse` pattern depends on this. - **Never throw across a capability boundary.** `IReadable` reports per-ref failures as Bad-coded snapshots (throws only if the driver itself is unreachable); `IDriverProbe.ProbeAsync` never throws (returns `Ok=false`). - **Secrets from env/secret-refs.** No agent URL creds committed or logged. - **`Float64`/`Int64` are the real `DriverDataType` member names** (verified in `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverDataType.cs`). --- ## Task 0: Verify TrakHound MTConnect.NET pin (or record the hand-roll decision) **Classification:** small **Estimated implement time:** ~5 min **Parallelizable with:** none (gates the `.csproj` package refs in Task 1) **Files:** - Modify: `docs/plans/2026-07-24-mtconnect-driver.md` (record the decision inline under this task) The design (§2 "Library verification checklist") flags three research-sourced facts as unverifiable offline. Verify against real NuGet before committing to the dependency: 1. `dotnet package search MTConnect.NET-Common --exact-match` (and `-HTTP`) — confirm the pinned version (`6.9.0.2` or then-current) **exists on nuget.org**. 2. In a throwaway project, `dotnet add package MTConnect.NET-Common -v ` + `dotnet restore` — confirm it **restores** and its TFM set includes `netstandard2.0` (loads on net10). 3. Open the restored package's embedded `LICENSE` — confirm the *pinned version* is **MIT** (older releases carried mixed MIT/Apache/"all rights reserved"). **Decision rule:** all three pass ⇒ TrakHound path (Task 1 adds the two `PackageReference`s). 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: ```bash git add docs/plans/2026-07-24-mtconnect-driver.md git commit -m "docs(mtconnect): record TrakHound-vs-hand-rolled client decision (Task 0)" ``` --- ## 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 `` lines) Steps: - Copy the `.Contracts` csproj boilerplate from `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/…csproj`: `net10.0`, `Nullable=enable`, `ImplicitUsings=enable`, `TreatWarningsAsErrors=true`. `.Contracts` references **only** `Core.Abstractions` (no backend NuGet). Add `GenerateDocumentationFile=true` (matches the fleet). - The runtime `Driver.MTConnect.csproj` references `Core`, `Core.Abstractions`, `.Contracts`, and — **per the Task 0 decision** — either the two TrakHound packages or nothing extra (hand-roll uses only the BCL). Add `InternalsVisibleTo` the Tests project (mirror Modbus). - The `.Tests` csproj: xUnit + Shouldly (copy `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/…csproj` package block), ProjectReferences to `Driver.MTConnect` + `.Contracts`. Mark the future `Fixtures/*.xml` as `CopyToOutputDirectory` (`PreserveNewest…`). - Add all three to `ZB.MOM.WW.OtOpcUa.slnx` beside the Modbus entries (lines ~29–92 pattern). Verify: ```bash dotnet build src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.csproj # expect: PASS (empty compile) ``` ```bash 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): ```csharp [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(); } ``` ```bash 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): ```csharp [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); } ``` ```bash dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests --filter "FullyQualifiedName~MTConnectDataTypeInferenceTests" # RED, then GREEN git add -A && git commit -m "feat(mtconnect): pure data-type inference table + golden test (Task 3)" ``` --- ## Task 4: Capture the canned XML fixtures **Classification:** small **Estimated implement time:** ~5 min **Parallelizable with:** Task 2, Task 3 **Files:** - Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/Fixtures/probe.xml` - Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/Fixtures/current.xml` - Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/Fixtures/sample.xml` - Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/Fixtures/sample-gap.xml` - Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/Fixtures/current-unavailable.xml` These canned docs drive the bulk of unit coverage with no network (design §8.1). Capture from a demo agent (`https://demo.mtconnect.org/probe` / `/current` / `/sample`) or hand-author minimal valid MTConnect 1.x XML covering: - `probe.xml` — one `MTConnectDevices` with a `Device` → nested `Component` → `DataItem`s spanning every §3.3 category (a `SAMPLE` w/ units, a `TIME_SERIES` w/ `sampleCount`, an `EVENT` numeric, an `EVENT` controlled-vocab, a `CONDITION`), each with a distinct `id`. - `current.xml` — an `MTConnectStreams` with a `Header instanceId=... nextSequence=...` and one observation per data item, including at least one `<... >UNAVAILABLE`. - `sample.xml` — an `MTConnectStreams` chunk with several observations and a `Header nextSequence` that continues contiguously from `current.xml`. - `sample-gap.xml` — a `Header` whose `firstSequence` is **newer** than the `from` the driver would request (the ring-buffer-overflow / forced-`nextSequence`-gap case Task 7 + Task 11 assert re-baseline on). - `current-unavailable.xml` — every observation `UNAVAILABLE` (Task 8's quality-mapping fixture). No code, no test yet — verify the files copy to bin: ```bash 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: ```csharp public interface IMTConnectAgentClient { Task ProbeAsync(CancellationToken ct); Task CurrentAsync(CancellationToken ct); IAsyncEnumerable 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` (`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): ```bash 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: ```csharp [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(""); model.Devices[0].Components.ShouldNotBeEmpty(); // nesting preserved } ``` ```bash 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): ```csharp [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(); } ``` ```bash dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests --filter "FullyQualifiedName~MTConnectStreamsParseTests" # RED, then GREEN git add -A && git commit -m "feat(mtconnect): current/sample parse + sequence-gap detection (Task 7)" ``` --- ## Task 8: `MTConnectObservationIndex` + `UNAVAILABLE → BadNoCommunication` **Classification:** standard **Estimated implement time:** ~5 min **Parallelizable with:** none **Files:** - Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectObservationIndex.cs` - Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectObservationIndexTests.cs` Thread-safe `dataItemId → DataValueSnapshot` map updated by `/current` and `/sample`. The snapshot builder maps a raw observation → `DataValueSnapshot`: - value `UNAVAILABLE` → `new(null, BadNoCommunication, ts, now)` where `private const uint BadNoCommunication = 0x80310000u;` (design §3.3 — declared as a const, the Modbus `StatusBadCommunicationError` pattern; renders by name in the CLI's `SnapshotFormatter`). - a present value → coerced to the tag's `DriverDataType` with `StatusCode = Good (0)`, `SourceTimestampUtc = observation.timestamp`. - a `dataItemId` never seen / empty condition → `Bad`-coded snapshot. TDD (drive off `current.xml` + `current-unavailable.xml`): ```csharp [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(""); 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(""); snap.StatusCode.ShouldBe(0u); snap.SourceTimestampUtc.ShouldNotBeNull(); } ``` ```bash 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? agentClientFactory = null, ILogger? logger = null)` — **connection-free**. `agentClientFactory` defaults to the real `MTConnectAgentClient`; tests inject a canned-XML fake implementing `IMTConnectAgentClient`. Implement: - `InitializeAsync`: build the client, run one `/probe` under the deadline (cache `IDevice[]` model + `Header.instanceId`), prime the index with one `/current`; `DriverState.Healthy` on success, rethrow → Faulted on failure. - `ReinitializeAsync`: stop the sample stream, re-Initialize (config-only unless `AgentUri`/`DeviceName` changed). - `ShutdownAsync`: stop stream, dispose client. - `GetHealth`: `DriverHealth(State, LastSuccessfulRead, LastError)` — `LastSuccessfulRead` = last `/current`|`/sample` chunk time. - `GetMemoryFootprint` / `FlushOptionalCachesAsync`: footprint ≈ probe model + index; flush drops the probe/browse cache, keeps the index. TDD with a canned-XML fake client: ```csharp [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); } ``` ```bash 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: ```csharp [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[] { "", "does-not-exist" }, default); res.Count.ShouldBe(2); res[0].StatusCode.ShouldBe(0u); (res[1].StatusCode & 0x80000000u).ShouldBe(0x80000000u); // Bad } ``` ```bash dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests --filter "FullyQualifiedName~MTConnectReadTests" # RED, then GREEN git add -A && git commit -m "feat(mtconnect): IReadable via /current, ordered per-ref snapshots (Task 10)" ``` --- ## Task 11: `ISubscribable` — `/sample` long-poll pump + ring-buffer re-baseline **Classification:** high-risk (streaming state + re-baseline correctness) **Estimated implement time:** ~5 min **Parallelizable with:** none **Files:** - Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs` - Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectSampleHandle.cs` (`ISubscriptionHandle`) - Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectSubscribeTests.cs` - `SubscribeAsync`: on the **first** subscription, start the shared sample stream from `nextSequence`; record the subscribed ref set; immediately fire `OnDataChange` for each subscribed ref from the primed `/current` (initial-data convention); return a handle (monotonic id + `DiagnosticId`). The stream-start handshake must complete inside the invoker's Subscribe timeout; the long-lived pump then runs under the heartbeat watchdog, not that timeout. - Pump: each chunk → for each observation whose `dataItemId ∈` subscribed set, update the index + raise `OnDataChange(handle, dataItemId, snapshot)`; advance `from = nextSequence`. - **Ring-buffer overflow:** when `IsSequenceGap(from, chunk)` (Task 7) → re-`/current` to re-baseline, then resume from the new `nextSequence`. - `UnsubscribeAsync`: drop the handle's refs; stop the shared stream when the set empties. TDD — assert the initial-data callback AND that a forced gap triggers a re-baseline `/current` (drive `SampleAsync` to yield `sample-gap.xml`): ```csharp [Fact] public async Task Subscribe_fires_initial_data_from_current() { var d = await InitializedDriver(); var seen = new List(); d.OnDataChange += (_, e) => seen.Add(e.FullReference); await d.SubscribeAsync(new[] { "" }, TimeSpan.FromMilliseconds(500), default); seen.ShouldContain(""); } [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[] { "" }, TimeSpan.FromMilliseconds(50), default); await fake.PumpOnce(); fake.CurrentCallCount.ShouldBeGreaterThan(1); // initial prime + re-baseline } ``` ```bash 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` → recursive `Folder(name, displayName)` on the parent's child builder; - each `DataItem` → `child.Variable(browseName, displayName, attr)` with `browseName = dataItem.Name ?? dataItem.Id`, and `attr = new DriverAttributeInfo(FullName: dataItem.Id, DriverDataType: , IsArray: rep==TIME_SERIES, ArrayDim: , SecurityClass: SecurityClassification.ViewOnly, IsHistorized: false, IsAlarm: category=="CONDITION")`. - If `DeviceName` is set, stream only that device's subtree. **Critical:** `FullName == dataItem.Id` — the value the universal browser commits as `TagConfig.FullName` and the key read/subscribe resolve against, aligned by construction. TDD with a capturing builder (a test double, or reuse Wave-0's `CapturingAddressSpaceBuilder` if referenceable): ```csharp [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(""); cap.Variables.Single(v => v.Attr.IsAlarm).Attr.DriverDataType.ShouldBe(DriverDataType.String); d.SupportsOnlineDiscovery.ShouldBeTrue(); d.RediscoverPolicy.ShouldBe(DiscoveryRediscoverPolicy.Once); } ``` ```bash dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests --filter "FullyQualifiedName~MTConnectDiscoverTests" # RED, then GREEN git add -A && git commit -m "feat(mtconnect): ITagDiscovery streams tree + SupportsOnlineDiscovery opt-in (Task 12)" ``` --- ## Task 13: `IHostConnectivityProbe` + `IRediscoverable` (instanceId watch) **Classification:** standard **Estimated implement time:** ~5 min **Parallelizable with:** Task 10, Task 12 **Files:** - Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs` - Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectHostAndRediscoverTests.cs` - `IHostConnectivityProbe`: one `HostConnectivityStatus` per Agent (`HostName = AgentUri`); a cheap periodic `/probe` (or the sample-stream heartbeat) flips `Running ↔ Stopped`; raise `OnHostStatusChanged` on transition. Enable/interval from `MTConnectDriverOptions.Probe`. - `IRediscoverable`: cache `Header.instanceId` from Initialize; on every `/current`/`/sample` chunk, a **changed** `instanceId` means the Agent restarted / its model changed → raise `OnRediscoveryNeeded(new("MTConnect agent instanceId changed", null))`. This is why `RediscoverPolicy = Once` is safe — instanceId change, not polling, drives re-discovery. TDD: ```csharp [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[] { "" }, TimeSpan.FromMilliseconds(50), default); await fake.PumpOnce(); got.ShouldNotBeNull(); } ``` ```bash 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): ```csharp [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(); ``` ```bash 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())`. Factory `JsonOptions` mirror Modbus (`PropertyNameCaseInsensitive`, `ReadCommentHandling=Skip`, `AllowTrailingCommas`, **no enum converter** — enum-carrying DTO fields stay `string?` + go through `ParseEnum`). TDD: ```csharp [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(() => MTConnectDriverFactoryExtensions.CreateInstance("mt1", "{}")); ``` ```bash dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests --filter "FullyQualifiedName~MTConnectFactoryTests" # RED, then GREEN git add -A && git commit -m "feat(mtconnect): driver factory + config DTO (Task 15)" ``` --- ## Task 16: Host registration + `DriverTypeNames.MTConnect` + guard-test parity **Classification:** small **Estimated implement time:** ~5 min **Parallelizable with:** none **Files:** - Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs` (add `MTConnect` const + append to `All`) - Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs` (Register call + probe alias + `TryAddEnumerable`) - Modify: `tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.csproj` (add ProjectReference to `Driver.MTConnect`) Three coupled edits that **must land together** to keep `DriverTypeNamesGuardTests` green (it asserts exact-set parity between `DriverTypeNames.All` and the reflection-discovered registered factories, scanning `ZB.MOM.WW.OtOpcUa.Driver.*.dll` in the test's bin): 1. Add `public const string MTConnect = "MTConnect";` to `DriverTypeNames` and append it to `All` (design uses `DriverTypeNames.MTConnect` in the editor map/validator per the CLAUDE.md "keyed off constants" rule). 2. In `DriverFactoryBootstrap`: add `using MTConnectProbe = Driver.MTConnect.MTConnectDriverProbe;`, `Driver.MTConnect.MTConnectDriverFactoryExtensions.Register(registry, loggerFactory);` in `Register(...)`, and `services.TryAddEnumerable(ServiceDescriptor.Singleton());` in `AddOtOpcUaDriverProbes` (so the probe reaches admin-only nodes — the admin-pinned Test-Connect singleton; `TryAddEnumerable` prevents the fused-node double-register that would make `ToDictionary(p=>p.DriverType)` throw). 3. Add the `Driver.MTConnect` ProjectReference to the guard-test project — **without it the new assembly isn't in bin, the guard doesn't discover the factory, and the new constant fails `Every_constant_matches_a_registered_factory`.** Verify (the guard test is the gate here): ```bash 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//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: ```csharp [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(); ``` ```bash dotnet test tests/Server/ --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 `