999 lines
62 KiB
Markdown
999 lines
62 KiB
Markdown
# 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` + `/current` wrap in a `CancellationTokenSource(RequestTimeoutMs)` linked to the caller's `ct` AND set `HttpClient.Timeout`; the `/sample` long-poll cannot use `HttpClient.Timeout`, so it runs under a heartbeat watchdog (§7 of the design). No unbounded waits.
|
||
- **Enum-serialization trap (project-wide MEMORY).** Enums on any config/tag surface serialize as **name strings**: the AdminUI model writes them via `TagConfigJson.Set` (name), the probe parses with `new JsonStringEnumConverter()`, and the factory keeps enum-carrying DTO fields `string?` + parses via `ParseEnum<T>` (no converter in the factory `JsonOptions`). A numerically-serialized enum faults the driver at deploy.
|
||
- **Ctor is connection-free.** The `MTConnectDriver` constructor opens no sockets — every connect happens in `InitializeAsync`. The universal browser's throwaway-instance `CanBrowse` pattern depends on this.
|
||
- **Never throw across a capability boundary.** `IReadable` reports per-ref failures as Bad-coded snapshots (throws only if the driver itself is unreachable); `IDriverProbe.ProbeAsync` never throws (returns `Ok=false`).
|
||
- **Secrets from env/secret-refs.** No agent URL creds committed or logged.
|
||
- **`Float64`/`Int64` are the real `DriverDataType` member names** (verified in `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverDataType.cs`).
|
||
|
||
---
|
||
|
||
## Task 0: Verify TrakHound MTConnect.NET pin (or record the hand-roll decision)
|
||
|
||
**Classification:** small
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** none (gates the `.csproj` package refs in Task 1)
|
||
**Files:**
|
||
- Modify: `docs/plans/2026-07-24-mtconnect-driver.md` (record the decision inline under this task)
|
||
|
||
The design (§2 "Library verification checklist") flags three research-sourced facts as unverifiable offline. Verify against real NuGet before committing to the dependency:
|
||
|
||
1. `dotnet package search MTConnect.NET-Common --exact-match` (and `-HTTP`) — confirm the pinned version (`6.9.0.2` or then-current) **exists on nuget.org**.
|
||
2. In a throwaway project, `dotnet add package MTConnect.NET-Common -v <pin>` + `dotnet restore` — confirm it **restores** and its TFM set includes `netstandard2.0` (loads on net10).
|
||
3. Open the restored package's embedded `LICENSE` — confirm the *pinned version* is **MIT** (older releases carried mixed MIT/Apache/"all rights reserved").
|
||
|
||
**Decision rule:** all three pass ⇒ TrakHound path (Task 1 adds the two `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)"
|
||
```
|
||
|
||
**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 `PackageReference`s 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:
|
||
|
||
1. **It cannot be exercised without a socket.** Its only constructor takes a *URL* and it dials the
|
||
connection itself inside `Run` — there is no `HttpMessageHandler`, no `HttpClient`, and no `Stream`
|
||
injection point. The whole `IMTConnectAgentClient` seam exists so Tasks 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.
|
||
2. **"Framing alone" was never actually on offer.** The type does not surface raw part payloads — it
|
||
raises `DocumentReceived` with an already-parsed `IDocument`, resolved through the same
|
||
`documentFormat` formatter lookup that Task 6 proved is missing. Using it for framing would still
|
||
have required adding `MTConnect.NET-XML`, i.e. option (b) in disguise.
|
||
3. **The shape is inverted and the payload is heavy.** An event-driven `Start`/`Run` object has to be
|
||
adapted back into the `IAsyncEnumerable<MTConnectStreamsResult>` the seam declares, and `-HTTP`
|
||
vendors an entire embedded web server (`Ceen.*` types) that this driver would never touch.
|
||
|
||
The replacement is `MultipartStreamReader`, a ~200-line private nested class in
|
||
`MTConnectAgentClient.cs` that frames `multipart/x-mixed-replace` off the response stream. Two paths:
|
||
`Content-length` present (every agent in the wild writes it) → read by length and emit the chunk the
|
||
instant it completes; absent → scan to the next boundary, which necessarily emits one chunk late and
|
||
exists as a correctness fallback only. Both paths are covered by tests. Every read is bounded by a
|
||
heartbeat watchdog (`2 × HeartbeatMs + RequestTimeoutMs`) so a silent agent faults instead of wedging
|
||
the pump — the R2-01 shape — and the buffer is capped at 32 MiB.
|
||
|
||
**Also settled here: the streaming leg gets its own `HttpClient`.** `HttpClient.Timeout` is a
|
||
per-instance whole-response deadline, so the long poll cannot share the unary client. Raising the one
|
||
timeout would have un-bounded `/probe` + `/current`, which R2-01 forbids; instead `_streamHttp` runs
|
||
`Timeout.InfiniteTimeSpan` and is bounded by the request deadline on its *header* phase plus the
|
||
watchdog on its body.
|
||
|
||
**Process lesson:** "the dependency restores" is not "the dependency does the job." A library
|
||
verification checklist needs one end-to-end call against a real input, not just a restore. Task 7 adds
|
||
a second: **check the constructor for a test seam before counting a library as usable** — a type that
|
||
can only be handed a URL cannot participate in a socket-free unit suite, however good its internals.
|
||
|
||
---
|
||
|
||
## Task 1: Scaffold the two driver projects + the test project
|
||
|
||
**Classification:** small
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** none (every later task builds on these projects)
|
||
**Files:**
|
||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts.csproj`
|
||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.csproj`
|
||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests.csproj`
|
||
- Modify: `ZB.MOM.WW.OtOpcUa.slnx` (add the three `<Project Path=...>` lines)
|
||
|
||
Steps:
|
||
- Copy the `.Contracts` csproj boilerplate from `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/…csproj`: `net10.0`, `Nullable=enable`, `ImplicitUsings=enable`, `TreatWarningsAsErrors=true`. `.Contracts` references **only** `Core.Abstractions` (no backend NuGet). Add `GenerateDocumentationFile=true` (matches the fleet).
|
||
- The runtime `Driver.MTConnect.csproj` references `Core`, `Core.Abstractions`, `.Contracts`, and — **per the Task 0 decision** — either the two TrakHound packages or nothing extra (hand-roll uses only the BCL). Add `InternalsVisibleTo` the Tests project (mirror Modbus).
|
||
- The `.Tests` csproj: xUnit + Shouldly (copy `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/…csproj` package block), ProjectReferences to `Driver.MTConnect` + `.Contracts`. Mark the future `Fixtures/*.xml` as `CopyToOutputDirectory` (`<Content Include="Fixtures\**\*.xml"><CopyToOutputDirectory>PreserveNewest…`).
|
||
- Add all three to `ZB.MOM.WW.OtOpcUa.slnx` beside the Modbus entries (lines ~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<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):
|
||
|
||
```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("<the CONDITION dataItem id from the fixture>");
|
||
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 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.
|
||
|
||
1. **`SampleAsync` never ends normally except by cancellation.** The seam documents the stream as
|
||
yielding "indefinitely, until `ct` is 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` (with `MTConnectStreamEndReason.ConnectionClosed` /
|
||
`ClosingBoundary` — transient, reconnect) or `MTConnectStreamNotSupportedException` (configuration —
|
||
reconnecting reproduces it forever), sharing the base `MTConnectStreamException`. The non-multipart
|
||
body is still parsed first, so an Agent's own `MTConnectError` text still wins, but the document is
|
||
**not** yielded.
|
||
2. **`IsSequenceGap` moved to `IMTConnectAgentClient` (static) and its first parameter is now
|
||
`expectedFrom`.** The sketch's `requestedFrom` name and doc were wrong for every chunk after the
|
||
first: the correct comparand is the **previous chunk's `NextSequence`**, and comparing against the
|
||
opening `from` reports a gap on every chunk once the ring buffer rolls — an endless `/current`
|
||
re-baseline storm against a healthy stream. Rehomed onto the seam so Task 11 need not reference the
|
||
concrete client. **Task 11 must also treat an `OUT_OF_RANGE` `InvalidDataException` as a re-baseline
|
||
trigger:** cppagent answers a `from` below `firstSequence` with an `MTConnectError` under HTTP 200,
|
||
so ring-buffer overflow arrives as a *parse failure*, never as a gap-bearing chunk. `IsSequenceGap`
|
||
alone does not cover it. Documented on the seam.
|
||
3. **`MTConnectObservation` gained `IsStructured`** (default `false`), set from `element.HasElements`.
|
||
A DATA_SET/TABLE observation's `<Entry key=…>` children concatenate through `element.Value` to
|
||
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-bearing `Message` — 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)` 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("<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();
|
||
}
|
||
```
|
||
|
||
```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<MTConnectDriverOptions, IMTConnectAgentClient>? agentClientFactory = null, ILogger<MTConnectDriver>? logger = null)` — **connection-free**. `agentClientFactory` defaults to the real `MTConnectAgentClient`; tests inject a canned-XML fake implementing `IMTConnectAgentClient`. Implement:
|
||
- `InitializeAsync`: build the client, run one `/probe` under the deadline (cache `IDevice[]` model + `Header.instanceId`), prime the index with one `/current`; `DriverState.Healthy` on success, rethrow → Faulted on failure.
|
||
- `ReinitializeAsync`: stop the sample stream, re-Initialize (config-only unless `AgentUri`/`DeviceName` changed).
|
||
- `ShutdownAsync`: stop stream, dispose client.
|
||
- `GetHealth`: `DriverHealth(State, LastSuccessfulRead, LastError)` — `LastSuccessfulRead` = last `/current`|`/sample` chunk time.
|
||
- `GetMemoryFootprint` / `FlushOptionalCachesAsync`: footprint ≈ probe model + index; flush drops the probe/browse cache, keeps the index.
|
||
|
||
TDD with a canned-XML fake client:
|
||
|
||
```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[] { "<good id>", "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<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
|
||
}
|
||
```
|
||
|
||
```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: <Infer(...).DataType>, IsArray: rep==TIME_SERIES, ArrayDim: <declared sampleCount or null>, SecurityClass: SecurityClassification.ViewOnly, IsHistorized: false, IsAlarm: category=="CONDITION")`.
|
||
- If `DeviceName` is set, stream only that device's subtree.
|
||
|
||
**Critical:** `FullName == dataItem.Id` — the value the universal browser commits as `TagConfig.FullName` and the key read/subscribe resolve against, aligned by construction.
|
||
|
||
TDD with a capturing builder (a test double, or reuse Wave-0's `CapturingAddressSpaceBuilder` if referenceable):
|
||
|
||
```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("<CONDITION dataItem id>");
|
||
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[] { "<good id>" }, 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<MTConnectDriver>())`. Factory `JsonOptions` mirror Modbus (`PropertyNameCaseInsensitive`, `ReadCommentHandling=Skip`, `AllowTrailingCommas`, **no enum converter** — enum-carrying DTO fields stay `string?` + go through `ParseEnum<T>`).
|
||
|
||
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<InvalidOperationException>(() => 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<IDriverProbe, MTConnectProbe>());` in `AddOtOpcUaDriverProbes` (so the probe reaches admin-only nodes — the admin-pinned Test-Connect singleton; `TryAddEnumerable` prevents the fused-node double-register that would make `ToDictionary(p=>p.DriverType)` throw).
|
||
3. Add the `Driver.MTConnect` ProjectReference to the guard-test project — **without it the new assembly isn't in bin, the guard doesn't discover the factory, and the new constant fails `Every_constant_matches_a_registered_factory`.**
|
||
|
||
Verify (the guard test is the gate here):
|
||
|
||
```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/<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:
|
||
|
||
```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/<AdminUI test project> --filter "FullyQualifiedName~MTConnectTagConfigModelTests" # RED, then GREEN
|
||
git add -A && git commit -m "feat(mtconnect): typed AdminUI tag-config model (Task 17)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 18: AdminUI editor razor + map/validator registration
|
||
|
||
**Classification:** small
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** none
|
||
**Files:**
|
||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/MTConnectTagConfigEditor.razor`
|
||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs`
|
||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs`
|
||
|
||
Thin razor shell over `MTConnectTagConfigModel` (mirror `ModbusTagConfigEditor.razor`): `FullName` text, read-only `mtCategory`/`mtType`, a `DataType` override `<select>`. Because most tags arrive via the browse picker (which fills the model), the editor is mostly a confirm/override surface. Register:
|
||
- `[DriverTypeNames.MTConnect] = typeof(Components.Shared.Uns.TagEditors.MTConnectTagConfigEditor)` in `TagConfigEditorMap`.
|
||
- `[DriverTypeNames.MTConnect] = j => MTConnectTagConfigModel.FromJson(j).Validate()` in `TagConfigValidator`.
|
||
|
||
(No AdminUI `IDriverBrowser` DI line — browse is the universal browser, already registered.)
|
||
|
||
Verify (AdminUI has no bUnit — Razor binding is validated live in Task 21; here just compile):
|
||
|
||
```bash
|
||
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:
|
||
|
||
```bash
|
||
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):
|
||
|
||
```bash
|
||
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests # all tests SKIP when the env var/endpoint is absent
|
||
git add -A && git commit -m "test(mtconnect): env-gated cppagent integration suite (Task 20)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 21: Live `/run` verify on docker-dev — browse picker, editor, read, subscribe, deploy
|
||
|
||
**Classification:** high-risk (live; Razor + deploy-inertness bugs pass unit tests)
|
||
**Estimated implement time:** ~5 min (driving; excludes fixture spin-up)
|
||
**Parallelizable with:** none
|
||
**Files:**
|
||
- Modify: `docs/plans/2026-07-24-mtconnect-driver.md` (append a LIVE-GATE RESULT note)
|
||
|
||
> **DEPENDENCY:** this task's browse-picker leg is gated on the **Wave-0 universal-browser live gate (Gitea #468)** being closed — the picker's Browse button, `DiscoveryDriverBrowser.CanBrowse`, and `CapturedTreeBrowseSession` are Wave-0 machinery this driver only opts into via `SupportsOnlineDiscovery=true`. If #468 is still open, run the read/subscribe/deploy legs and record the browse leg as blocked-on-#468.
|
||
|
||
Bring the cppagent fixture up (`lmxopcua-fix up mtconnect`), author an MTConnect driver on docker-dev (`http://localhost:9200`, auto-authenticated admin — no login), and verify **in the running AdminUI + against `opc.tcp://localhost:4840`** (per the live-verify discipline — Razor binding bugs pass unit tests):
|
||
1. **Test Connect** on the MTConnect driver goes green (probe reaches the fixture).
|
||
2. **Browse picker** (`/uns` TagModal → Browse): the Device→Component→DataItem tree renders; a picked leaf commits `TagConfig.FullName = <dataItemId>` (Wave-0 dependency).
|
||
3. **Typed editor** shows `FullName`/read-only `mtCategory`/`DataType` override and round-trips.
|
||
4. **Deploy** the config; via Client.CLI read a picked node (`dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- read -u opc.tcp://localhost:4840 -n "ns=…;s=…"`) — value present, `UNAVAILABLE` items render `BadNoCommunication`.
|
||
5. **Subscribe** (`... subscribe ...`) — live updates flow from the `/sample` stream.
|
||
|
||
```bash
|
||
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) — 4 of 5 legs PASSED; the deploy leg is BLOCKED on a rig artifact, not a defect
|
||
|
||
**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 | ⛔ **BLOCKED — rig artifact** | see below |
|
||
|
||
**Why leg 5 is blocked, and why it is not a product defect.** 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.
|
||
|
||
**To finish leg 5**, do any one of: (a) use a distinct host so cookies don't collide (a hosts entry,
|
||
or bind `127.0.0.2`), (b) drive `:9220` from an incognito/separate browser profile, or (c) stop the
|
||
`otopcua-dev` rig first. Then Deploy and:
|
||
`dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- read -u opc.tcp://localhost:4860 -n "ns=2;s=docker-dev/mtc-1/vmc/fixture_asset_changed"`.
|
||
Note the RawPath keying is already proven at the driver seam by the live integration test above;
|
||
what leg 5 adds is the `DeploymentArtifact` → address-space → OPC UA materialisation on top.
|
||
|
||
**Incidental findings from the rig work:**
|
||
1. ~~`ClusterNode` has no `MaintenanceMode` column, so CLAUDE.md is ahead of the migration.~~
|
||
**CORRECTED — this was my error, not a repo inconsistency.** The migration
|
||
`20260722125506_AddClusterNodeMaintenanceMode` exists on master, along with `AkkaPort`/`GrpcPort`.
|
||
**This branch is 39 commits behind master** (base `963eec1b`, master `28c28667`) 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. **Rebase onto master before
|
||
merging** — and once rebased, `MaintenanceMode = 1` on the site nodes is the correct, documented
|
||
hatch for a partial-topology live gate (it is what the SQL-poll driver's gate used), in place of
|
||
the row deletion I resorted to.
|
||
2. `sqlcmd` against this schema needs `-I` (`SET QUOTED_IDENTIFIER ON`); without it every DML
|
||
statement fails with `Msg 1934` because of the filtered/computed indexes.
|
||
3. **Status-code parity is pre-verified against master's new guard.** Master gained
|
||
`StatusCodeParityTests`, which reflects over every `ZB.MOM.WW.OtOpcUa.Driver.*.dll` in its bin for
|
||
status-shaped `const uint` fields — including `private const` on `internal` types — and checks each
|
||
against `Opc.Ua.StatusCodes`. Task 16 added the `Driver.MTConnect` ProjectReference 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.
|
||
4. **The separately-tracked `BadTypeMismatch` defect 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 correct `0x80740000` and pinned it with a mutation test, so the two agree; no action.
|
||
|
||
**Teardown when done:**
|
||
```bash
|
||
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.
|
||
|
||
```bash
|
||
git add -A && git commit -m "docs(mtconnect): P1 driver guide + tracking-doc update, defer write-back (Task 22)"
|
||
```
|
||
|
||
---
|
||
|
||
## Deferred (out of P1 — pointers, not scope)
|
||
|
||
- **Write-back (MTConnect Interfaces)** — design §3.6: read-only Agent surface; revisit only on a concrete deployment need.
|
||
- **CONDITION → native Part-9 alarms** (`IAlarmSource`), **`TIME_SERIES` SAMPLE arrays** materialized as OPC UA arrays, **EVENT controlled-vocab → OPC UA enumerations** — design §9 P1.5 fast-follow.
|
||
- **SHDR adapter ingest** (`SourceMode: "Agent" | "Shdr"`, loses auto-discovery) — design §9 P2.
|