Files
lmxopcua/docs/plans/2026-06-16-stillpending-phase-4b-driver-gaps.md
T

26 KiB
Raw Blame History

Phase 4b — Mac-verifiable driver gaps Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.

Goal: Close three independent, contract-free driver/AdminUI gaps from stillpending.md §2: Modbus driver-type-string reconcile, Galaxy nested gobject hierarchy, and FOCAS cnc_getfigure axis auto-scale.

Architecture: Three disjoint slices touching different projects (AdminUI + Modbus driver / Galaxy driver / FOCAS driver), so their implementers run concurrently. Each is purely additive or a mechanical reconcile — no EF migration, no Commons wire/proto contract change, no bUnit.

Tech Stack: .NET 10, xUnit + Shouldly, Blazor Server (Razor), Akka.NET driver hosting, gRPC-proto Galaxy contracts, FOCAS FWLIB P/Invoke.

Design doc: docs/plans/2026-06-16-stillpending-phase-4b-driver-gaps-design.md (approved, f90017bc).

Hard rules (every task): stage by explicit path, never git add .; never stage sql_login.txt / src/Server/ZB.MOM.WW.OtOpcUa.Host/pki/ / pending.md / current.md / docker-dev/docker-compose.yml / stillpending.md; no force-push / no --no-verify; NO EF migration; NO Commons wire/proto change; NO bUnit. Branch feat/stillpending-phase-4b-driver-gaps (already created off master c081917a).

Build/test note: the Bash sandbox blocks the 10.100.0.x rig + docker-forwarded localhost ports — run any dotnet build / dotnet test / rig command with dangerouslyDisableSandbox: true.


Task 1: Modbus driver-type-string reconcile (canonicalize on "Modbus")

Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 2, Task 3a

Context: The runtime factory registration key + the driver's own DriverType are "Modbus" (ModbusDriverFactoryExtensions.DriverTypeName, ModbusDriver.DriverType) and the docker-dev seed inserts DriverType="Modbus". But AdminUI + the probe use "ModbusTcp". Effect: rig-seeded Modbus drivers miss the typed editor (TagConfigEditorMap keyed on "ModbusTcp") and fall to the raw-JSON textarea; an AdminUI-created Modbus driver stores "ModbusTcp", which the runtime factory can't instantiate. Canonicalize on "Modbus" — NEVER touch the factory key or ModbusDriver.DriverType; NO migration (rig seed already "Modbus"). The stored/dispatched VALUE becomes "Modbus"; the route slug modbustcp and a friendly DISPLAY label ("Modbus TCP") stay.

Files:

  • Modify: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverProbe.cs:29
  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs:13
  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs:15
  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/DriverEditRouter.razor:55
  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/ModbusDriverPage.razor (the DriverTypeKey const — what AdminUI-created drivers STORE; leave the @page .../modbustcp route untouched)
  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/DriverTypePicker.razor:40 (DisplayName only; keep Slug modbustcp)
  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverIdentitySection.razor:31,76
  • Test: tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagConfigValidatorTests.cs + the 7 sibling Uns/UnsTreeService*Tests.cs files seeding DriverType="ModbusTcp"

Step 1: Flip the tests to expect "Modbus" (TDD red)

In tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/, replace every DriverType = "ModbusTcp" / "ModbusTcp" literal that seeds a Modbus driver or asserts editor/validator dispatch with "Modbus". The 8 files are: TagConfigValidatorTests.cs, UnsTreeServiceImportTests.cs, UnsTreeServiceTagTests.cs, UnsTreeServiceAreaLineTests.cs, UnsTreeServiceTagDriversTests.cs, UnsTreeServiceLoadEditTests.cs, UnsTreeServiceEquipmentTests.cs, UnsTreeServiceDeleteClusterTests.cs. Grep each for ModbusTcp and change the value to Modbus (these are seed/dispatch values, not display strings).

Add one explicit assertion to TagConfigValidatorTests.cs if not already present:

[Fact]
public void Resolve_Modbus_dispatches_typed_editor()
{
    TagConfigEditorMap.Resolve("Modbus").ShouldBe(typeof(ModbusTagConfigEditor));
}

Step 2: Run — verify RED

Run: dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests --filter "FullyQualifiedName~TagConfigValidator" --nologo (dangerouslyDisableSandbox: true) — expect failures (editor map still keyed "ModbusTcp").

Step 3: Canonicalize the source on "Modbus"

  • ModbusDriverProbe.cs:29: public string DriverType => "ModbusTcp";=> "Modbus";
  • TagConfigEditorMap.cs:13: ["ModbusTcp"]["Modbus"]
  • TagConfigValidator.cs:15: ["ModbusTcp"]["Modbus"]
  • DriverEditRouter.razor:55: ["ModbusTcp"] = typeof(ModbusDriverPage),["Modbus"] = typeof(ModbusDriverPage),
  • ModbusDriverPage.razor: the DriverTypeKey const (used at _identityModel = new() { DriverType = DriverTypeKey } and the create path DriverType = DriverTypeKey) → "Modbus". Do NOT change @page "/clusters/{ClusterId}/drivers/new/modbustcp" (route slug stays).
  • DriverTypePicker.razor:40: new DriverTypeEntry("ModbusTcp", "modbustcp", "[M]", "...") → change the DisplayName (1st field) to "Modbus TCP"; keep the Slug "modbustcp" (it matches the @page route).
  • DriverIdentitySection.razor:31: <option value="ModbusTcp">ModbusTcp</option><option value="Modbus">Modbus TCP</option>
  • DriverIdentitySection.razor:76: public string DriverType { get; set; } = "ModbusTcp";= "Modbus";

Step 4: Run — verify GREEN + no AdminUI.Tests regression

Run: dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests --nologo (dangerouslyDisableSandbox: true) Expected: all green. Sanity-grep that no SOURCE "ModbusTcp" remains except the route slug: grep -rn "ModbusTcp" src/ | grep -v bin/ | grep -v obj/ should be empty (the slug is lowercase modbustcp).

Step 5: Commit

git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverProbe.cs \
        src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs \
        src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs \
        src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/DriverEditRouter.razor \
        src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/ModbusDriverPage.razor \
        src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/DriverTypePicker.razor \
        src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverIdentitySection.razor \
        tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/
git commit -m "fix(adminui): canonicalize Modbus driver-type string on \"Modbus\" (was ModbusTcp)"

Acceptance: stored/dispatched value is "Modbus" everywhere; route slug modbustcp + display "Modbus TCP" preserved; factory key + ModbusDriver.DriverType untouched; AdminUI.Tests green.


Task 2: Galaxy nested gobject hierarchy

Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 1, Task 3a

Context: GalaxyDiscoverer.DiscoverAsync calls builder.Folder(...) at root level per gobject (flat). The proto GalaxyObject already carries gobject_id (1), parent_gobject_id (5), is_area (6), hosted_by_gobject_id (8) — no gateway change needed. IAddressSpaceBuilder.Folder(...) returns IAddressSpaceBuilder, so folders nest natively. Rewrite as a two-pass build that nests by parent_gobject_id, degrading to flat when parentage is absent.

Files:

  • Modify: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/GalaxyDiscoverer.cs
  • Test: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Browse/GalaxyDiscovererTests.cs

Step 1: Write failing tests

Reuse the file's existing FakeHierarchySource + FakeBuilder (records Folder/Variable/ MarkAsAlarmCondition calls; the ChildBuilder already tracks the parent folder per call — extend FolderCall/ChildBuilder to also record the PARENT folder browse-name so nesting is assertable). Add a GalaxyObject builder helper setting GobjectId/ParentGobjectId/ContainedName/TagName. Tests:

[Fact] public async Task Nests_child_folder_under_its_parent()
// parent gobject_id=1 (parent=0), child gobject_id=2 (parent=1)
//  -> folder "child" is created under folder "parent" (not at root)

[Fact] public async Task Is_order_independent_child_before_parent()
// same two objects but child returned FIRST in the list -> still nests under parent

[Fact] public async Task Degrades_to_flat_when_parent_is_zero()
// both objects parent_gobject_id=0 -> both at root (current behaviour)

[Fact] public async Task Degrades_to_flat_when_parent_not_in_set()
// child parent_gobject_id=99 (not present) -> child attaches to root

[Fact] public async Task Self_parent_attaches_to_root()
// gobject_id=5 parent_gobject_id=5 -> root (defensive)

[Fact] public async Task Variables_land_in_their_owner_folder()
// a child object's attributes are added into the child folder, not the parent

Keep/verify the existing alarm-ref + []-suffix-strip tests still pass unchanged.

Step 2: Run — verify RED (FolderCall lacks a parent; nesting not implemented): dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests --filter "FullyQualifiedName~GalaxyDiscoverer" --nologo (dangerouslyDisableSandbox: true)

Step 3: Rewrite DiscoverAsync as a two-pass build

public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
{
    ArgumentNullException.ThrowIfNull(builder);
    var objects = await _source.GetHierarchyAsync(cancellationToken).ConfigureAwait(false);

    // Pass 1 — index gobjects with a usable browse identity by gobject_id.
    var byId = new Dictionary<int, GalaxyObject>();
    foreach (var obj in objects)
    {
        var browseName = string.IsNullOrEmpty(obj.ContainedName) ? obj.TagName : obj.ContainedName;
        if (string.IsNullOrEmpty(browseName)) continue;       // skip objects with no identity
        byId[obj.GobjectId] = obj;
    }

    // Pass 2 — create each gobject's folder under its parent's folder (memoised), then variables.
    var folders = new Dictionary<int, IAddressSpaceBuilder>();
    foreach (var obj in byId.Values)
    {
        var folder = EnsureFolder(obj, builder, byId, folders);
        foreach (var attr in obj.Attributes)
        {
            if (string.IsNullOrEmpty(attr.AttributeName)) continue;
            var fullReference = !string.IsNullOrEmpty(attr.FullTagReference)
                ? StripArraySuffix(attr.FullTagReference)
                : obj.TagName + "." + attr.AttributeName;
            var info = new DriverAttributeInfo(
                FullName: fullReference,
                DriverDataType: DataTypeMap.Map(attr.MxDataType),
                IsArray: attr.IsArray,
                ArrayDim: attr.IsArray && attr.ArrayDimensionPresent && attr.ArrayDimension > 0
                    ? (uint)attr.ArrayDimension : null,
                SecurityClass: SecurityMap.Map(attr.SecurityClassification),
                IsHistorized: attr.IsHistorized,
                IsAlarm: attr.IsAlarm);
            var handle = folder.Variable(attr.AttributeName, attr.AttributeName, info);
            if (attr.IsAlarm)
                handle.MarkAsAlarmCondition(AlarmRefBuilder.Build(fullReference));
        }
    }
}

// Memoised folder creation: places each gobject's folder under its parent's folder when the parent
// is a known in-set gobject (and not self), else at the driver root (flat fallback). Recursion is
// bounded by `building` (cycle guard) + the finite gobject set.
private static IAddressSpaceBuilder EnsureFolder(
    GalaxyObject obj, IAddressSpaceBuilder root,
    IReadOnlyDictionary<int, GalaxyObject> byId,
    Dictionary<int, IAddressSpaceBuilder> folders,
    HashSet<int>? building = null)
{
    if (folders.TryGetValue(obj.GobjectId, out var existing)) return existing;

    var browseName = string.IsNullOrEmpty(obj.ContainedName) ? obj.TagName : obj.ContainedName;
    building ??= new HashSet<int>();

    IAddressSpaceBuilder parentBuilder = root;
    if (obj.ParentGobjectId != 0
        && obj.ParentGobjectId != obj.GobjectId            // self-parent guard
        && !building.Contains(obj.ParentGobjectId)          // cycle guard
        && byId.TryGetValue(obj.ParentGobjectId, out var parentObj))
    {
        building.Add(obj.GobjectId);
        parentBuilder = EnsureFolder(parentObj, root, byId, folders, building);
        building.Remove(obj.GobjectId);
    }

    var folder = parentBuilder.Folder(browseName, browseName);
    folders[obj.GobjectId] = folder;
    return folder;
}

Keep StripArraySuffix unchanged. Update the class <remarks> to describe the nested-by-parent_gobject_id layout (degrade-to-flat) instead of the old "rendered flat" note.

Step 4: Run — verify GREEN (new + pre-existing Galaxy discoverer tests): dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests --filter "FullyQualifiedName~GalaxyDiscoverer" --nologo (dangerouslyDisableSandbox: true)

Step 5: Commit

git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/GalaxyDiscoverer.cs \
        tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Browse/GalaxyDiscovererTests.cs
git commit -m "feat(galaxy): nest gobject browse tree by parent_gobject_id (degrade-to-flat)"

Acceptance: child folders nest under parents; order-independent; degrades to flat for parent=0/unknown-parent/self-parent; variables land in the owner folder; no gateway/proto change.


Task 3a: IFocasClient.GetPositionFiguresAsync binding (cnc_getfigure)

Classification: standard Estimated implement time: ~4 min Parallelizable with: Task 1, Task 2

Context: Add a per-axis decimal-place figure read to the FOCAS client seam. The Wire client P/Invokes cnc_getfigure; Fake + Unimplemented clients return an empty/unsupported result. This is a DRIVER-INTERNAL interface (IFocasClient), not a Commons wire contract.

Files:

  • Modify: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs (interface method + UnimplementedFocasClientFactory's client if it has one; if the unimplemented path throws at factory EnsureUsable, no client impl is needed)
  • Modify: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/WireFocasClient.cs (P/Invoke cnc_getfigure + the new method)
  • Modify: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs (virtual stub returning a settable list, default empty)
  • Test: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/ (add a small FocasGetFigureTests.cs for the Wire-method contract IF a pure-managed seam exists; otherwise the binding is exercised through Task 3b's driver tests via FakeFocasClient)

Step 1: Add the interface method

In IFocasClient.cs, add (mirroring the existing GetAxisNamesAsync doc style):

/// <summary>
///     Read the per-axis position decimal-place figure via <c>cnc_getfigure</c>. The returned
///     list is parallel to <see cref="GetAxisNamesAsync"/> (index = axis). An EMPTY list means the
///     CNC/backend does not report figures — the driver then falls back to the configured
///     <c>PositionDecimalPlaces</c>. Values are clamped non-negative by the caller.
/// </summary>
Task<IReadOnlyList<int>> GetPositionFiguresAsync(CancellationToken cancellationToken);

Step 2: Implement on WireFocasClient — add the [DllImport("fwlib32", ...)] cnc_getfigure P/Invoke (signature: short cnc_getfigure(ushort handle, short type, short* number, short* data); type=0 selects position figures; number is in/out axis count, data receives per-axis figures). On a non-EW_OK return or no handle, return Array.Empty<int>() (degrade — never throw from a figure read). Clamp each figure to >= 0.

Step 3: Stub FakeFocasClient — add a settable IReadOnlyList<int> PositionFigures { get; set; } = []; and public virtual Task<IReadOnlyList<int>> GetPositionFiguresAsync(CancellationToken ct) => Task.FromResult(PositionFigures);

Step 4: Build — verify the interface compiles across all impls

Run: dotnet build src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS --nologo (dangerouslyDisableSandbox: true) Expected: 0 errors (Wire + any other IFocasClient impl satisfy the new member).

Step 5: Commit

git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs \
        src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/WireFocasClient.cs \
        tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs
git commit -m "feat(focas): add cnc_getfigure per-axis position-figure client binding"

Acceptance: IFocasClient.GetPositionFiguresAsync exists; Wire P/Invokes cnc_getfigure and degrades to empty on error; Fake returns a settable list; the driver project compiles.


Task 3b: FOCAS driver auto-scale wiring (auto wins, manual fallback)

Classification: standard Estimated implement time: ~5 min Parallelizable with: none Blocked by: Task 3a

Context: At device init the driver already reads GetSysInfoAsync + GetAxisNamesAsync (FocasDriver.cs:655-656). Fetch the per-axis figures there, cache them on DeviceState, and have PublishAxisSnapshot (FocasDriver.cs:~820-834) divide each axis by 10^(effective figure). Precedence (approved): auto wins; manual PositionDecimalPlaces is the per-axis fallback used only when cnc_getfigure returns nothing for that axis.

Files:

  • Modify: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs (init fetch + DeviceState cache field + per-axis factor in PublishAxisSnapshot)
  • Test: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/ (add FocasPositionAutoScaleTests.cs)

Step 1: Write failing tests (fake IFocasClient with settable PositionFigures):

[Fact] public async Task Auto_figure_wins_over_manual_config()
// PositionFigures=[3], PositionDecimalPlaces config=1, snap.AbsolutePosition=12345
//  -> published Absolute == 12.345 (÷10^3), NOT 1234.5

[Fact] public async Task Falls_back_to_manual_when_getfigure_empty()
// PositionFigures=[], PositionDecimalPlaces=2, snap=12345 -> 123.45

[Fact] public async Task Per_axis_figures_scale_independently()
// two axes, PositionFigures=[3,1] -> axis0 ÷1000, axis1 ÷10

[Fact] public async Task Manual_only_legacy_path_unchanged()
// PositionFigures=[], PositionDecimalPlaces=0 -> factor 1.0, value byte-identical to integer widened

Drive these through the device init + a single poll/PublishAxisSnapshot, asserting LastFixedSnapshots[".../AbsolutePosition"]. Model the harness on the existing FocasDriverMediumFindingsTests / FocasCapabilityTests device-boot helpers.

Step 2: Run — verify RED: dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests --filter "FullyQualifiedName~AutoScale" --nologo (dangerouslyDisableSandbox: true)

Step 3: Implement

  • Add to DeviceState (FocasDriver.cs:1139): public IReadOnlyList<int> PositionFigures { get; set; } = [];
  • At init (right after GetAxisNamesAsync, ~:656): state.PositionFigures = await client.GetPositionFiguresAsync(ct).ConfigureAwait(false);
  • Add a per-axis effective-figure helper and use it in PublishAxisSnapshot (replace the single factor):
// Auto (cnc_getfigure) wins per-axis; manual PositionDecimalPlaces is the fallback when the CNC
// didn't report a figure for that axis index. Both clamp non-negative; 0 ⇒ factor 1.0 (legacy).
private static double AxisFactor(DeviceState state, int axisIndex)
{
    var figures = state.PositionFigures;
    var dp = axisIndex >= 0 && axisIndex < figures.Count && figures[axisIndex] >= 0
        ? figures[axisIndex]
        : Math.Max(0, state.Options.PositionDecimalPlaces);
    return dp > 0 ? Math.Pow(10, dp) : 1.0;
}

PublishAxisSnapshot takes the axis index (it already iterates axes — thread the index through; the caller loops GetAxisNamesAsync results so the index is available) and uses var factor = AxisFactor(state, axisIndex);. Update the scaling comment to note auto-vs-manual precedence.

Step 4: Run — verify GREEN + full FOCAS suite: dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests --nologo (dangerouslyDisableSandbox: true)

Step 5: Commit

git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs \
        tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPositionAutoScaleTests.cs
git commit -m "feat(focas): auto-scale axis positions from cnc_getfigure (manual config = fallback)"

Acceptance: auto figure wins per-axis; manual PositionDecimalPlaces used only when the CNC reports nothing; per-axis independent; the manual-only/zero legacy path is byte-identical.


Task 4: Docs + bookkeeping

Classification: small Estimated implement time: ~4 min Parallelizable with: none Blocked by: Task 1, Task 2, Task 3b

Files:

  • Modify: docs/drivers/TestConnectProbes.md (if it names the Modbus type string) and/or the Modbus driver doc — note the canonical "Modbus" driver-type string (AdminUI display "Modbus TCP").
  • Modify: the Galaxy driver doc (e.g. docs/ Galaxy section) — browse tree now nests by parent_gobject_id.
  • Modify: the FOCAS driver doc — axis positions auto-scale via cnc_getfigure; PositionDecimalPlaces is now the fallback.
  • (Do NOT stage stillpending.md — the §2 lines are marked resolved only via this plan record.)

Steps: Update the three driver docs to match the shipped behaviour (13 sentences each). Grep docs/ for any stale "rendered flat" Galaxy note or "ModbusTcp" mention and correct it.

Commit:

git add docs/   # by explicit path of the files you touched — never docs/ wholesale if it sweeps untracked files; prefer naming each .md
git commit -m "docs(phase4b): Modbus driver-type canonical + Galaxy nesting + FOCAS auto-scale"

Acceptance: docs describe the shipped behaviour; stillpending.md untouched.


Task 5: Full build + test + final integration review

Classification: standard (verification) Estimated implement time: ~5 min (+ build/test time) Parallelizable with: none Blocked by: Task 1, Task 2, Task 3a, Task 3b, Task 4

Steps:

  1. dotnet build ZB.MOM.WW.OtOpcUa.slnx --nologo (dangerouslyDisableSandbox: true) — 0 errors.
  2. Run the three affected suites green (dangerouslyDisableSandbox: true):
    • dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests --nologo
    • dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests --nologo
    • dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests --nologo
    • dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests --nologo
  3. Dispatch a final integration reviewer over git diff c081917a..HEAD — focus: no stray source "ModbusTcp" (slug modbustcp excepted); the Galaxy two-pass build is order-independent + degrade-safe; the FOCAS precedence is auto-wins/manual-fallback and the zero/legacy path is byte-identical; no EF/Commons/proto contract change; no never-stage file touched.

Acceptance: build clean; four suites green; integration review = SHIP (fix any Critical/Important then re-review).


Task 6: Live /run verification

Classification: standard (verification — no subagent review) Estimated implement time: ~5 min (+ rig build time) Parallelizable with: none Blocked by: Task 5

Steps (run from repo root on this Mac; dangerouslyDisableSandbox: true for all):

  1. Rebuild central-1 from the branch + recreate: docker compose -f docker-dev/docker-compose.yml build central-1 then docker compose -f docker-dev/docker-compose.yml up -d --force-recreate central-1 traefik.
  2. Modbus reconcile (live): open the /uns Tag modal for a tag on the rig's seeded MAIN-modbus-eq (DriverType="Modbus") at http://localhost:9200 (login disabled) via the Chrome tools → confirm the typed Modbus editor + "Build address" button render (NOT the raw-JSON textarea) — the exact gap Phase 6 couldn't drive. Also confirm /clusters/.../drivers lists the Modbus driver and its config page opens (DriverEditRouter dispatch on "Modbus").
  3. Galaxy nesting (live): ensure a Galaxy driver is configured against the gateway on 10.100.0.48:5120 (source GALAXY_MXGW_API_KEY as in project_galaxy_standard_driver — never echo it), deploy, then dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- browse -u opc.tcp://localhost:4840 -r -d 5 → confirm the Galaxy gobject tree browses nested (child folders under parent gobjects), not flat. If the gateway populates parent_gobject_id=0 for all (flat source data), note the degrade-to-flat path held and the nesting is unit-proven.
  4. FOCAS: unit-proven only (no CNC) — state this honestly; do not fabricate a live read.

Acceptance: Modbus typed editor + Build-address reachable on the rig's seeded driver; Galaxy browses nested vs the gateway (or degrade-to-flat confirmed + unit-proven); FOCAS unit-proven. If anything regresses, STOP and debug (don't paper over).


Done criteria

  • dotnet build ZB.MOM.WW.OtOpcUa.slnx clean.
  • AdminUI.Tests / Galaxy / FOCAS / Modbus driver suites green.
  • Live /run: Modbus typed editor + Build-address on the rig; Galaxy nested browse (or degrade-to-flat
    • unit-proven); FOCAS unit-proven.
  • NO EF migration, NO Commons wire/proto change, NO bUnit. Then finishing-a-development-branch → merge to master + push.

Out of scope (next 4b slices)

S7 wide types / Timer-Counter, the cross-driver array slice (sink-contract change), AbCip/TwinCAT UDT member-paths, AbLegacy/TwinCAT bit-RMW writes, OpcUaClient ReadEventsAsync, Historian tie-cluster paging (#400).