Commit Graph

1232 Commits

Author SHA1 Message Date
Joseph Doherty 844f93f64f feat(adminui): B2-WP3 driver/device config modals + page-to-form refactor
Extract the 8 typed driver pages into embeddable <Driver>DriverForm bodies
(channel/protocol config) and add per-driver <Driver>DeviceForm bodies
(connection endpoint -> Device.DeviceConfig, v3 endpoint split). New
DriverConfigModal + DeviceModal dispatch by DriverType (DriverTypeNames) for
the /raw tree; Test-connect inside DeviceModal builds the merged Driver+Device
config transiently via DriverDeviceConfigMerger. Routed pages now host the
form bodies and keep working. Round-trip + enum-serialization guard tests for
the Modbus driver form + Modbus device model; retargeted the existing
page-form serialization tests + the _jsonOpts converter guard to the forms.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 03:26:24 -04:00
Joseph Doherty 76cffe1f49 feat(adminui): B2 Wave-B service prelude — tag CRUD/import + driver/device config + merged probe
Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 02:58:13 -04:00
Joseph Doherty 928e06dd01 review(b2-waveA): /raw auto-expand enterprise roots; friendly create-race + delete failures; auth-guard classifies /raw + dev demo
Wave-A reviewer findings (no Critical/High):
- M-2: GlobalRaw auto-expands Enterprise roots so the Cluster level is visible on load (mirrors GlobalUns.ExpandStructural); clusters stay lazily collapsed.
- L-1: create mutations route SaveChanges through SaveCreateAsync — a lost sibling-name race (filtered-unique-index clash) becomes a friendly failure instead of an uncaught DbUpdateException.
- L-2: SaveDeleteAsync no longer leaks raw EF/SQL text; operator-friendly guidance.
- L-5: RawNode.ChildCount doc clarified (direct children only).
- Regression fix: PageAuthorizationGuardTests classifies the two new routable pages (/raw → ConfigEditor, /dev/context-menu-demo → AuthenticatedRead).
M-1 (ordinal vs SQL-collation sibling uniqueness) assessed benign: name indexes use the DB default collation and the server-side pre-check runs under that same collation, so pre-check and index agree — no case-variant rows can coexist, so runtime ordinal RawPath keying never collides.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 02:45:06 -04:00
Joseph Doherty 76b8325b84 feat(adminui): B2-WP1 RawTreeService + lazy tree data layer + mutations
Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 02:34:36 -04:00
Joseph Doherty c3277b52c9 review(track0): async ContextMenu OnClick + keyboard-⋯ anchoring + focus-return; discovery-driven DriverTypeNames guard; CSV round-trip comment
Wave-0 reviewer findings (no Critical/High):
- T0-1 M: ContextMenuItem.OnClick Action→Func<Task> (kills async-void at menu call sites Wave A/B/C need); keyboard-activated ⋯ now anchors below the button instead of viewport (0,0); focus returns to trigger on close; backdrop closes on native right-click too.
- T0-3 M: guard test now discovers *DriverFactoryExtensions.Register by convention from the deployed driver assemblies instead of a hand-copied list, so a future driver lacking a constant is actually caught.
- T0-2 L: corrected the round-trip 'sole non-round-trippable shape' comment (any table whose final row is a lone empty field).

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 02:17:37 -04:00
Joseph Doherty 9a896efecd merge wt/t0-4 into v3/batch2-raw-ui (Track 0) 2026-07-16 02:08:20 -04:00
Joseph Doherty a38af16f0f merge wt/t0-3 into v3/batch2-raw-ui (Track 0) 2026-07-16 02:08:20 -04:00
Joseph Doherty 86ca1a76d1 feat(driver-calc): T0-4 Calculation evaluator core + project
New Driver.Calculation project + CalculationEvaluator, mirroring the
VirtualTag RoslynVirtualTagEvaluator: source-hash compile cache
(CompiledScriptCache), 2 s TimedScriptEvaluator, passthrough fast-path,
single-tag mode (ctx.SetVirtualTag dropped + logged), sandbox/compile/
timeout/throw all surfaced as VirtualTagEvalResult.Failure for WP7 to map
to Bad quality. IScriptCacheOwner.ClearCompiledScripts for the deploy
generation boundary. 11 parity unit tests. Both projects added to the
solution. Evaluator + tests only — driver shell / registration is WP7.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 02:07:38 -04:00
Joseph Doherty 17c7e97efb feat(commons): T0-2 RFC-4180 CSV parser + writer
Add a pure, no-I/O RFC 4180 CSV reader and writer to the Commons project
(greenfield — no CSV code existed).

CsvParser: string / TextReader -> rows of string[]; streaming IEnumerable
overload + eager Parse(string) + thin ParseWithHeader. Handles quoted
fields (embedded delimiter/CR/LF/CRLF, "" -> " escape), space
preservation, CRLF/LF/CR terminators, no phantom trailing row. Strict
malformed-input policy: FormatException with 1-based line/column on a
quote inside an unquoted field, a char after a closing quote, or an
unterminated quote. Faithful empty-line policy: a blank line is one empty
field (callers filter).

CsvWriter: rows -> string / TextWriter; quote-on-demand (delimiter, quote,
CR, LF only; internal quotes doubled), configurable delimiter/newline
(default CRLF), optional quote-all, no trailing terminator.

Tests (xUnit + Shouldly, 109): full RFC edge corpus for the parser, the
writer quote-on-demand rules, and the required Parse(Write(rows)) == rows
round-trip property over a table of every tricky character (x CRLF/LF/
quote-all/semicolon).

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 02:06:43 -04:00
Joseph Doherty 12b9978974 feat(core): T0-3 DriverTypeNames constants + reflection guard
Add DriverTypeNames as the single source of truth for driver-type
dispatch strings, one const per currently-registered driver factory
(value = the exact factory DriverTypeName). Fixes the latent drift
between hand-authored dispatch-map literals and the real factory names
(e.g. TwinCAT/FOCAS/GalaxyMxGateway) at the source.

A reflection guard test in Core.Abstractions.Tests builds the real
registered-factory set by driving each *DriverFactoryExtensions.Register
into a live DriverFactoryRegistry (mirroring the Host's
DriverFactoryBootstrap) and asserts bidirectional parity with the
constants, so any future rename on either side breaks the test gate.

Consumers are rewired in Batch 2 WP8; Calculation's const lands with its
factory in WP7.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 02:05:09 -04:00
Joseph Doherty b14a7cb847 v3 batch1 gate: fix stale collision-error-code assertions (EquipmentSignalNameCollision -> UnsEffectiveNameCollision) in Configuration.Tests
WP1 wrote these against its interim VirtualTag-only collision rule; WP4a replaced it
with the broader UnsEffectiveNameCollision rule. Neither test-sweep scope covered these
two Configuration.Tests files, so the assertions went stale. Configuration.Tests 107/107.
2026-07-15 21:56:36 -04:00
Joseph Doherty 991a3dd70a Merge B1-wp6b-ui 2026-07-15 21:51:21 -04:00
Joseph Doherty 6a616a1ab2 test(adminui): migrate AdminUI.Tests to v3 greenfield schema + Batch-1 stubs
Migrate tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests (was 358 build errors) to
the v3 Raw-only Tag schema and the Batch-1 AdminUI stubs. Production untouched.

- Driver-page serialization tests: drop the retired pre-declared Tags editor
  (page *TagRow types + DriverOptions.Tags); multi-device pages keep their
  Devices editor (ToOptions(devices)). Enum-serialization round-trip coverage
  (CpuType/ModbusFamily/MelsecFamily/AbCipPlcFamily/AbLegacyPlcFamily/FocasCncSeries)
  preserved intact.
- UnsTreeTestDb: reseed to v3 (Device->Tag raw slice, no EquipmentId/DriverInstanceId
  on Tag, no Namespace).
- UnsTreeService tag tests: assert Batch-1 stubs (CreateTag/UpdateTag refusal,
  empty tag/tag-driver lists); DeleteTag stays live against a raw Tag.
- Equipment #122 guard retained (validates input driver vs line cluster); drop the
  retired persisted-binding assertions + NamespaceId seeds. Area/Line #122 driver-
  orphan guard retired -> assert cross-cluster move now succeeds.
- DeleteCluster: drop the deleted-Namespace child-check test.
- OpcUaClientTagConfigModel/TagConfigValidator: address key FullName -> nodeId.
- ScriptTagCatalog: project surviving Name/DataType/TagConfig; DriverInstanceId null.
- VirtualTag {{equip}} equipment-tag-derived base is dark -> 3 tests skipped (Batch-3).

Result: build green; 507 passed / 3 skipped / 0 failed.
2026-07-15 21:50:48 -04:00
Joseph Doherty 2c7fc65bae test(v3): migrate OpcUaServer.Tests + Host.IntegrationTests to v3 dark address space
Migrate the server composer/applier + 2-node harness tests off the retired
Namespace/NamespaceKind + equipment→driver/device binding schema onto the v3
greenfield shape (raw-only Tag; DriverInstance-RawFolderId; equipment via UnsLine).

OpcUaServer.Tests (134 build errors -> green, 335 pass / 4 skip):
- LIVE (migrated, passing): VirtualTag-historize + ScriptedAlarm composition,
  composer purity/hierarchy, applier MaterialiseHierarchy + MaterialiseEquipmentTags
  idempotency (hand-fed applier, not dark).
- SKIP dark-until-Batch-4: {{equip}} token expansion, applier equipment-namespace
  E2E (equipment-tag variable materialization).
- RETIRE-with-comment / skip: alias-tag (Tag.EquipmentId binding gone) + device-host
  equipment binding (architecturally removed).
- Add local DarkAddressSpaceReasons Skip-reason constants (mirrors Runtime.Tests).

Host.IntegrationTests (green, in-memory): drop deleted-Namespace seeds from
MultiClusterScoping + DriverReconnect; reshape EquipmentNamespaceMaterialization to
assert the DARK contract (folder nodes decode, EquipmentTags empty despite a seeded
v3 raw tag) with the full raw-tag->EquipmentTag materialization kept as a
Batch-4-pending Skip.

Tests-only; no src/ changes.
2026-07-15 21:41:04 -04:00
Joseph Doherty aaba2ebd66 Merge B1-wp6integ 2026-07-15 21:26:51 -04:00
Joseph Doherty 329144b1aa v3 B1-WP6: migrate Core + Server test projects to greenfield schema
Fix TESTS only (production already green) so the four in-scope test projects
compile + pass under the v3 dark address space:

- Core.Abstractions.Tests: drop AllowedNamespaceKinds/NamespaceKindCompatibility
  from DriverTypeMetadata; rewrite EquipmentTagRefResolver tests to the single-func
  RawPath-lookup contract (no parseRef/transient cache).
- Core.Tests: rewrite EquipmentNodeWalkerTests to EquipmentNamespaceContent(Areas,
  Lines,Equipment,VirtualTags?,ScriptedAlarms?) — folders + VirtualTags +
  ScriptedAlarms only; raw-tag variables dark (skipped Batch-4 placeholder). Drop
  retired Equipment.DriverInstanceId.
- Runtime.Tests: rewrite golden corpus + TagConfigCorpusParityTests to the v3
  RawPath/RawTagEntry round-trip (byte + TagConfigIntent parity; EquipmentTags dark).
  Salvage VirtualTag + ScriptedAlarm artifact parity to the new Compose signature.
  Retire the equipment-tag-materialization parity files (Array/Historize/Alias) and
  the equipment-device-binding DeviceHost parity to documented skipped placeholders.
  Skip 34 dark equipment-tag routing/value/alarm/write/discovery actor tests with a
  shared DarkAddressSpaceReasons constant. Re-key cluster-scoped tests to v3 UNS
  line->area attribution.
- ControlPlane.Tests: ConfigComposer round-trips re-keyed to RawFolder/Device (no
  Namespaces); tag-config gate resolves driver via Device; deploy-gate collision test
  re-keyed to UnsEffectiveNameCollision; drop retired BadCrossClusterNamespaceBinding
  case.
2026-07-15 21:26:00 -04:00
Joseph Doherty a4c61989b2 test(v3): migrate 6 driver IntegrationTests to RawTags API
Replace pre-declared options.Tags (typed <Driver>TagDefinition lists) with
options.RawTags (IReadOnlyList<RawTagEntry>) across all 6 Docker-gated driver
integration suites so they COMPILE against the v3 driver API.

Each project gets a local <Driver>RawTags helper mirroring the unit-test
project's helper: it serialises a typed def back to its TagConfig blob via the
driver's <Driver>TagDefinitionFactory.ToTagConfig and packages it as a
RawTagEntry (RawPath = def.Name; DeviceName = def.DeviceHostAddress for the
multi-device drivers AbCip/AbLegacy/TwinCAT/FOCAS). Test intent (addresses,
data types, device routing, assertions) is unchanged — only the
tag-authoring/config-delivery shape was migrated.

Docker-gated: these still skip at runtime without fixtures; COMPILE is the gate.
2026-07-15 20:59:42 -04:00
Joseph Doherty e81ac352ed v3 B1-WP4/WP5: pipeline rewire to greenfield schema + dark address space
- ConfigComposer: snapshot v3 tables (RawFolders/TagGroups/UnsTagReferences),
  drop the retired Namespaces snapshot; RevisionHash follows the new blob.
- DeploymentArtifact: compute each raw tag's RawPath (RawFolder->Driver->Device->
  TagGroup ancestry via shared RawPathResolver); per-driver RawTags + merged
  DriverConfig+DeviceConfig config injected into DriverInstanceSpec via
  DriverDeviceConfigMerger; ParseComposition emits an empty EquipmentTags set
  (DARK until Batch 4); EquipmentNode driver/device hooks always null; cluster
  scoping attributes equipment by UNS line only.
- AddressSpaceComposer: rewritten to the v3 shape (no Namespace/NamespaceKind,
  no dropped Tag/Equipment fields); emits empty EquipmentTags (parity mirror).
- DriverHostActor: fan-out/write map tuple member FullName -> RawPath.
- Commons: new RawPathResolver (shared identity authority) + DriverDeviceConfigMerger
  (single/multi-device endpoint+RawTags merge).
- DraftValidator: v3 rules — raw-name charset (RawPaths.ValidateSegment),
  historized effective-tagname <=255, UNS effective-leaf uniqueness across
  UnsTagReference/VirtualTag/ScriptedAlarm; DraftSnapshot(+Factory) carry the new tables.
- AdminOperationsActor: tag-config inspection resolves driver via Device (no
  EquipmentId/DriverInstanceId on Tag).
- Tests: RawPathResolver/merger unit tests (Commons.Tests, green) +
  DeploymentArtifact RawPath/dark test (Runtime.Tests, awaits WP6 corpus fixes).
2026-07-15 20:45:24 -04:00
Joseph Doherty bd9d47eeae Merge B1-galaxy: v3 RawPath read-path 2026-07-15 20:20:49 -04:00
Joseph Doherty 8794991592 Merge B1-opcuaclient: v3 RawPath read-path 2026-07-15 20:20:49 -04:00
Joseph Doherty 53167624ae Merge B1-focas: v3 RawPath read-path 2026-07-15 20:20:49 -04:00
Joseph Doherty 7c2f28313d Merge B1-twincat: v3 RawPath read-path 2026-07-15 20:20:49 -04:00
Joseph Doherty 63b7d6ca5d Merge B1-ablegacy: v3 RawPath read-path 2026-07-15 20:20:49 -04:00
Joseph Doherty 1748af7261 Merge B1-abcip: v3 RawPath read-path 2026-07-15 20:20:49 -04:00
Joseph Doherty 8b2c3fa04c v3(focas): adopt Modbus RawTagEntry exemplar (multi-device)
- Rename FocasEquipmentTagParser -> FocasTagDefinitionFactory; TryParse ->
  FromTagConfig(tagConfig, rawPath, out def). Drop leading-{ heuristic + the
  deviceHostAddress blob key; def.Name = rawPath. Add inverse ToTagConfig.
  writable: absent -> read-only, explicit true honoured (note preserved).
- FocasDriverOptions.Tags -> RawTags (IReadOnlyList<RawTagEntry>).
- FocasDriver: _tagsByRawPath (Ordinal); byName-only resolver; build table from
  RawTags via FromTagConfig; multi-device routing threads RawTagEntry.DeviceName
  -> ResolveDeviceHost match against options.Devices -> def.DeviceHostAddress
  (TODO v3 WaveC: live host from Device row DeviceConfig). Tolerant per-tag
  skip+log (mapper/address/matrix miss -> BadNodeIdUnknown); unknown device
  fails init fast.
- Factory: bind List<RawTagEntry> RawTags; retire FocasTagDto/ParseDataType.
- CLI FocasCommandBase.BuildOptions -> RawTags via ToTagConfig + DeviceName.
- Tests: FocasRawTags helper; migrate Tags= -> RawTags=; rename parser tests to
  FromTagConfig; rewrite retired blob-ref tests (equipment-tag/capability-gate/
  resolve-host) onto authored RawPath. 272 driver + 52 CLI green.
2026-07-15 20:19:55 -04:00
Joseph Doherty c0379742bc v3(b1-abcip): RawPath read-path for AbCip (multi-device)
Apply the reviewed Modbus exemplar to the AbCip multi-device driver:
- Mapper AbCipEquipmentTagParser -> AbCipTagDefinitionFactory; TryParse -> FromTagConfig(tagConfig, rawPath, out def); drop leading-{ heuristic + the deviceHostAddress key; add inverse ToTagConfig; round-trip arrays/members/safety for coverage.
- Options: Tags (typed) -> RawTags (RawTagEntry list); Devices list kept.
- Driver: byName-only resolver over _tagsByRawPath (Ordinal); table built from RawTags via FromTagConfig, threading entry.DeviceName (device routing key) + entry.WriteIdempotent onto the def. Multi-device routing keys on DeviceName-or-host via a _devicesByRoutingKey index; discovery groups _declaredTags by RoutesToDevice.
- Factory: retire the pre-declared Tags DTO/BuildTag; bind List<RawTagEntry> RawTags.
- Probe: derive probe tag path from RawTags via the mapper.
- Cli BuildOptions projects typed tags -> RawTagEntry (ToTagConfig).
- Tests: AbCipRawTags helper (typed def -> RawTagEntry); parser tests -> FromTagConfig; blob-fallback driver tests rewritten to author via RawTags + read by RawPath. Driver.AbCip.Tests 342/342, Cli.Tests 42/42.

TODO(v3 WaveC): DeviceName becomes a pure logical name once the device connection host moves to the Device row's DeviceConfig.
2026-07-15 20:18:28 -04:00
Joseph Doherty 636c755b04 v3(galaxy): re-key GalaxyDriver to RawPath identity (dial attributeRef)
Wave-B driver re-keying. Under v3 the server addresses Galaxy nodes by RawPath
and the driver dials the Galaxy attributeRef (tag_name.AttributeName). The
dialled address moved into TagConfig under the camelCase key `attributeRef`
(renamed from the pre-v3 PascalCase FullName) and rides on RawTagEntry.

Options + factory:
- GalaxyDriverOptions gains `IReadOnlyList<RawTagEntry> RawTags` (Contracts now
  references the zero-dep Core.Abstractions leaf for RawTagEntry — same as
  Modbus.Contracts). Factory binds `List<RawTagEntry>? RawTags` from DriverConfig.

Boundary translation (built once from options.RawTags):
- RawPath -> attributeRef (forward dial) on Read / Write / Subscribe / Ack.
- attributeRef -> RawPath (reverse) on the value fan-out (OnDataChange) and the
  alarm ConditionId — so the alarm resolves back to the same RawPath the value
  path surfaces. Both legs fall back to identity on a miss, so the live-browse
  discovery path (no authored tags) and synthetic alarm sub-refs
  (attributeRef.InAlarm) dial straight through unchanged.
- WriteAsync dials the attributeRef but resolves security via the RawPath-keyed
  map the discoverer captured (reverse-maps in the resolver closure).

Discovery + alarms:
- GalaxyDiscoverer emits FullName = RawPath for authored attributes (falls back
  to attributeRef), threads RawTagEntry.WriteIdempotent onto DriverAttributeInfo,
  and gains an optional attributeRef -> RawTagEntry resolver ctor param.
- AlarmRefBuilder.Build(conditionReference, dialReference): SourceName/ConditionId
  = RawPath identity; the five live-state/ack sub-refs dial off the attributeRef.

ReinitializeAsync equivalence now compares the session-shape sections field-wise,
excluding RawTags (address-space state re-applied via rediscovery, and a record's
synthesized equality would compare the lists by reference).

Tests: +9 (discoverer RawPath/WriteIdempotent + alarm identity; factory RawTags
binding; driver read/write/subscribe/alarm boundary translation). Existing 304
preserved via identity fallback. 313 pass, 5 live-gated skips.
2026-07-15 20:12:22 -04:00
Joseph Doherty ec01649905 v3(b1-opcuaclient): re-key OpcUaClient resolution to RawPath via RawTags
NamespaceMap now carries a RawPath -> upstream node id table built from the
deployed RawTagEntry list (reads each tag's TagConfig.nodeId, threads
WriteIdempotent). Under v3 the read/write/subscribe/history reference handed to
the driver is the tag's RawPath identity; the driver resolves it in two stages:
RawPath -> nodeId string (instance TryResolve) -> live NodeId re-bound against
the session (static TryResolve). Alarm ConditionId + event-history sourceName
stay on the direct session parse (they are upstream node ids, not RawPaths).

- Options: add IReadOnlyList<RawTagEntry> RawTags (Contracts now refs Core.Abstractions).
- Factory: RawTags binds straight into options (no separate DTO); EndpointUrl kept.
- Browser: unchanged (emits neutral BrowseNode DTOs, no TagConfig FullName key).
- Tests: 138 green; new RawPath resolution + factory-binding coverage; migrated
  the stale-session ReadRaw test to author a resolving RawTag.

Contracts + Driver + Browser build clean (0 warn). Wave C wires endpoint->DeviceConfig
and the deploy artifact that populates RawTags.
2026-07-15 20:11:44 -04:00
Joseph Doherty a53be2af65 v3(b1-twincat): apply Modbus exemplar pattern to TwinCAT (multi-device)
- Rename TwinCATEquipmentTagParser -> TwinCATTagDefinitionFactory; TryParse ->
  FromTagConfig(tagConfig, rawPath, out def). Drop leading-'{' heuristic + the
  deviceHostAddress key; def.Name = rawPath. Keep strict-enum + Inspect; add a
  Structure guard (sole tag path now) + inverse ToTagConfig.
- Options: Tags -> RawTags (RawTagEntry); keep Devices + protocol fields.
- Driver: _tagsByRawPath (Ordinal); byName-only resolver; build table from
  _options.RawTags via FromTagConfig, threading WriteIdempotent + DeviceName.
  Multi-device: ResolveDeviceHost matches entry.DeviceName against options.Devices
  (by name, then host) -> def.DeviceHostAddress (TODO(v3 WaveC): live host from the
  Device row's DeviceConfig). DiscoverAsync now emits from the table.
- Factory: retire pre-declared tags[] DTO path; bind rawTags; keep Devices.
- Cli: BuildOptions serialises typed defs -> RawTagEntry via ToTagConfig.
- Tests: TwinCATRawTags helper; migrate Tags= -> RawTags=; rename parser tests to
  FromTagConfig; equipment/resolve-host/array tests delivered via RawTags.

Gate: Contracts + Driver build green; Driver.TwinCAT.Tests 192 pass, Cli.Tests 70 pass.
2026-07-15 20:11:23 -04:00
Joseph Doherty 1e26b9ab58 v3(s7): apply Modbus RawTags exemplar to the S7 driver
Rename S7EquipmentTagParser -> S7TagDefinitionFactory; TryParse(reference)
-> FromTagConfig(tagConfig, rawPath, out def) (drops the leading-{ heuristic,
keys the def by RawPath, adds inverse ToTagConfig). Replace S7DriverOptions.Tags
(pre-declared S7TagDefinition list) with RawTags (IReadOnlyList<RawTagEntry>);
the driver builds its RawPath->def table from RawTags at Initialize via the
factory (skip+log on miss), resolver is byName-only, DiscoverAsync + init guards
+ address pre-parse now run off that table. Factory retires the pre-declared DTO
tag path (RawTags binding only). Cli BuildOptions serialises typed defs to
RawTagEntry. Tests migrated to a S7RawTags helper + FromTagConfig; parser test
files renamed. Coordinator's EquipmentTagConfigInspector S7 rename left to Wave-B
coordinator; Driver.S7.IntegrationTests (Docker-gated) left red per scope.

Driver.S7 + Cli build green; Driver.S7.Tests 264/264, S7.Cli.Tests 49/49.
2026-07-15 20:11:04 -04:00
Joseph Doherty 3878b51e97 v3(ablegacy): apply Modbus exemplar — RawTags delivery + RawPath mapper
- Rename AbLegacyEquipmentTagParser → AbLegacyTagDefinitionFactory; TryParse →
  FromTagConfig(tagConfig, rawPath, out def). Drop the leading-{ heuristic and the
  deviceHostAddress key; def.Name = rawPath. Add inverse ToTagConfig. Keep strict-enum
  guards + Inspect.
- Options: Tags(IReadOnlyList<AbLegacyTagDefinition>) → RawTags(IReadOnlyList<RawTagEntry>).
- Driver: _tagsByRawPath (Ordinal); byName-only resolver; build table from _options.RawTags
  via FromTagConfig, threading WriteIdempotent + resolving RawTagEntry.DeviceName to the
  owning device host (ResolveDeviceHost; TODO(v3 WaveC) for the live Device-row host).
  DiscoverAsync + family-profile validation iterate the built table. Probe picks its address
  from RawTags via the mapper.
- Factory: retire the pre-declared tag DTO path; bind List<RawTagEntry>? RawTags; keep Devices.
- Cli BuildOptions: deliver typed defs as RawTagEntry via ToTagConfig.
- Tests: AbLegacyRawTags helper; migrate Tags= → RawTags; parser tests → FromTagConfig;
  equipment-ref driver/ResolveHost tests re-authored as RawTagEntry + read by RawPath.

Driver+Contracts+Cli green; 213 driver + 36 Cli tests pass.
2026-07-15 20:09:46 -04:00
Joseph Doherty aafb9d4929 v3(b1-modbus): RawPath identity seam — RawTagEntry-driven Modbus driver
Wave-B EXEMPLAR. Retire the pre-declared/blob-parse tag model; the driver now
builds its RawPath -> definition table from the artifact's RawTagEntry set via a
pure mapper.

- Contracts: rename ModbusEquipmentTagParser -> ModbusTagDefinitionFactory;
  TryParse(reference) -> FromTagConfig(tagConfig, rawPath) (def.Name = rawPath,
  no leading-brace heuristic); add inverse ToTagConfig(def) serializer; keep all
  strict-enum reads + guards; also read stringByteOrder/deadband/coalesceProhibited
  so a RawTagEntry round-trips the full authored def. Inspect() unchanged.
- Options: Tags(IReadOnlyList<ModbusTagDefinition>) -> RawTags(IReadOnlyList<RawTagEntry>).
- Driver: _tagsByName -> _tagsByRawPath (Ordinal); resolver byRawPath-only; build
  the table from RawTags at Initialize threading entry.WriteIdempotent onto each def;
  Discover/Teardown updated.
- Factory: remove ModbusTagDto/BuildTag/ValidateStringLength + ConfigDto.Tags; bind
  RawTags from driver-config JSON.
- CLI ModbusCommandBase.BuildOptions: serialise typed defs -> RawTagEntry via ToTagConfig.
- Tests: migrate Tags= -> RawTags via ModbusRawTags.Entries helper; parser tests ->
  FromTagConfig(rawPath); factory String-length/enum tests re-seated on the mapper seam.
- Propagated rename to ControlPlane EquipmentTagConfigInspector (outside Modbus projects).

Modbus.Tests 315/315, Addressing.Tests 161/161, Cli.Tests 72/72 green.
Docker-gated Driver.Modbus.IntegrationTests left untouched (won't compile against
the new API; expected).
2026-07-15 19:50:10 -04:00
Joseph Doherty bda27e2aab Merge B1-WP2: TagConfigIntent successor + RawPaths tests + key normalization 2026-07-15 19:20:40 -04:00
Joseph Doherty 4d39b98564 v3 batch1 WP1: greenfield DbContext + V3Initial squash + seeds + tests
DbContext (OtOpcUaConfigDbContext):
- Drop Namespace/EquipmentImportBatch/EquipmentImportRow DbSets + Configure methods.
- Add RawFolder/TagGroup/UnsTagReference DbSets + Configure methods with sibling-name
  filtered unique indexes over nullable parents (two-filtered pattern per level).
- Reshape DriverInstance (RawFolderId + UX_DriverInstance_Folder_Name/_ClusterRoot_Name),
  Tag (required DeviceId, nullable TagGroupId, UX_Tag_Group_Name/_Device_Name, IX_Tag_Device),
  Equipment (drop DriverInstanceId/DeviceId + IX_Equipment_Driver), Device (UX_Device_Driver_Name).

Migration squash -> V3Initial:
- Delete the whole Migrations/ folder; scaffold one V3Initial (applies clean to a fresh DB).
- Port the hand-written procs + AuthorizationGrants into V3Initial.StoredProcedures.cs. The v1
  generation-model procs (ConfigGeneration/GenerationId, dead since V2HostingAlignment) are
  rewritten as v3-schema-valid retirement stubs that preserve the two-role EXECUTE-grant surface
  + AuthorizationTests behaviors; sp_ComputeGenerationDiff now references the v3 tables;
  sp_ReleaseExternalIdReservation ported verbatim.

Compile-fixes for the build gate (Wave-C-owned, minimal + TODO(v3 WP4) markers):
- DraftSnapshot/DraftSnapshotFactory: drop the retired Namespaces list.
- DraftValidator: delete namespace-binding + Galaxy-FullName rules; collision rule now VirtualTag-only.

Seeds -> v3 schema:
- seed-clusters.sql summary SELECTs; all 5 scripts/smoke/seed-*.sql rewritten (DriverInstance
  RawFolderId, Device+DeviceConfig endpoint, Tag DeviceId, UnsTagReference; no ConfigGeneration/
  Namespace/sp_PublishGeneration). Verified: all seeds apply clean against a fresh V3Initial DB.

Tests (Configuration.Tests, 107 pass):
- SchemaComplianceTests rewritten to v3 tables + filtered indexes.
- New RawSchemaIntegrityTests: DB-enforced sibling-name uniqueness per raw level + UnsTagReference
  (Equipment,Tag) uniqueness.
- Delete DraftValidatorGalaxyFullNameCorpusTests (WP2/WP6 replaces); fix DraftValidatorTests +
  DraftSnapshotFactoryTests to v3 shapes; repoint scripting-migration existence test to V3Initial.
2026-07-15 19:19:21 -04:00
Joseph Doherty d4dab75323 v3 B1-WP2: TagConfigIntent sheds FullName identity; RawPaths tests; nodeId key normalization 2026-07-15 18:58:57 -04:00
Joseph Doherty bbbda99719 feat(browse): Wave-0 Batch F — capture coalescing/cap + AddAdminUI DI registration
Task 10: in-flight coalescing (Lazy<Task<CapturedTree>> keyed on driverType+SHA256(config))
         so repeat Browse clicks share one device walk; global SemaphoreSlim cap
         (MaxConcurrentCaptures=4) queues excess; per-caller ct via WaitAsync leaves the shared
         capture (bounded by open-timeout, CancellationToken.None) unaffected; in-flight-only map
         (Refresh = fresh capture). 7 concurrency tests green (158 total in Commons.Tests).
Task 12: AddAdminUI registers IUniversalDriverBrowser->DiscoveryDriverBrowser with
         NullDriverFactory fallback (standalone AdminUI => CanBrowse false => manual entry).
         AdminUI.Tests 542 green.
2026-07-15 17:37:02 -04:00
Joseph Doherty 698703744f feat(browse): Wave-0 Batch E — UntilStable settle loop + BrowserSessionService fallback
Task 9: DiscoverTreeAsync UntilStable settle loop (FOCAS) — re-capture on a 1s interval until
        the node set is non-empty and stable across two passes, bounded by open-timeout; on
        timeout with a prior non-empty capture, return it (best-effort) instead of failing.
Task 11: BrowserSessionService resolves bespoke-first, falls back to IUniversalDriverBrowser
         when no bespoke browser matches and CanBrowse is true; new CanBrowse on the service.
16 unit tests green (4 settle + 12 service).
2026-07-15 17:31:50 -04:00
Joseph Doherty fa339a5565 feat(browse): Wave-0 Batch D — IUniversalDriverBrowser + DiscoveryDriverBrowser core
Task 8: IUniversalDriverBrowser + DiscoveryDriverBrowser open path — construct(+PatchForBrowse)
        -> connect -> one-shot capture -> bounded (10s) shutdown; CanBrowse gate (catch-throwing
        TryCreate, defensive teardown of the throwaway instance); deep-merge browse patch.
        Commons gains Microsoft.Extensions.Logging.Abstractions (ILogger doesn't flow transitively)
        + InternalsVisibleTo(Commons.Tests). OTOPCUA0001 suppressed on the deliberate one-shot
        browse-capture DiscoverAsync (not a runtime dispatch path). 12 unit tests green.
2026-07-15 17:27:04 -04:00
Joseph Doherty 15da8d4f0d feat(browse): Wave-0 Batch C — CapturingAddressSpaceBuilder + CapturedTreeBrowseSession
Task 6: CapturedTree/CapturedNode model + CapturingAddressSpaceBuilder — an in-memory
        IAddressSpaceBuilder that records a driver's DiscoverAsync stream (folder nesting,
        leaf FullName as node id, alarm mark, node-cap truncation, no-op alarm sink).
Task 7: CapturedTreeBrowseSession — serves the captured snapshot through IBrowseSession
        (Root/Expand/Attributes from memory, truncation marker, shared-immutable-tree dispose).
13 unit tests green.
2026-07-15 17:22:00 -04:00
Joseph Doherty 679484ae78 feat(browse): Wave-0 Batch B — AbCip/TwinCAT/FOCAS opt into online discovery
Task 3/4/5: SupportsOnlineDiscovery => true on AbCipDriver, TwinCATDriver, FocasDriver
(their device enumeration is real, config-gated on EnableControllerBrowse / FixedTree.Enabled
which PatchForBrowse guarantees at open). One gate fact per driver test project.
2026-07-15 17:18:02 -04:00
Joseph Doherty 85776cf650 feat(browse): Wave-0 Batch A — SupportsOnlineDiscovery gate, Commons ref, TagModal editor plumbing
Task 1: ITagDiscovery.SupportsOnlineDiscovery default member (=> false)
Task 2: Commons -> Core.Abstractions ProjectReference
Task 13: TagModal BuildEditorParameters passes DriverType + GetDriverConfigJson;
         all 7 typed tag editors declare the two new [Parameter]s (DynamicComponent
         throws on unmatched params, so every editor must accept them).
2026-07-15 17:15:32 -04:00
Joseph Doherty e8db0a683f fix(drivers): flip runtime tag parsers to strict enum parsing (#457)
v2-ci / build (pull_request) Successful in 4m12s
v2-ci / unit-tests (pull_request) Failing after 10m15s
R2-11 Phase C. All six equipment-tag parsers (Modbus/S7/AbCip/AbLegacy/
TwinCAT/Focas) now read enum fields via the new TagConfigJson.TryReadEnumStrict:
absent -> fallback, valid -> parsed, present-but-invalid (typo) -> TryParse
returns false -> EquipmentTagRefResolver.TryResolve false -> driver surfaces
BadNodeIdUnknown, instead of the old lenient path that silently defaulted a
typo to a wrong-width Good.

Modbus flips all three enum fields (region/dataType/byteOrder); the other five
flip dataType. The deploy-time Deployment:TagConfigValidationMode=Error gate is
unchanged and remains the operator pre-flight.

Coverage:
- Six *EquipmentTagParserStrictnessTests inverted from Freeze_typo_* (lenient)
  to Typo_*_rejects_the_tag + Valid_*_still_parses.
- TagConfigJsonTests.TryReadEnumStrict_rejects_only_invalid matrix.
- Driver-level end-to-end proof:
  AbCipEquipmentTagTests.Driver_read_of_a_typod_dataType_ref_surfaces_BadNodeIdUnknown
  drives the real AbCipDriver.ReadAsync through resolve->status.

Golden parity corpus has no typo'd enums, so the flip is a no-op there.
Full solution builds clean; all six driver suites + core + parity + gate green.
2026-07-15 11:28:34 -04:00
Joseph Doherty b0eb653bad fix(redundancy): 2-node SBR exit-and-rejoin recovery — watchdog + restart supervision + both-node seeds (#459)
v2-ci / unit-tests (pull_request) Failing after 12m9s
v2-ci / build (pull_request) Successful in 5m12s
Corrects the #459 finding. 2-node keep-oldest recovery works fine (the ScadaBridge
sister project proves it); OtOpcUa was missing the supervision pieces that make it
automatic, and docs/Redundancy.md wrongly claimed in-place oldest-crash failover.

Mechanism (confirmed on a 2-container rig + by decompiling Akka KeepOldest.OldestDecision):
on an OLDEST-node crash keep-oldest downs the LONE survivor (DownReachable including
myself) — down-if-alone can't rescue a lone survivor (its branch needs >=2 survivors).
Recovery is exit-and-rejoin: run-coordinated-shutdown-when-down terminates the node and
the service supervisor restarts it. My earlier 'total outage' was a docker-dev artifact
(no restart policy); production Install-Services.ps1 already has sc.exe failure restart.

Changes (ScadaBridge parity):
- ActorSystemTerminationWatchdog (Host, registered after AddAkka): watches
  ActorSystem.WhenTerminated and on an unexpected self-down calls StopApplication so the
  process exits (supervisor restarts it) instead of idling with a dead actor system.
  Distinguishes graceful shutdown via _stopRequested + ApplicationStopping. 3 unit tests.
- docker-dev: restart: unless-stopped on the host anchor (models production supervision) +
  both redundancy peers in SeedNodes so a restarted node re-forms via either peer.
- docs/Redundancy.md: rewrote the split-brain recovery section — younger-loss = in-place
  fast failover; oldest-loss = exit-and-rejoin under supervision (not in-place); the three
  requirements (supervisor + watchdog + both-node seeds); flagged HardKillFailoverTests as
  non-representative (Transport.Shutdown, not a real crash). Instant in-place takeover on
  ANY single loss needs 3+ members.

Cluster.Tests 29/29 (SBR guards), watchdog tests 3/3, full solution builds.
Live re-verify of the watchdog image pending (host docker disk full).
2026-07-15 10:52:36 -04:00
Joseph Doherty 809e7886d8 test(resilience): prove ResilienceConfig survives the real ConfigComposer round-trip (#456)
The arch-review #10 sub-gap worried that per-instance ResilienceConfig never
reaches the runtime pipeline. The threading is in fact wired end-to-end (task #13):
ConfigComposer serialises the whole DriverInstance entity, so ResilienceConfig
rides the artifact into DriverInstanceSpec, which DriverHostActor layers onto the
driver's Polly pipeline. The only leg with no test was the real composer->artifact
serialization — the existing DeploymentArtifactTests hand-build the JSON.

Adds ResilienceConfig_survives_ConfigComposer_to_ParseDriverInstances_round_trip
(mirrors the DeviceHost round-trip guard): seeds a DriverInstance WITH a non-default
override + one WITHOUT, runs the real SnapshotAndFlattenAsync, and asserts
ParseDriverInstances recovers the override byte-for-byte (and null stays null).
Guards against a future projection / [JsonIgnore] silently reverting authored
resilience policy to tier defaults while hand-built artifact tests stay green.

ControlPlane.Tests ConfigComposerTests 6/6 green.
2026-07-15 08:38:13 -04:00
Joseph Doherty 4d2dcff5df test(ldap): live GLAuth outage/recovery gate (R2-08 03/S2)
v2-ci / build (pull_request) Successful in 3m59s
v2-ci / unit-tests (pull_request) Failing after 11m55s
Closes the R2-08 S2 live leg. The offline LdapAuthResilienceTests prove the
directory-outage circuit with fakes + an unroutable-host blackhole; this drives
the SAME circuit through the production auth path (OtOpcUaLdapAuthService +
LdapOpcUaUserAuthenticator) against a REAL GLAuth that is paused mid-run and
unpaused — and doubles as the first end-to-end verification that the PR #451
GLAuth swap (retired bitnami/openldap) binds correctly.

Triple-gated (GLAUTH_LIVE_HOST + GLAUTH_OUTAGE_START_CMD/STOP_CMD), skips clean
offline (verified: 1 skipped, 8 ms). try/finally always unpauses GLAuth.

Verified GREEN against 10.100.0.35:3894 (12 s): healthy binds (alice+bob, mapped
roles) -> docker pause -> 3 bounded failures -> circuit opens -> sub-second
"unavailable" fast-deny -> docker unpause -> half-open probe closes -> binds
succeed again. A boundary AuthTimeout feeds the circuit (authenticator :123-130),
so the docker-pause timeout shape is a faithful outage.
2026-07-15 07:45:07 -04:00
Joseph Doherty a5a0f96d49 fix(drivers): harden operator TimeoutMs handling + AbLegacy evict parity
v2-ci / build (pull_request) Successful in 4m5s
v2-ci / unit-tests (pull_request) Failing after 10m45s
Follow-ups from the fleet-wide read-timeout audit that the S7 R2-01 read-leg
fix (PR #453) prompted. The audit confirmed S7 was the ONLY driver with the
async-read-ignores-socket-timeout hang; these are the two adjacent (non-hang)
findings it surfaced.

1. FOCAS TimeoutMs:0 footgun (the risky one): a non-positive Timeout made
   SynchronizedFocasClient DISABLE its per-call wall-clock ceiling, reverting to
   the caller's long-lived poll token — reintroducing exactly the frozen-peer
   wedge S7 just eliminated, under misconfig. Clamp non-positive TimeoutMs to the
   2s default at the config boundary so the deadline can never be authored away.

2. TimeoutMs validation symmetry: apply the same clamp in the AbCip + AbLegacy
   factories. libplctag's Tag.Timeout setter throws on <=0, faulting tag creation
   on every read/write; clamping keeps a misconfigured TimeoutMs:0 running on the
   default bound instead. (Shared PositiveTimeoutOrDefault helper per factory.)

3. AbLegacy reconnect parity with AbCip: AbLegacy evicted the cached libplctag
   runtime on neither the non-zero-status nor transport-exception read/write path
   (AbCip evicts on both), so a data-path fault recovered only via the probe loop
   / libplctag internals. Added EvictRuntime + wired it into both read and write
   failure paths so a fresh handle is created on the next call.

Tests: FOCAS 265->269 (clamp theory + positive), AbCip 336->339 (clamp theory +
positive), AbLegacy 209->212 (read-nonzero / read-exception / write-nonzero evict).
No production regressions; all three driver suites green.
2026-07-15 07:35:17 -04:00
Joseph Doherty 88e0977ae6 fix(s7): bound async wire ops with a wall-clock deadline (R2-01 read leg)
v2-ci / build (pull_request) Successful in 5m5s
v2-ci / unit-tests (pull_request) Failing after 13m50s
The R2-01 live gate (S7_1500ConnectTimeoutOutageTests, docker-pause blackhole)
surfaced a real gap the offline fakes couldn't: S7.Net's ReadTimeout/WriteTimeout
map to the TcpClient's ReceiveTimeout/SendTimeout, which govern only SYNCHRONOUS
socket calls — the async read/write paths S7.Net uses ignore them. On an
established-but-frozen peer (frozen PLC / firewall DROP / cable pulled mid-flow,
TCP session still open) a read blocked until the OS TCP stack gave up (minutes),
silently wedging the poll loop: no Bad tick, no reconnect. STAB-14 fixed only the
CONNECT leg (EnsureConnectedAsync CancelAfter); this is its READ-leg sibling.

- New S7OperationDeadline: bounds every data-plane wire op with a wall-clock
  ceiling (= _options.Timeout), surfacing an overrun as TimeoutException. Applied
  in S7PlcAdapter to Read/ReadBytes/Write/WriteBytes/ReadStatus. OpenAsync is left
  to EnsureConnectedAsync's own CancelAfter (not double-bounded).
- IsS7ConnectionFatal now classifies TimeoutException fatal → handle marked dead →
  next EnsureConnectedAsync reopens (connect-timeout fix takes over from there).
- Tests: 5 S7OperationDeadline unit tests (deadline / token-honouring / caller-
  cancel-passthrough / resultless), 1 driver-reaction test (read TimeoutException
  → reopen). Driver.S7.Tests 260/260.
- Live gate S7_1500ConnectTimeoutOutageTests now GREEN against the real snap7 sim
  (8s: baseline Good -> pause blackhole -> Bad tick -> unpause -> recovered Good).
2026-07-15 07:11:42 -04:00
Joseph Doherty 859d63178a fix(harness): make deploy E2E tests pass against real SQL (seed cluster FKs)
v2-ci / build (pull_request) Successful in 4m23s
v2-ci / unit-tests (pull_request) Failing after 10m14s
Integration-sweep follow-up #6 (SQL leg). The sweep flagged 7 heavy 2-node
deploy/failover/reconnect E2E tests as 'time out under amd64-emulated SQL' and
suggested raising deadlines. That diagnosis was WRONG: run against real
(native-amd64) SQL on the Docker host, they still hang — even at 4x deadline.
Root cause is that these tests had never actually run against real SQL, only the
EF in-memory provider, which does NOT enforce foreign keys.

Two distinct defects, both hidden by in-memory:

1. Missing FK-parent seed (5 tests). The deploy records a NodeDeploymentState row
   per node, FK'd to ClusterNode (FK_NodeDeploymentState_ClusterNode_NodeId). The
   tests never seeded ServerCluster + ClusterNode, so on SQL each node-state INSERT
   throws and the deploy never seals (waits the full deadline regardless of size).
   Added TwoNodeClusterHarness.SeedDefaultClusterAsync (seeds a ServerCluster +
   a ClusterNode per node; Warm/NodeCount=2 to satisfy the SQL CHECK constraint
   CK_ServerCluster_RedundancyMode_NodeCount AND the ClusterEnabledNodeCountMismatch
   validator). Called it in DeployHappyPath x2 / Failover / FleetDiagnostics /
   EquipmentNamespace; fixed DriverReconnect to seed node B + NodeCount=2.

2. Stale EquipmentId (EquipmentNamespace test; pre-existing on BOTH providers).
   Seeded EquipmentId='eq-1', but the later-added EquipmentIdNotDerived validator
   requires EquipmentId == DeriveEquipmentId(EquipmentUuid) ('EQ-'+first 12 hex).
   Fixed the seed to a canonical id + matching UUID. Only surfaced once the FK fix
   let the deploy reach equipment validation.

Also added OTOPCUA_HARNESS_SQL_HOST / OTOPCUA_HARNESS_LDAP_HOST overrides so the
harness fixtures can run on the native-amd64 Docker host (avoids arm64 mssql
emulation entirely).

Verified: the 5 SQL-backed classes go 11/11 green vs native SQL; in-memory default
unregressed (12/12). RoslynVirtualTagEvaluatorTests racing test is a pre-existing
flaky race (not SQL-backed; passes in isolation) — left as-is.
2026-07-15 06:43:15 -04:00
Joseph Doherty 3a48eb0c70 fix(harness): swap retired bitnami/openldap for GLAuth in the Host real-LDAP mode
v2-ci / build (pull_request) Successful in 4m13s
v2-ci / unit-tests (pull_request) Failing after 10m18s
The Host.IntegrationTests opt-in real-LDAP mode (OTOPCUA_HARNESS_USE_LDAP=1)
used bitnami/openldap:2.6, gone since Bitnami deprecated their free image
catalog in 2025. Replaced it with GLAuth — the same LDAP server the rest of
the project already uses (docker-dev + the live OPC UA data-plane auth, both
against the shared GLAuth on :3893) — removing the lone OpenLDAP outlier and
the gone-image breakage.

- Added tests/.../Host.IntegrationTests/glauth/config.toml: baseDN dc=zb,dc=local,
  a search-capable serviceaccount, dev users alice (full access) / bob (read-only),
  and role groups with the same gidnumbers as scadaproj/infra/glauth so GroupToRole
  maps identically.
- Compose ldap service -> glauth/glauth:latest, mounting the config, host :3894 -> :3893.
- Repointed the harness real-LDAP override from the OpenLDAP cn=admin/ldapadmin to
  GLAuth's cn=serviceaccount/serviceaccount123.

Live-verified against a deployed container on :3894: the serviceaccount bind
searches and returns alice + her 5 memberOf groups; alice/bob bind with the
correct password; a wrong password yields Invalid credentials (49) — exactly the
OtOpcUaLdapAuthService flow (proven identical to the data-plane's shared-GLAuth path).

NB: real-LDAP mode is opt-in; the default Host run uses StubLdapAuthService, so
this never blocked the default suite. Integration-sweep follow-up #6 (LDAP leg);
the amd64-emulated-SQL deadline leg remains open.
2026-07-15 06:03:50 -04:00
Joseph Doherty 7982043673 fix(focas-tests): honour fixture endpoint + fix mock timer endianness (9F/1P -> 10/10)
v2-ci / build (pull_request) Successful in 4m31s
v2-ci / unit-tests (pull_request) Failing after 10m26s
Two test/fixture bugs; NO OtOpcUa driver change.

1. Topology: FocasSimFixture reads an env-overridable endpoint
   (OTOPCUA_FOCAS_SIM_ENDPOINT, default localhost:8193) and exposes Host/Port,
   but WireBackendTests + WireBackendCoverageTests hardcoded
   focas://127.0.0.1:8193. Against a remote fixture the skip-gate passed
   (remote reachable) but the driver dialed localhost -> KeyNotFound. Added a
   DeviceUri property to the fixture; each test now derives DeviceHost from it.
   Recovered 8 of 9.

2. Mock timer endianness (surfaced once #1 let the tests reach the mock): the
   last failure read 60397977600 for a 3600 s power-on timer (= 0x3C000000 x 60).
   The focas-mock's _wire_timer encoded the minute/msec fields big-endian (_u32),
   but cnc_rdtimer's timer fields are LITTLE-endian on real hardware — a
   documented, live-31i-B-validated quirk that FocasWireClient.ParseTimer decodes
   LE (docs/plans/2026-06-25-focas-pdu-v3-30i-b-support.md). The driver was
   correct; the mock was wrong. _wire_timer now emits little-endian (_u32_le).

Suite 9F/1P -> 10/10 against the remote fixture at ~/otopcua-focas.
Integration-sweep follow-up #3.
2026-07-15 05:51:47 -04:00