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

478 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:
```csharp
[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**
```bash
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:
```csharp
[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**
```csharp
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**
```bash
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):
```csharp
/// <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**
```bash
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`):
```csharp
[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`):
```csharp
// 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**
```bash
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:**
```bash
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).