docs(opcuaclient): plan — register OpcUaClient driver factory (3 tasks)
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
# OpcUaClient Driver Factory — Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Register the OpcUaClient driver factory so `DriverType="OpcUaClient"` instances are constructed (not stubbed) — fixing a real bug and yielding the first end-to-end live equipment-tag value (live-proving the `FullName→NodeId` router shipped in `c4435e4f`).
|
||||
|
||||
**Architecture:** Mirror `ModbusDriverFactoryExtensions`: a new `OpcUaClientDriverFactoryExtensions` (`Register` + `CreateInstance` that deserialises `OpcUaClientDriverOptions` exactly as the existing probe does) plus one registration line in `DriverFactoryBootstrap.Register`. OpcUaClient is cross-platform, so once a factory exists it runs as a real driver in docker-dev.
|
||||
|
||||
**Tech Stack:** .NET 10, System.Text.Json, OPC UA Foundation stack, xUnit + Shouldly. Design: `docs/plans/2026-06-13-opcuaclient-factory-design.md` (master `5aa1030b`).
|
||||
|
||||
**Branch:** `feat/opcuaclient-factory` off master `5aa1030b`.
|
||||
|
||||
---
|
||||
|
||||
## Hard rules (every task)
|
||||
- Stage by path; never `git add .`. Never stage `sql_login.txt`, `src/Server/ZB.MOM.WW.OtOpcUa.Host/pki/`, `pending.md`, or `current.md`.
|
||||
- Never echo secrets. No force-push, no `--no-verify`. No Configuration/EF migration change.
|
||||
|
||||
## Task order
|
||||
T0 branch → T1 factory + register + unit test → T2 verify (build + test + live `/run`).
|
||||
|
||||
---
|
||||
|
||||
### Task 0: Create feature branch
|
||||
|
||||
**Classification:** trivial
|
||||
**Estimated implement time:** ~1 min
|
||||
**Parallelizable with:** none
|
||||
|
||||
**Files:** git only
|
||||
|
||||
```bash
|
||||
git checkout master && git rev-parse --short HEAD # expect 5aa1030b
|
||||
git checkout -b feat/opcuaclient-factory
|
||||
```
|
||||
Confirm clean tree (ignore untracked `pending.md` / `current.md` / `sql_login.txt` / `pki/`).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `OpcUaClientDriverFactoryExtensions` + register it + unit test
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverFactoryExtensions.cs`
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs` (the `Register` method, ~lines 100-106)
|
||||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientDriverFactoryTests.cs`
|
||||
|
||||
**Context:** Read `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverFactoryExtensions.cs` as the template (its `Register` + `CreateInstance` + `JsonOptions`), and `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverProbe.cs` (it already deserialises the config). Confirm the `OpcUaClientDriver` ctor: `OpcUaClientDriver(OpcUaClientDriverOptions options, string driverInstanceId, ILogger<OpcUaClientDriver>? logger = null)`. Look at `GalaxyDriverFactoryTests.cs` / the Modbus factory tests for the test conventions (xUnit v3 + Shouldly).
|
||||
|
||||
**Step 1: Write the failing test** (`OpcUaClientDriverFactoryTests.cs`)
|
||||
|
||||
```csharp
|
||||
using Shouldly;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
public class OpcUaClientDriverFactoryTests
|
||||
{
|
||||
private const string SampleConfig =
|
||||
"""{"EndpointUrl":"opc.tcp://host:4840","SecurityMode":"None","AutoAcceptCertificates":true}""";
|
||||
|
||||
[Fact]
|
||||
public void CreateInstance_builds_an_OpcUaClient_driver_with_the_right_identity()
|
||||
{
|
||||
var driver = OpcUaClientDriverFactoryExtensions.CreateInstance("drv-1", SampleConfig, NullLoggerFactory.Instance);
|
||||
driver.DriverType.ShouldBe("OpcUaClient");
|
||||
driver.DriverInstanceId.ShouldBe("drv-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DriverTypeName_is_OpcUaClient()
|
||||
=> OpcUaClientDriverFactoryExtensions.DriverTypeName.ShouldBe("OpcUaClient");
|
||||
|
||||
[Fact]
|
||||
public void CreateInstance_throws_on_null_json_deserialisation()
|
||||
=> Should.Throw<System.InvalidOperationException>(
|
||||
() => OpcUaClientDriverFactoryExtensions.CreateInstance("drv-1", "null", NullLoggerFactory.Instance));
|
||||
}
|
||||
```
|
||||
(Confirm `OpcUaClientDriver` exposes `DriverType` / `DriverInstanceId` properties — it must, per `IDriver`. Adapt the assertions to the real property names if they differ. The OpcUaClient.Tests project must reference the driver project + `Microsoft.Extensions.Logging.Abstractions` — both almost certainly already present; add if missing.)
|
||||
|
||||
**Step 2: Run — expect FAIL** (`OpcUaClientDriverFactoryExtensions` not defined).
|
||||
`dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests --filter "FullyQualifiedName~OpcUaClientDriverFactory"`
|
||||
|
||||
**Step 3: Implement the factory** (`OpcUaClientDriverFactoryExtensions.cs`)
|
||||
|
||||
```csharp
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||
|
||||
/// <summary>
|
||||
/// Registers the OPC UA Client driver with the <see cref="DriverFactoryRegistry"/>. The driver's
|
||||
/// <c>DriverConfig</c> JSON deserialises directly into <see cref="OpcUaClientDriverOptions"/>
|
||||
/// (the same shape <see cref="OpcUaClientDriverProbe"/> reads), so no separate DTO is needed.
|
||||
/// </summary>
|
||||
public static class OpcUaClientDriverFactoryExtensions
|
||||
{
|
||||
/// <summary>Driver type name — matches <c>DriverInstance.DriverType</c> values.</summary>
|
||||
public const string DriverTypeName = "OpcUaClient";
|
||||
|
||||
// Match OpcUaClientDriverProbe exactly so factory + probe parse a config identically.
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
||||
};
|
||||
|
||||
/// <summary>Register the OpcUaClient factory with the driver registry.</summary>
|
||||
/// <param name="registry">The driver factory registry to register with.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory used to create per-instance loggers.</param>
|
||||
public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory));
|
||||
}
|
||||
|
||||
/// <summary>Public for the Server-side bootstrapper + test consumers.</summary>
|
||||
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
|
||||
/// <param name="driverConfigJson">The JSON configuration string for the driver.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory for the per-instance logger.</param>
|
||||
/// <returns>A configured <see cref="OpcUaClientDriver"/>.</returns>
|
||||
public static OpcUaClientDriver CreateInstance(
|
||||
string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
||||
|
||||
var options = JsonSerializer.Deserialize<OpcUaClientDriverOptions>(driverConfigJson, JsonOptions)
|
||||
?? throw new InvalidOperationException(
|
||||
$"OpcUaClient driver config for '{driverInstanceId}' deserialised to null");
|
||||
|
||||
return new OpcUaClientDriver(options, driverInstanceId, loggerFactory?.CreateLogger<OpcUaClientDriver>());
|
||||
}
|
||||
}
|
||||
```
|
||||
(Confirm the `using` for `OpcUaClientDriverOptions` — it's in `…Driver.OpcUaClient.Contracts`; the driver project already references it. Confirm `DriverFactoryRegistry`'s namespace is `ZB.MOM.WW.OtOpcUa.Core.Hosting` — check the Modbus factory's usings.)
|
||||
|
||||
**Step 4: Register it in the bootstrap** — add to `DriverFactoryBootstrap.Register` (alongside the other lines):
|
||||
```csharp
|
||||
Driver.OpcUaClient.OpcUaClientDriverFactoryExtensions.Register(registry, loggerFactory);
|
||||
```
|
||||
|
||||
**Step 5: Run tests + full build**
|
||||
```bash
|
||||
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests --filter "FullyQualifiedName~OpcUaClientDriverFactory"
|
||||
dotnet build src/Server/ZB.MOM.WW.OtOpcUa.Host
|
||||
```
|
||||
Expected: tests PASS; Host builds 0 errors.
|
||||
|
||||
**Step 6: Commit** (stage by path)
|
||||
```bash
|
||||
git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverFactoryExtensions.cs src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientDriverFactoryTests.cs
|
||||
git commit -m "fix(opcuaclient): register the OpcUaClient driver factory (was always stubbed)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Verify — build, test, agent-driven live `/run` (the payoff)
|
||||
|
||||
**Classification:** verification
|
||||
**Estimated implement time:** ~5 min + live
|
||||
**Parallelizable with:** none
|
||||
**blockedBy:** Task 1
|
||||
|
||||
**Files:** none
|
||||
|
||||
**Step 1:** Full solution build + the touched suites:
|
||||
```bash
|
||||
dotnet build ZB.MOM.WW.OtOpcUa.slnx
|
||||
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests
|
||||
```
|
||||
Expect 0 errors; green.
|
||||
|
||||
**Step 2: Live docker-dev `/run` (agent drives; dev login DISABLED).** The `MAIN-opcua-eq` OpcUaClient driver + `FastUInt1` tag (`{"FullName":"ns=3;s=FastUInt1"}`) are **already authored + deployed** in the dev rig; the opc-plc sim is up at `opc.tcp://10.100.0.35:50000` (start it if down: `ssh 10.100.0.35 'cd /opt/otopcua-opcuaclient && docker compose up -d'`).
|
||||
- Rebuild central on the branch: `docker compose -f docker-dev/docker-compose.yml up -d --build migrator central-1 central-2` (no new migration; the rebuild ships the branch code).
|
||||
- Redeploy: `curl -sS -X POST http://localhost:9200/api/deployments -H "X-Api-Key: docker-dev-deploy-key" -H "Content-Type: application/json" -d '{}'`.
|
||||
- **Confirm the fix:** `docker logs --since 3m otopcua-dev-central-1-1 2>&1 | grep -iE "OpcUaClient|opc-plc|no factory|session|subscrib"` — must **NOT** show "no factory for driver type OpcUaClient"; should show the driver connecting/subscribing.
|
||||
- **Confirm the live value (the router payoff):** retry-read `ns=2;s=EQ-55297329838d/FastUInt1` from `opc.tcp://localhost:4840` via Client.CLI until it shows a Good, changing value (not `BadWaitingForInitialData`):
|
||||
`dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- read -u opc.tcp://localhost:4840 -n "ns=2;s=EQ-55297329838d/FastUInt1"` (read twice ~2s apart — `FastUInt1` increments, so the value should change).
|
||||
|
||||
**Step 3:** On green, finish via `superpowers-extended-cc:finishing-a-development-branch` (intent: merge-to-master + push).
|
||||
|
||||
---
|
||||
|
||||
## Out of scope
|
||||
Protocol-driver (Modbus/S7) equipment-tag↔tag-table linkage; Galaxy gateway; Phase B/C — separate milestones (see `pending.md`).
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-06-13-opcuaclient-factory-plan.md",
|
||||
"tasks": [
|
||||
{"id": 338, "subject": "Task 0: Create feature branch", "status": "pending"},
|
||||
{"id": 339, "subject": "Task 1: OpcUaClientDriverFactoryExtensions + register + unit test", "status": "pending", "blockedBy": [338]},
|
||||
{"id": 340, "subject": "Task 2: Verify — build, test, live /run", "status": "pending", "blockedBy": [339]}
|
||||
],
|
||||
"lastUpdated": "2026-06-13"
|
||||
}
|
||||
Reference in New Issue
Block a user