plan(phase4b): Mac-verifiable driver gaps implementation plan + tasks.json
This commit is contained in:
@@ -0,0 +1,477 @@
|
||||
# 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 (1–3 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).
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-06-16-stillpending-phase-4b-driver-gaps.md",
|
||||
"designPath": "docs/plans/2026-06-16-stillpending-phase-4b-driver-gaps-design.md",
|
||||
"designCommit": "f90017bc",
|
||||
"baseMaster": "c081917a",
|
||||
"branch": "feat/stillpending-phase-4b-driver-gaps",
|
||||
"nativeTaskIds": {"1": 482, "2": 483, "3a": 484, "3b": 485, "4": 486, "5": 487, "6": 488},
|
||||
"tasks": [
|
||||
{"id": 1, "subject": "Task 1: Modbus driver-type-string reconcile (canonicalize on \"Modbus\")", "status": "pending", "classification": "standard", "parallelizableWith": [2, "3a"]},
|
||||
{"id": 2, "subject": "Task 2: Galaxy nested gobject hierarchy", "status": "pending", "classification": "standard", "parallelizableWith": [1, "3a"]},
|
||||
{"id": "3a", "subject": "Task 3a: IFocasClient.GetPositionFiguresAsync (cnc_getfigure binding)", "status": "pending", "classification": "standard", "parallelizableWith": [1, 2]},
|
||||
{"id": "3b", "subject": "Task 3b: FOCAS driver auto-scale wiring (auto wins, manual fallback)", "status": "pending", "classification": "standard", "blockedBy": ["3a"]},
|
||||
{"id": 4, "subject": "Task 4: Docs + bookkeeping", "status": "pending", "classification": "small", "blockedBy": [1, 2, "3b"]},
|
||||
{"id": 5, "subject": "Task 5: Full build + test + final integration review", "status": "pending", "classification": "standard", "blockedBy": [1, 2, "3a", "3b", 4]},
|
||||
{"id": 6, "subject": "Task 6: Live /run verification", "status": "pending", "classification": "standard", "blockedBy": [5]}
|
||||
],
|
||||
"lastUpdated": "2026-06-16"
|
||||
}
|
||||
Reference in New Issue
Block a user