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

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:

  1. 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.
  2. Galaxy nested gobject hierarchyGalaxyDiscoverer renders the gobject tree flat.
  3. FOCAS cnc_getfigure auto-scale — axis positions need the decimal-place figure hand-configured; the CNC reports it via cnc_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 key ModbusDriverFactoryExtensions.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:29 DriverType => "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:

  1. Pass 1 — folder map: create a gobject_id → folder-builder entry for every gobject (browse name = contained_name ?? tag_name, as today). Don't attach to a parent yet.
  2. Pass 2 — link + populate: for each gobject, resolve its folder under its parent's folder when parent_gobject_id != 0 and 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_id or 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 on 10.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/Invokes cnc_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_getfigure returns 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 configured PositionDecimalPlaces. 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 PublishAxisSnapshot divides each axis by its own 10^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.slnx clean.
  • 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.