9.1 KiB
Phase 4b — Mac-verifiable driver gaps (design)
Status: approved 2026-06-16. Branch feat/stillpending-phase-4b-driver-gaps off master c081917a.
Goal: Close three independent, contract-free stillpending.md §2 driver gaps that are
shippable on their own and (for the first two) live-verifiable from this Mac:
- Modbus driver-type-string reconcile —
"ModbusTcp"(AdminUI/probe) vs"Modbus"(runtime factory + seed) → rig Modbus drivers fall to the raw-JSON editor; AdminUI-created Modbus drivers store an unloadable type string. - Galaxy nested gobject hierarchy —
GalaxyDiscovererrenders the gobject tree flat. - FOCAS
cnc_getfigureauto-scale — axis positions need the decimal-place figure hand-configured; the CNC reports it viacnc_getfigure.
The three touch disjoint projects (AdminUI + Modbus driver / Galaxy driver / FOCAS driver), so implementers can run concurrently.
Hard constraints (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 contract change; NO bUnit (Razor proven by live
/run). The IFocasClient addition is a driver-internal interface, not a wire contract; the
Galaxy proto is unchanged.
Component 1 — Modbus driver-type-string reconcile
Problem
The Modbus driver-type string is inconsistent:
- Runtime canonical =
"Modbus"—ModbusDriver.DriverType(ModbusDriver.cs:185) and the factory registration keyModbusDriverFactoryExtensions.DriverTypeName(:16). - AdminUI =
"ModbusTcp"—DriverIdentitySection.razor,DriverTypePicker.razor,DriverEditRouter.razor,ModbusDriverPage.razor,Uns/TagEditors/TagConfigEditorMap.cs,Uns/TagEditors/TagConfigValidator.cs, + ~13 AdminUI.Tests rows. - Probe =
"ModbusTcp"—ModbusDriverProbe.cs:29(a third spelling).
Effect: the rig's MAIN-modbus-eq is seeded DriverType="Modbus", so the /uns Tag modal's
TagConfigEditorMap.Resolve (keyed on "ModbusTcp") misses → raw-JSON editor (and Phase 6's
Build-address button is unreachable). An AdminUI-created Modbus driver stores "ModbusTcp",
which the runtime factory ("Modbus") cannot instantiate.
Approach (chosen): canonicalize on "Modbus"
"Modbus" is the runtime factory registration key — the hard runtime dependency. Canonicalizing
on it means never touching the factory key or ModbusDriver.DriverType and no data
migration (the dev-rig seed is already "Modbus").
Changes — change the 7 outliers + tests to "Modbus":
ModbusDriverProbe.cs:29DriverType => "ModbusTcp"→"Modbus".- The 6 AdminUI files above: every
"ModbusTcp"stored/dispatched value →"Modbus". - ~13 AdminUI.Tests rows seeding
DriverType="ModbusTcp"→"Modbus".
A friendly display label ("Modbus TCP") stays in the picker UI; only the stored/dispatched
value canonicalizes. Any legacy "ModbusTcp" DriverInstance.DriverType row (none on the rig)
is a documented one-line SQL UPDATE — not a migration.
Rejected: canonicalize on "ModbusTcp" (forces a runtime-factory-key change + seed + DB
migration — touches the load-bearing dependency); dual-alias registration (YAGNI machinery).
Tests
Unit: TagConfigEditorMap.Resolve("Modbus") returns the Modbus editor; TagConfigValidator
validates "Modbus"; the picker/router dispatch on "Modbus". Update the ~13 AdminUI.Tests rows.
Live /run: the rig's seeded MAIN-modbus-eq now opens the typed Modbus editor + Build-address
button in the /uns Tag modal (the exact thing Phase 6 couldn't drive).
Component 2 — Galaxy nested gobject hierarchy
Problem
GalaxyDiscoverer.DiscoverAsync (Driver.Galaxy/Browse/GalaxyDiscoverer.cs) calls
builder.Folder(...) at root level for every gobject, then adds the gobject's variables into it.
Large Galaxy models browse as a flat list.
Approach (chosen): nest by parent_gobject_id (driver-internal, no gateway change)
The gateway proto GalaxyObject already carries the parentage we need:
gobject_id (1), parent_gobject_id (5), is_area (6), hosted_by_gobject_id (8).
IAddressSpaceBuilder.Folder(...) returns IAddressSpaceBuilder, so folders already nest
(parentFolder.Folder(child, child)) — no contract change.
Rewrite DiscoverAsync as a two-pass build:
- Pass 1 — folder map: create a
gobject_id → folder-builderentry for every gobject (browse name =contained_name??tag_name, as today). Don't attach to a parent yet. - Pass 2 — link + populate: for each gobject, resolve its folder under its parent's folder
when
parent_gobject_id != 0and that parent id is in the map; otherwise attach to the driver root (the current flat behaviour). Then add the gobject's variables into its own folder (unchanged per-attribute logic, incl. alarm-ref marking and the[]-suffix strip).
Properties:
- Order-independent — children may appear before parents (build the map first, link second).
- Degrades to flat — gateways that don't populate
parent_gobject_id(returns 0) or models with the parent outside the returned set fall back to root attachment. No regression for the current flat data. - Nest by the model/area relationship (
parent_gobject_id), not deployment hosting (hosted_by_gobject_id) — that is AVEVA's natural browse tree. - Cycle/self-parent guard: if
parent_gobject_id == gobject_idor a cycle is detected, attach to root (defensive; real Galaxy models are a DAG/tree).
Tests
Unit (Galaxy driver test project) — fake IGalaxyHierarchySource returns canned multi-level
GalaxyObject sets through a capturing IAddressSpaceBuilder:
- two-level nest (child folder under parent folder, variables in the right folder);
- order-independence (child returned before parent → still nests);
- degrade-to-flat (
parent_gobject_id=0, and parent-id-not-in-set → both attach to root); - defensive self-parent/cycle → root.
Live
/run: Client.CLI browse the Galaxy driver tree vs the gateway on10.100.0.48→ nested folders instead of flat.
Component 3 — FOCAS cnc_getfigure auto-scale
Problem
FocasDriver.PublishAxisSnapshot divides axis positions by 10^PositionDecimalPlaces
(FocasDriver.cs:823-829), where PositionDecimalPlaces is a manual per-device config knob
(default 0, added in Phase 4). The CNC reports the real figure via cnc_getfigure
(IFocasClient.cs:219-221 notes the auto path "once that export lands").
Approach (chosen): add a cnc_getfigure binding; auto wins, manual is the fallback
- Add a binding to
IFocasClient(e.g.Task<IReadOnlyList<int>> GetPositionFiguresAsync(CancellationToken)returning the per-axis decimal-place figure). The Wire client P/Invokescnc_getfigure; the Fake and Unimplemented clients return an empty/unsupported result. - At init (alongside the existing axis-name/sysinfo reads), fetch the per-axis figures and cache them on the driver state.
- Precedence (approved): auto wins; manual is the fallback. When
cnc_getfigurereturns a usable figure for an axis, that figure is authoritative for that axis's scale. When it returns nothing / is unavailable (older FWLIB, Fake/Unimplemented backend, per-axis gap), fall back to the configuredPositionDecimalPlaces. This keeps existing manual-knob configs working when the CNC doesn't report and matches the "auto-scale" intent (the CNC's own scaling is the truth). - Resolve the effective decimal places per axis (the figure can differ by axis), so
PublishAxisSnapshotdivides each axis by its own10^figure.
Rejected: drop the manual knob entirely (loses the fallback for backends/sims that can't report); leave manual-only (that's the current gap).
Tests
Unit (FOCAS driver test project) with a fake IFocasClient:
- auto wins — fake returns figures → driver scales by the auto figure, ignoring a differing manual config;
- fallback — fake returns nothing/unsupported → driver uses the manual
PositionDecimalPlaces; - per-axis — different figures per axis scale independently;
- regression — the existing manual-only path (no getfigure) still divides correctly. Unit-proven only (no CNC on this Mac — honestly operator-gated for a live read).
Out of scope (deferred)
- 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). These remain the next slices of Phase 4b's theme.
Done criteria
dotnet build ZB.MOM.WW.OtOpcUa.slnxclean.- Affected suites green: AdminUI.Tests (Modbus reconcile), Galaxy driver tests (nesting), FOCAS driver tests (auto-scale).
- Live
/run: Modbus typed editor + Build-address reachable on the rig's seeded driver; Galaxy tree browses nested vs the gateway. FOCAS unit-proven (operator-gated live). - Subagent-driven, per-task review by classification, final integration review, then merge + push.