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
The HistorianGateway driver is now the sole historian read/write+alarm backend, so the
Wonderware sidecar projects are dead code. Removes the 5 Wonderware projects (driver,
.Client, .Client.Contracts, + their 2 test projects) from the solution and tree, and fully
retires the vestigial 'Historian.Wonderware' driver type (UI/probe-only; it had no driver
factory): the Host probe registration, the AdminUI driver-config surface (driver page,
tag-config editor/model/validator entry, address picker/builder, driver-type catalog +
dropdown + edit-router entries), and their tests. Prunes the now-unused Wonderware
connection fields (Host/Port/UseTls/ServerCertThumbprint/SharedSecret) from
AlarmHistorianOptions (keeping Enabled + the SQLite store-and-forward knobs) and refreshes
the stale XML docs that named Wonderware as the production backend.
Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii
Move WonderwareHistorianClientOptions to a new
Driver.Historian.Wonderware.Client.Contracts sibling project. The record
had no using directives and uses only primitive types (string, TimeSpan)
so the contracts project is dependency-free.
Convert one doc-comment reference:
<see cref="WonderwareHistorianClient"/> → <c>WonderwareHistorianClient</c>
per the approved decision — no compilable usings were present.
The runtime Driver.Historian.Wonderware.Client project gains a
ProjectReference to .Contracts; the .slnx is updated accordingly.
Move GalaxyDriverOptions (and nested records GalaxyGatewayOptions,
GalaxyMxAccessOptions, GalaxyRepositoryOptions, GalaxyReconnectOptions)
from Config/GalaxyDriverOptions.cs into a new Driver.Galaxy.Contracts
sibling project at the contracts root (no Config/ subdirectory). The
existing namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config is preserved
unchanged — it is a runtime ABI concern and all consumers already import
it via the namespace qualifier.
No doc-comment substitutions required — the only cref in the file
(<see cref="ApiKeySecretRef"/>) is an intra-type parameter reference
that resolves within the contracts project itself.
The options file had no using directives and no NuGet type surface;
the contracts project is dependency-free. The runtime Driver.Galaxy
project gains a ProjectReference to .Contracts; the .slnx is updated
accordingly.
Move OpcUaClientDriverOptions and all companion enums (OpcUaTargetNamespaceKind,
OpcUaSecurityMode, OpcUaSecurityPolicy, OpcUaAuthType) to a new
Driver.OpcUaClient.Contracts sibling project. The options file had no
using directives — all types were defined in the same file — so no
NuGet mirror enum pattern was required.
Convert two doc-comment references:
<see cref="OpcUaClientDriver.InitializeAsync"/> → <c>OpcUaClientDriver.InitializeAsync</c>
<see cref="OpcUaClientDriver.ValidateNamespaceKind"/> → <c>OpcUaClientDriver.ValidateNamespaceKind</c>
per the approved decision — no compilable usings were present.
The runtime Driver.OpcUaClient project gains a ProjectReference to .Contracts;
the .slnx is updated accordingly.
Move FocasDriverOptions (and companion option types), FocasCncSeries,
and the FocasDataType enum to a new Driver.FOCAS.Contracts sibling
project. FocasDataTypeExtensions (which uses DriverDataType from
Core.Abstractions) stays in the runtime driver as FocasDataTypeExtensions.cs.
Convert two doc-comment references:
<see cref="FocasDriver.InitializeAsync"/> → <c>FocasDriver.InitializeAsync</c>
<see cref="FocasAddress.TryParse"/> → <c>FocasAddress.TryParse</c>
per the approved decision — no compilable usings were present in the
moved files.
The runtime Driver.FOCAS project gains a ProjectReference to .Contracts;
the .slnx is updated accordingly.
Introduces Driver.S7.Contracts (dependency-free POCO project) and moves
S7DriverOptions / S7ProbeOptions / S7TagDefinition / S7DataType into it.
Adds S7CpuType enum mirroring S7.Net.CpuType exactly (7 values with
explicit integer codes). Runtime S7CpuTypeMap bridges S7CpuType →
S7.Net.CpuType at the single Plc construction site in S7Driver.InitializeAsync.
S7DriverFactoryExtensions and S7CommandBase updated to use S7CpuType; test
files updated to match (S7_1500Profile, S7DriverScaffoldTests). AdminUI can
now reference Driver.S7.Contracts without pulling in S7netplus.
Move TwinCATDriverOptions and TwinCATDataType enum to a new
Driver.TwinCAT.Contracts sibling project. TwinCATDataTypeExtensions
(which uses DriverDataType from Core.Abstractions) stays in the
runtime driver as TwinCATDataTypeExtensions.cs.
Replace two doc-comment references:
<see cref="Core.Abstractions.PollGroupEngine"/> → <c>PollGroupEngine</c>
<see cref="TwinCATAmsAddress.TryParse"/> → <c>TwinCATAmsAddress.TryParse</c>
per the approved decision — no compilable usings were present.
The runtime Driver.TwinCAT project gains a ProjectReference to .Contracts;
the .slnx is updated accordingly.
Move AbLegacyDriverOptions, AbLegacyDataType enum, and
AbLegacyPlcFamilyProfile (including AbLegacyPlcFamily enum) to a new
Driver.AbLegacy.Contracts sibling project. All three files are zero-dep
after splitting AbLegacyDataTypeExtensions (which uses DriverDataType
from Core.Abstractions) into a new file that stays in the runtime driver.
Drop the doc-comment <see cref="AbLegacyAddress.TryParse"/> reference and
replace with <c>AbLegacyAddress.TryParse</c> per the approved decision.
The PlcFamilies using directive is retained in the contracts project since
both namespaces live there.
The runtime Driver.AbLegacy project gains a ProjectReference to .Contracts;
the .slnx is updated accordingly.
Move AbCipDriverOptions (and AbCipDataType enum) to a new
Driver.AbCip.Contracts sibling project. AbCipDataTypeExtensions
(which uses DriverDataType from Core.Abstractions) stays in the
runtime driver as AbCipDataTypeExtensions.cs.
Replace two doc-comment <see cref="Core.Abstractions.IAlarmSource"/>
and <see cref="Core.Abstractions.IHostConnectivityProbe"/> with <c>X</c>
per the approved decision — no compilable using was present.
The runtime Driver.AbCip project gains a ProjectReference to .Contracts;
the .slnx is updated accordingly.
Move ModbusDriverOptions (and companion option types) to a new
Driver.Modbus.Contracts sibling project. The contracts project
references only Driver.Modbus.Addressing (itself zero-dep and
Admin-safe) because ModbusDriverOptions.Probe/Family/Region
properties use enum types that live there.
Drop 'using ZB.MOM.WW.OtOpcUa.Core.Abstractions' and replace
<see cref="IHostConnectivityProbe"/> with <c>IHostConnectivityProbe</c>
per the approved decision — the using was doc-comment-only.
The runtime Driver.Modbus project gains a ProjectReference back to
.Contracts; the .slnx is updated accordingly.
Builds TwoNodeClusterHarness: two in-process Host-equivalent nodes sharing
an in-memory ConfigDb. Forms a 2-member Akka cluster. ClusterFormationTests
proves both nodes see each other as admin+driver role members.
Fixes a real production bug uncovered while wiring the harness — Program.cs
ran two separate ActorSystems (one from AddOtOpcUaCluster.AkkaHostedService
with cluster HOCON, one from Akka.Hosting.AddAkka with bare HOCON). Cluster
singletons landed on the bare ActorSystem and could not actually form a
cluster ("Configuration does not contain `akka.cluster` node").
Consolidation:
- AddOtOpcUaCluster now only binds AkkaClusterOptions + registers IClusterRoleInfo
- New WithOtOpcUaClusterBootstrap pushes embedded HOCON + Remote/Cluster options
into Akka.Hosting's AkkaConfigurationBuilder
- AkkaHostedService.cs deleted — Akka.Hosting now owns the lifecycle
- Program.cs + harness call WithOtOpcUaClusterBootstrap inside AddAkka
Why not WebApplicationFactory<Program>? Program.cs reads OTOPCUA_ROLES from
process env (shared across in-process WAFs); the harness replays Program.cs's
DI graph from a clean WebApplicationBuilder per node with per-node config
overrides. Same production extensions, isolated config + Kestrel + Akka ports.
Tests: 93 v2 tests pass (was 91 + 2 new cluster formation), 0 skipped.
Task 56: removes the legacy in-process Server + Admin Web project + their test
projects (Server.Tests, Admin.Tests, Admin.E2ETests). The fused OtOpcUa.Host
binary built across Phases 1-9 is now the sole production entry point.
What happened to the 47 legacy Admin Blazor pages: per follow-up F15, the
v1 architecture's draft/publish UX is replaced by v2's live-edit + snapshot-
deploy model, so a 1:1 migration is not meaningful. The mechanical move via
git mv preserves the history; service classes + page bodies that referenced
removed v1 types (ConfigGeneration, RedundancyRole, GenerationId) were
deleted. AdminUI now ships a minimal Home page + the v2 Deployments page.
Per-page rebuild against the v2 surface is tracked as F15. The v2 Deployments
page (Task 52) is the only first-party UI shipping in this PR.
Task 57: solution build green; 84+ tests green across active v2 + legacy
driver test projects.
Adds the empty project skeletons that subsequent v2 tasks fill in:
src/Core/ZB.MOM.WW.OtOpcUa.Commons (types, interfaces, message contracts)
src/Core/ZB.MOM.WW.OtOpcUa.Cluster (Akka.Hosting + cluster wiring)
src/Server/ZB.MOM.WW.OtOpcUa.Security (cookie+JWT auth, LDAP)
src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane (admin-role cluster singletons)
src/Server/ZB.MOM.WW.OtOpcUa.Runtime (per-node driver actors)
src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer (OPC UA SDK application host)
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI (Razor class library)
src/Server/ZB.MOM.WW.OtOpcUa.Host (single fused web binary)
Each project sets TreatWarningsAsErrors=true in its own csproj (per the
Directory.Build.props deviation note in the previous commit). NuGetAuditSuppress
entries cover transitive vulnerability advisories the new strictness surfaces:
- GHSA-g94r-2vxg-569j (OpenTelemetry.Api 1.9.0 via Akka.Cluster.Hosting/Tools)
- GHSA-h958-fxgg-g7w3 (Opc.Ua.Core 1.5.374.126 via OpcUaServer)
- GHSA-37gx-xxp4-5rgx + GHSA-w3x6-4m5h-cxqf (legacy advisories already accepted)
OpcUaServer pins OPCFoundation.NetStandard.Opc.Ua.Configuration to 1.5.374.126
via VersionOverride to match Opc.Ua.Server's transitive Opc.Ua.Core (same
constraint as the legacy Server project).
Runtime does NOT project-reference any concrete Driver.* assemblies; drivers
load reflectively at runtime (Phase 6). Runtime gets the IDriver contract
through Core.Abstractions instead.
Host's Microsoft.Extensions.Hosting.WindowsServices is conditional on the
Windows OS so the project builds on macOS dev machines.
Build verification: dotnet build -> 438 warnings (all pre-existing xUnit1051
in legacy Server.Tests/Admin.Tests), 0 errors. Closes Task 9 (build green
smoke check, no separate commit).
- Driver.FOCAS.Cli-001: WriteCommand.ParseValue now wraps numeric
FormatException / OverflowException as CliFx CommandException with
the offending value.
- Driver.FOCAS.Cli-002: SubscribeCommand's OnDataChange handler and the
banner both take a writeLock so notification-callback and main-thread
writes can't interleave; handler exceptions are warn-and-swallow.
- Driver.FOCAS.Cli-003: FocasCommandBase.ValidateOptions rejects
--cnc-port outside 1..65535, non-positive --timeout-ms, and
non-positive --interval-ms; ExecuteAsync calls it first.
- Driver.FOCAS.Cli-004: 'await using var driver' is the sole driver
disposal path; dropped the redundant explicit await ShutdownAsync.
- Driver.FOCAS.Cli-005 (Deferred): the fix lives in
Driver.Cli.Common.SnapshotFormatter — explicitly naming the
status-code shortlist there benefits every driver CLI. Left as a
Driver.Cli.Common follow-up.
- Registered the new tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Tests
project in ZB.MOM.WW.OtOpcUa.slnx.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Group all 69 projects into category subfolders under src/ and tests/ so the
Rider Solution Explorer mirrors the module structure. Folders: Core, Server,
Drivers (with a nested Driver CLIs subfolder), Client, Tooling.
- Move every project folder on disk with git mv (history preserved as renames).
- Recompute relative paths in 57 .csproj files: cross-category ProjectReferences,
the lib/ HintPath+None refs in Driver.Historian.Wonderware, and the external
mxaccessgw refs in Driver.Galaxy and its test project.
- Rebuild ZB.MOM.WW.OtOpcUa.slnx with nested solution folders.
- Re-prefix project paths in functional scripts (e2e, compliance, smoke SQL,
integration, install).
Build green (0 errors); unit tests pass. Docs left for a separate pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Matrix-gate satisfied (14 passed / 1 skipped / 0 failed on 2026-04-30
per docs/v2/Galaxy.ParityMatrix.md). Galaxy access flows through the
in-process GalaxyDriver → mxaccessgw exclusively. Legacy infrastructure
deleted in this commit:
Source projects (6):
- src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host (.NET 4.8 x86 + MXAccess COM)
- src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy (in-process pipe client)
- src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared (pipe-IPC contracts)
- tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests
- tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests
- tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests
Test projects with no consumer after legacy retired (3):
- tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E (drove Galaxy.Host EXE)
- tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests (drove both backends)
- tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport (only consumed by Host/Proxy tests)
Edits:
- ZB.MOM.WW.OtOpcUa.slnx: drop nine project entries
- Server.csproj: drop Driver.Galaxy.Proxy ProjectReference
- Server/Program.cs: drop GalaxyProxyDriverFactoryExtensions.Register
+ the parallel-registration comment block; only GalaxyDriverFactoryExtensions
registers now under DriverType "GalaxyMxGateway"
- Install-Services.ps1: rewrite to drop OtOpcUaGalaxyHost service install +
the GalaxySharedSecret/ZbConnection/GalaxyClientName/GalaxyPipeName/
AvevaServiceDependencies/MxAccessInitialConnect* parameters that only
applied to the legacy host. Adds a closing note pointing operators at
the separate mxaccessgw install
- Uninstall-Services.ps1: keep OtOpcUaGalaxyHost in the cleanup loop so
pre-7.2 rigs upgrade-uninstall cleanly, plus add OtOpcUaWonderwareHistorian
- scripts/e2e/test-galaxy.ps1: deleted (drove the legacy E2E)
- scripts/e2e/e2e-config.sample.json: rewrite the galaxy section comment
to reflect the GalaxyMxGateway-only path
- scripts/e2e/README.md: drop OtOpcUaGalaxyHost references
- scripts/compliance/phase-7-compliance.ps1: drop Galaxy.Shared
HistorianAlarms* checks (those contracts moved to
Driver.Historian.Wonderware.Client in PR 3.4)
Live state: OtOpcUaGalaxyHost Windows service stopped + removed via
NSSM before this commit. The dev box's Galaxy access is now exclusively
through the running mxaccessgw (separate repo).
Stays out of scope for PR 7.2 (PR 7.3 territory):
- CLAUDE.md Galaxy section rewrite
- mxaccess_documentation.md deletion
- Memory entries for the now-retired Galaxy.Host service
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Side-by-side fixture that boots both backends against the same dev Galaxy:
- Legacy GalaxyProxyDriver against an out-of-process Galaxy.Host EXE
(skipped when ZB SQL on localhost:1433 isn't reachable or when the EXE
hasn't been built).
- New in-process GalaxyDriver against an mxaccessgw gateway at
http://localhost:5120 by default (skipped when the gateway isn't
reachable). Endpoint, API key, and client name are env-var overridable
for the central parity host.
Per-backend availability is independent — each scenario decides whether
to RequireBoth, GetDriver(specific), or use RunOnAvailableAsync to drive
both with the same closure and diff snapshots. PR 5.2–5.8 land scenarios
on top of this shell.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New in-process .NET 10 driver project at
src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/. The Tier-A replacement for
Driver.Galaxy.Host + Driver.Galaxy.Proxy. PR 4.0 ships only the IDriver
shape + factory + options; capability bodies (browse, read, write,
subscribe, deploy-watch, host probes) land in PRs 4.1–4.7.
Files:
- Driver.Galaxy.csproj — net10 x64, AnyCPU+x64 platforms, references
Core.Abstractions + Core. No MxGatewayClient ProjectReference yet — that
comes in PR 4.2 once the gw NuGet package is wired (the user is
shipping mxaccessgw on a parallel track).
- Config/GalaxyDriverOptions.cs — nested record hierarchy
(Gateway/MxAccess/Repository/Reconnect) mirroring the JSON shape spelled
out in lmx_mxgw_impl.md PR 4.0 acceptance section.
- GalaxyDriver.cs — minimal IDriver impl. Initialize/Shutdown toggle
DriverHealth between Healthy/Unknown; Reinitialize bumps the timestamp;
GetMemoryFootprint=0 (PR 4.4 wires SubscriptionRegistry size);
FlushOptionalCachesAsync no-op. Logs intent on lifecycle calls so
partial deployments are diagnosable.
- GalaxyDriverFactoryExtensions.cs — JSON parser, default fill-ins,
validation throw on missing required fields. Driver type name
"GalaxyMxGateway" intentionally distinct from legacy "Galaxy" so both
factories coexist during parity testing (Phase 5). PR 4.W's
Galaxy:Backend switch picks one or the other.
Tests:
- 10 tests in Driver.Galaxy.Tests covering minimal-config defaults, full
override path, three required-field error cases, factory registration
via DriverFactoryRegistry.TryGet, lifecycle health transitions
(Init → Shutdown → Reinit), Dispose idempotency, and post-disposal
ObjectDisposedException.
slnx: registers the new Driver.Galaxy + Driver.Galaxy.Tests projects.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Solution + DI plumbing to complete Phase 3. With this PR the .NET 10 server
can boot with the Wonderware historian sidecar in the loop, gated by config
so existing deployments are unaffected.
slnx: registers Driver.Historian.Wonderware (net48 sidecar),
Driver.Historian.Wonderware.Client (net10 client), and both test projects.
Server.csproj: adds ProjectReference to the .NET 10 client.
Program.cs: reads Historian:Wonderware:* configuration. When Enabled=true,
constructs a WonderwareHistorianClient singleton and:
- Registers it as IAlarmHistorianWriter so the SqliteStoreAndForwardSink
drain (task #248) can pick it up.
- Registers a WonderwareHistorianBootstrap hosted service that, on
StartAsync, calls IHistoryRouter.Register(prefix, client) under the
configured DriverInstancePrefix (default "galaxy") — lets the
HistoryRead* dispatch in DriverNodeManager find the sidecar via
longest-prefix-match resolution.
When Enabled=false (the default), DriverNodeManager keeps using its
internal LegacyDriverHistoryAdapter for the read path and the existing
NullAlarmHistorianSink stays in place — drop-in compatible with every
deployment that hasn't moved off Galaxy.Host yet.
42 server integration tests + 10 client tests pass. Full solution build
clean (0/0).
Note: scripts/install/Install-Services.ps1 and
src/.../Server/appsettings.json carry intermixed user WIP and are NOT
committed in this PR. Equivalent edits applied locally:
Install-Services.ps1: new -InstallWonderwareHistorian switch installs the
OtOpcUaWonderwareHistorian service alongside OtOpcUaGalaxyHost;
generates a fresh historian shared secret; OtOpcUa service depends on
both when historian sidecar is installed.
Server/appsettings.json: new Historian.Wonderware section with
Enabled=false default, PipeName/SharedSecret/PeerName/
DriverInstancePrefix/ConnectTimeoutSeconds/CallTimeoutSeconds keys.
Both pieces should land in a follow-up commit once the user's WIP on those
files clears.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Foundation for the Modbus addressing-grammar work tracked in #137-#145. Adds
ModbusModiconAddress.Parse / TryParse that turns classic Modicon strings
(40001 / 400001 / 30001 / 00001 / 10001) into (Region, ushort PduOffset).
Also extracts ModbusRegion to a new Driver.Modbus.Addressing assembly so the
Admin UI (#145) can reference the addressing surface without taking a dep on
the wire driver. The new assembly intentionally extends the same
ZB.MOM.WW.OtOpcUa.Driver.Modbus namespace as the driver — callers see the
type as if it lived in one place; only the project layout changes. No
existing call site needed editing (zero-churn move).
Behaviour:
- Single leading digit selects region (0=Coils, 1=DiscreteInputs,
3=InputRegisters, 4=HoldingRegisters).
- 5-digit form: trailing 4 digits are 1-based register, supports 1..9999.
- 6-digit form: trailing 5 digits are 1-based register, supports 1..65536
(full PDU address space).
- Strict 5-or-6 length check; whitespace trimmed; clear FormatException
diagnostics for every malformed shape (wrong length, non-digit body,
illegal leading digit, register zero, register overflow).
29/29 new unit tests pass. Full Driver.Modbus suite (182 tests) and the
solution-wide build still green after the ModbusRegion move.
Migration closes the FOCAS Tier-C architecture. OtOpcUa previously had
`Driver.FOCAS.Host` (NSSM-wrapped Windows service loading Fwlib64.dll via
P/Invoke) + `Driver.FOCAS.Shared` (MessagePack IPC contracts) + a C shim
DLL stand-in for unit tests. All of it is deleted; the driver is now a
single in-process managed assembly talking the FOCAS/2 Ethernet binary
protocol directly on TCP:8193.
Architecture
- Pure-managed `FocasWireClient` inlined at `src/.../Driver.FOCAS/Wire/`
(owner-imported — see Wire/FocasWireClient.cs for the full surface).
Opens two TCP sockets, runs the initiate handshake, serialises requests
on socket 2 through a semaphore, closes cleanly with PDU + socket
teardown. Both sync `IDisposable` and async `IAsyncDisposable`.
- `WireFocasClient` (same folder) adapts the wire client to OtOpcUa's
`IFocasClient` surface — fixed-tree reads, PARAM/MACRO/PMC addresses,
alarms. Writes return `BadNotWritable` by design — OtOpcUa is read-only
against FOCAS.
- `FocasDriverFactoryExtensions` now accepts `"Backend": "wire"` (default)
and `"Backend": "unimplemented"`. Legacy `ipc` and `fwlib` backends are
rejected at startup with a diagnostic pointing at the migration doc.
Deletions
- `src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/` — whole project + Ipc/,
Backend/, Stability/, Program.cs.
- `src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/` — Contracts/, FrameReader,
FrameWriter, whole project.
- `tests/...Driver.FOCAS.Host.Tests/` + `.Shared.Tests/` — whole projects.
- `src/.../Driver.FOCAS/FwlibNative.cs` + `FwlibFocasClient.cs` — 21
P/Invokes + 7 `Pack=1` marshalling structs + the Fwlib-backed
`IFocasClient` implementation.
- `src/.../Driver.FOCAS/Ipc/` + `Supervisor/` — IPC client wrapper +
Host-process supervisor (backoff, circuit breaker, heartbeat, post-
mortem reader, process launcher).
- `scripts/install/Install-FocasHost.ps1` — NSSM service installer.
- `tests/.../Driver.FOCAS.Tests/{IpcFocasClientTests, IpcLoopback,
FwlibNativeHelperTests, PostMortemReaderCompatibilityTests,
SupervisorTests, FocasDriverFactoryExtensionsTests}.cs` — tests that
exercised the retired surfaces.
- `tests/.../Driver.FOCAS.IntegrationTests/Shim/` — the zig-built C shim
DLL that masqueraded as Fwlib64.dll.
Solution changes
- `ZB.MOM.WW.OtOpcUa.slnx` drops the 4 retired project refs.
- `src/.../Driver.FOCAS.csproj` drops the Shared ProjectReference, adds
`Microsoft.Extensions.Logging.Abstractions` for the optional `ILogger`
hook in `FocasWireClient`.
- `src/.../Driver.FOCAS.Cli.csproj` drops the six `<Content Include>`
entries that copied `vendor/fanuc/*.dll` into the CLI bin. CLI now uses
`WireFocasClient` directly.
- `FocasDriver` default factory flips to `Wire.WireFocasClientFactory`.
Integration tests
- New `tests/.../Driver.FOCAS.IntegrationTests/` project covering fixed-
tree reads (identity, axes, dynamic, program, operation mode, timers,
spindle load + max RPM, servo meters), user-authored PARAM / MACRO /
PMC reads, `DiscoverAsync` emission, `SubscribeAsync` + `OnDataChange`,
`IAlarmSource` raise/clear transitions, and `ProbeAsync` /
`OnHostStatusChanged`. 9 e2e tests against the focas-mock fixture
(Docker container with the vendored Python mock's native FOCAS/2
Ethernet responder).
- `scripts/integration/run-focas.ps1` orchestrates compose up → tests →
compose down. Dropped the shim-build stage + DLL-copy step + the split
testhost workaround (the latter only existed because of native-DLL
lifecycle bugs the shim tripped).
- Docker compose collapses from 11 per-series services to one `focas-sim`
service. Tests seed per-series state via `mock_load_profile` at test
start.
- Vendored focas-mock snapshot refreshed to pick up upstream's native
FOCAS/2 Ethernet responder (was 660 lines, now 1018) — the
pre-refresh snapshot only spoke the JSON admin protocol.
Tests
- 145/145 unit tests in `Driver.FOCAS.Tests` pass (was 208 pre-deletion;
63 removed tests exercised the retired IPC/shim/supervisor/Fwlib
surfaces).
- 9/9 integration tests pass against the refreshed mock.
- `FocasScaffoldingTests.Unimplemented_factory_throws_on_Create…` updated
to assert the new diagnostic message pointing at
`docs/drivers/FOCAS.md` rather than the now-gone `Fwlib64.dll`.
Docs
- `docs/drivers/FOCAS.md` rewritten for the managed wire topology —
deployment collapses to one `"Backend": "wire"` config block, no
separate service, no DLL deployment, no pipe ACL.
- `docs/drivers/FOCAS-Test-Fixture.md` updated — single TCP probe skip
gate instead of TCP + shim probe; fewer moving parts.
- `docs/drivers/README.md` row for FOCAS reflects the Tier-A managed
topology (previously listed Tier-C + `Fwlib64.dll` P/Invoke).
- `docs/Driver.FOCAS.Cli.md` drops the Tier-C architecture-note section.
- `docs/v2/implementation/focas-isolation-plan.md` marked historical —
the plan it documents was executed then superseded by the wire client.
- `docs/v2/v2-release-readiness.md` re-audited 2026-04-24. Phase 5
driver complement closed. FOCAS change-log entry added.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The driver-layer integration tests confirm the driver sees the PLC, and
the Client.CLI tests confirm the client sees the server. Nothing glued
them end-to-end until this PR.
- scripts/e2e/_common.ps1: shared helpers — CLI invocation (published-
binary OR `dotnet run` fallback), Test-Probe / Test-DriverLoopback /
Test-ServerBridge (all return @{Passed;Reason} hashtables).
- scripts/e2e/test-<modbus|abcip|ablegacy|s7|focas|twincat>.ps1: per-
driver three-stage script (probe → driver-loopback → server-bridge).
AB Legacy / FOCAS / TwinCAT are gated behind *_TRUST_WIRE env vars
since they need real hardware (#222) or a licensed runtime (#221).
- scripts/e2e/test-phase7-virtualtags.ps1: writes a Modbus HR, reads
the server-side VirtualTag (VT = input * 2) back via OPC UA, triggers
+ clears a scripted alarm. Exercises the Phase 7 CachedTagUpstreamSource
+ ScriptedAlarmEngine path.
- scripts/e2e/test-all.ps1: reads e2e-config.json sidecar, runs each
present driver, prints a FINAL MATRIX (PASS/FAIL/SKIP). Missing
sections SKIP rather than fail hard.
- scripts/e2e/e2e-config.sample.json: commented sample — each dev's
NodeIds are local-seed-specific so e2e-config.json is .gitignore-d.
- scripts/e2e/README.md: full walkthrough — prereqs, three-stage design,
env-var gates, expected matrix, why this is separate from `dotnet test`.
Tasks #249-#251 shipped Modbus/AbCip/AbLegacy/S7/TwinCAT CLIs but left
FOCAS out. Since test-focas.ps1 needs it, the 6th CLI ships here:
- src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli: probe/read/write/subscribe
commands, AssemblyName `otopcua-focas-cli`. WriteCommand.ParseValue
handles the full FocasDataType enum (Bit/Byte/Int16/Int32/Float32/
Float64/String — no UInt variants; the FOCAS protocol exposes signed
PMC + Fanuc-Float only). Default DataType is Int16 to match the PMC
register convention.
Full-solution build clean (0 errors). FOCAS CLI wired into
ZB.MOM.WW.OtOpcUa.slnx. No .Tests project for the FOCAS CLI yet —
symmetric with how ProbeCommand has no unit-testable pure logic in the
other 5 CLIs either; WriteCommand.ParseValue parity will land in a
follow-up to keep this PR scoped.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Final two of the five driver test clients. Pattern carried forward from
#249 (Modbus) + #250 (AB CIP, AB Legacy) — each CLI inherits Driver.Cli.Common
for DriverCommandBase + SnapshotFormatter and adds a protocol-specific
CommandBase + 4 commands (probe / read / write / subscribe).
New projects:
- src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/ — otopcua-s7-cli.
S7CommandBase carries host/port/cpu/rack/slot/timeout. Handles all S7
atomic types (Bool, Byte, Int16..UInt64, Float32/64, String, DateTime).
DateTime parses via RoundtripKind so "2026-04-21T12:34:56Z" works.
- src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/ — otopcua-twincat-cli.
TwinCATCommandBase carries ams-net-id + ams-port + --poll-only toggle
(flips UseNativeNotifications=false). Covers the full IEC 61131-3
atomic set: Bool, SInt/USInt, Int/UInt, DInt/UDInt, LInt/ULInt, Real,
LReal, String, WString, Time/Date/DateTime/TimeOfDay. Structure writes
refused as out-of-scope (same as AB CIP). IEC time/date variants marshal
as UDINT on the wire per IEC spec. Subscribe banner announces "ADS
notification" vs "polling" so the mechanism is obvious in bug reports.
Tests (49 new, 122 cumulative driver-CLI):
- S7: 22 tests. Every S7DataType has a happy-path + bounds case. DateTime
round-trips an ISO-8601 string. Tag-name synthesis round-trips every
S7 address form (DB / M / I / Q, bit/word/dword, strings).
- TwinCAT: 27 tests. Full IEC type matrix including WString UTF-8 pass-
through + the four IEC time/date variants landing on UDINT. Structure
rejection case. Tag-name synthesis for Program scope, GVL scope, nested
UDT members, and array elements.
Docs:
- docs/Driver.S7.Cli.md — address grammar cheat sheet + the PUT/GET-must-
be-enabled gotcha every S7-1200/1500 operator hits.
- docs/Driver.TwinCAT.Cli.md — AMS router prerequisite (XAR / standalone
Router NuGet / remote AMS route) + per-command examples.
Wiring:
- ZB.MOM.WW.OtOpcUa.slnx grew 4 entries (2 src + 2 tests).
Full-solution build clean. Both --help outputs verified end-to-end.
Driver CLI suite complete: 5 CLIs (otopcua-{modbus,abcip,ablegacy,s7,twincat}-cli)
sharing a common base + formatter. 122 CLI tests cumulative. Every driver family
shipped in v2 now has a shell-level ad-hoc validation tool.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Second + third of the four driver test clients. Both follow the same shape as
otopcua-modbus-cli (#249) and consume Driver.Cli.Common for DriverCommandBase +
SnapshotFormatter.
New projects:
- src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/ — otopcua-abcip-cli.
AbCipCommandBase carries gateway (ab://host[:port]/cip-path) + family
(ControlLogix/CompactLogix/Micro800/GuardLogix) + timeout.
Commands: probe, read, write, subscribe.
Value parser covers every AbCipDataType atomic type (Bool, SInt..LInt,
USInt..ULInt, Real, LReal, String, Dt); Structure writes refused as
out-of-scope for the CLI.
- src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/ — otopcua-ablegacy-cli.
AbLegacyCommandBase carries gateway + plc-type (Slc500/MicroLogix/Plc5/
LogixPccc) + timeout.
Commands: probe (default address N7:0), read, write, subscribe.
Value parser covers Bit, Int, Long, Float, AnalogInt, String, and the
three sub-element types (TimerElement / CounterElement / ControlElement
all land on int32 at the wire).
Tests (35 new, 73 cumulative across the driver CLI family):
- AB CIP: 17 tests — ParseValue happy-paths for every Logix atomic type,
failure cases (non-numeric / bool garbage), tag-name synthesis.
- AB Legacy: 18 tests — ParseValue coverage (Bit / Int / AnalogInt / Long /
Float / String / sub-elements), PCCC address round-trip in tag names
including bit-within-word + sub-element syntax.
Docs:
- docs/Driver.AbCip.Cli.md — family ↔ CIP-path cheat sheet + examples per
command + typical workflows.
- docs/Driver.AbLegacy.Cli.md — PCCC address primer (file letters → CLI
--type) + known ab_server upstream gap cross-ref to #224 close-out.
Wiring:
- ZB.MOM.WW.OtOpcUa.slnx grew 4 entries (2 src + 2 tests).
Full-solution build clean. `otopcua-abcip-cli --help` + `otopcua-ablegacy-cli
--help` verified end-to-end.
Next up (#251): S7 + TwinCAT CLIs, same pattern.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors the v1 otopcua-cli value prop (ad-hoc shell-level PLC validation) for
the Modbus-TCP driver, and lays down the shared scaffolding that AB CIP, AB
Legacy, S7, and TwinCAT CLIs will build on.
New projects:
- src/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/ — DriverCommandBase (verbose
flag + Serilog config) + SnapshotFormatter (single-tag + table +
write-result renders with invariant-culture value formatting + OPC UA
status-code shortnames + UTC-normalised timestamps).
- src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/ — otopcua-modbus-cli executable.
Commands: probe, read, write, subscribe. ModbusCommandBase carries the
host/port/unit-id flags + builds ModbusDriverOptions with Probe.Enabled
=false (CLI runs are one-shot; driver-internal keep-alive would race).
Commands + coverage:
- probe single FC03 + GetHealth() + pretty-print
- read region × address × type synth into one driver tag
- write same shape + --value parsed per --type
- subscribe polled-subscription stream until Ctrl+C
Tests (38 total):
- 16 SnapshotFormatterTests covering: status-code shortnames, unknown
codes fall back to hex, null value + timestamp placeholders, bool
lowercase, float invariant culture, string quoting, write-result shape,
aligned table columns, mismatched-length rejection, UTC normalisation.
- 22 Modbus CLI tests:
· ReadCommandTests.SynthesiseTagName (5 theory cases)
· WriteCommandParseValueTests (17 cases: bool aliases, unknown rejected,
Int16 bounds, UInt16/Bcd16 type, Float32/64 invariant culture,
String passthrough, BitInRegister, Int32 MinValue, non-numeric reject)
Wiring:
- ZB.MOM.WW.OtOpcUa.slnx grew 4 entries (2 src + 2 tests).
- docs/Driver.Modbus.Cli.md — operator-facing runbook with examples per
command + output format + typical workflows.
Regression: full-solution build clean; shared-lib tests 16/0, Modbus CLI tests
22/0.
Next up: repeat the pattern for AB CIP (shares ~40% more with Modbus via
libplctag), then AB Legacy, S7, TwinCAT. The shared base stays as-is unless
one of those exposes a gap the Modbus-first pass missed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ships the E2E infrastructure filed against task #199 (UnsTab drag-drop Playwright
smoke). The Blazor Server interactive-render assertion through a test-owned pipeline
needs a dedicated diagnosis pass — filed as task #242 — but the Playwright harness
lands here so that follow-up starts from a known-good scaffolding rather than
setting up the project from scratch.
## New project tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests
- AdminWebAppFactory — boots the Admin pipeline with Kestrel on a free loopback port,
swaps the SQL DbContext for EF Core InMemory, replaces the LDAP cookie auth with
TestAuthHandler, mirrors the Razor-components/auth/antiforgery pipeline, and seeds
a cluster + draft generation with areas warsaw / berlin and a line-a1 in warsaw.
Not a WebApplicationFactory<Program> because WAF's TestServer transport doesn't
coexist cleanly with Kestrel-on-a-real-port, which Playwright needs.
- TestAuthHandler — stamps every request with a FleetAdmin claim so tests hit
authenticated routes without the LDAP bind.
- PlaywrightFixture — one Chromium launch shared across tests; throws
PlaywrightBrowserMissingException when the binary isn't installed so tests can
Assert.Skip rather than fail hard.
- UnsTabDragDropE2ETests.Admin_host_serves_HTTP_via_Playwright_scaffolding — proves
the full stack comes up: Kestrel bind, InMemory DbContext, test auth, Playwright
navigation, Razor route pipeline responds with HTML < 500. One passing test.
## Prerequisite
Chromium must be installed locally:
pwsh tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/bin/Debug/net10.0/playwright.ps1 install chromium
Absent the browser, the suite Assert.Skip's cleanly — CI without the install step
still reports green. Once installed, `dotnet test` runs the scaffolding smoke in ~12s.
## Follow-up (task #242)
Diagnose why `/clusters/{id}/draft/{gen}` → UNS-tab click → drag-drop flow times out
under the test-owned Program.cs replica. Candidate causes: route-ordering difference,
missing SignalR hub mapping timing, JS interop asset differences, culture middleware.
Once the interactive circuit boots, add:
- happy-path drag-drop assertion (source row → target area → Confirm → assert re-parent)
- 409 conflict variant (preview → external DB mutation → Confirm → assert red-header modal)
Ships the Part 9 alarm fidelity layer Phase 7 committed to in plan decision #5. Every scripted alarm gets a full OPC UA AlarmConditionType state machine — EnabledState, ActiveState, AckedState, ConfirmedState, ShelvingState — with persistent operator-supplied state across server restarts per Phase 7 plan decision #14. Runtime shape matches the Galaxy-native + AB CIP ALMD alarm sources: scripted alarms fan out through the existing IAlarmSource surface so Phase 6.1 AlarmTracker composition consumes them without per-source branching.
Part9StateMachine is a pure-functions module — no instance state, no I/O, no mutation. Every transition (ApplyPredicate, ApplyAcknowledge, ApplyConfirm, ApplyOneShotShelve, ApplyTimedShelve, ApplyUnshelve, ApplyEnable, ApplyDisable, ApplyAddComment, ApplyShelvingCheck) takes the current AlarmConditionState record plus the event and returns a fresh state + EmissionKind hint. Two structural invariants enforced: disabled alarms never transition ActiveState / AckedState / ConfirmedState; shelved alarms still advance state (so startup recovery reflects reality) but emit a Suppressed hint so subscribers do not see the transition. OneShot shelving expires on clear; Timed shelving expires via ApplyShelvingCheck against the UnshelveAtUtc timestamp. Comments are append-only — every acknowledge, confirm, shelve, unshelve, enable, disable, explicit add-comment, and auto-unshelve appends an AlarmComment record with user identity + timestamp + kind + text for the GxP / 21 CFR Part 11 audit surface.
AlarmConditionState is the persistent record the store saves. Fields: AlarmId, Enabled, Active, Acked, Confirmed, Shelving (kind + UnshelveAtUtc), LastTransitionUtc, LastActiveUtc, LastClearedUtc, LastAckUtc + LastAckUser + LastAckComment, LastConfirmUtc + LastConfirmUser + LastConfirmComment, Comments. Fresh factory initializes everything to the no-event position.
IAlarmStateStore is the persistence abstraction — LoadAsync, LoadAllAsync, SaveAsync, RemoveAsync. Stream E wires this to a SQL-backed store with IAuditLogger hooks; tests use InMemoryAlarmStateStore. Startup recovery per Phase 7 plan decision #14: LoadAsync runs every configured alarm predicate against current tag values to rederive ActiveState, but EnabledState / AckedState / ConfirmedState / ShelvingState + audit history are loaded verbatim from the store so operators do not re-ack after an outage and shelved alarms stay shelved through maintenance windows.
MessageTemplate implements Phase 7 plan decision #13 — static-with-substitution. {TagPath} tokens resolved at event emission time from the engine value cache. Missing paths, non-Good quality, or null values all resolve to {?} so the event still fires but the operator sees where the reference broke. ExtractTokenPaths enumerates tokens at publish time so the engine knows to subscribe to every template-referenced tag in addition to predicate-referenced tags.
AlarmPredicateContext is the ScriptContext subclass alarm scripts see. GetTag reads from the engine shared cache; SetVirtualTag is explicitly rejected at runtime with a pointed error message — alarm predicates must be pure so their output does not couple to virtual-tag state in ways that become impossible to reason about. If cross-tag side effects are needed, the operator authors a virtual tag and the alarm predicate reads it.
ScriptedAlarmEngine orchestrates. LoadAsync compiles every predicate through Stream A ScriptSandbox + ForbiddenTypeAnalyzer, runs DependencyExtractor to find the read set, adds template token paths to the input set, reports every compile failure as one aggregated InvalidOperationException (not one-at-a-time), subscribes to each unique referenced upstream path, seeds the value cache, loads persisted state for each alarm (falling back to Fresh for first-load), re-evaluates the predicate, and saves the recovered state. ChangeTrigger — when an upstream tag changes, look up every alarm referencing that path in a per-path inverse index, enqueue all of them for re-evaluation via a SemaphoreSlim-gated path. Unlike the virtual-tag engine, scripted alarms are leaves in the evaluation DAG (no alarm drives another alarm), so no topological sort is needed. Operator actions (AcknowledgeAsync, ConfirmAsync, OneShotShelveAsync, TimedShelveAsync, UnshelveAsync, EnableAsync, DisableAsync, AddCommentAsync) route through the state machine, persist, and emit if there is an emission. A 5-second shelving-check timer auto-expires Timed shelving and emits Unshelved events at the right moment. Predicate evaluation errors (script throws, timeout, compile-time reads bad tag) leave the state unchanged — the engine does NOT invent a clear transition on predicate failure. Logged as scripts-*.log Error; companion WARN in main log.
ScriptedAlarmSource implements IAlarmSource. SubscribeAlarmsAsync filter is a set of equipment-path prefixes; empty means all. AcknowledgeAsync from the base interface routes to the engine with user identity "opcua-client" — Stream G will replace this with the authenticated principal from the OPC UA dispatch layer. The adapter implements only the base IAlarmSource methods; richer Part 9 methods (Confirm, Shelve, Unshelve, AddComment) remain on the engine and will bind to OPC UA method nodes in Stream G.
47 unit tests across 5 files. Part9StateMachineTests (16) — every transition + noop edge cases: predicate true/false, same-state noop, disabled ignores predicate, acknowledge records user/comment/adds audit, idempotent acknowledge, reject no-user ack, full activate-ack-clear-confirm walk, one-shot shelve suppresses next activation, one-shot expires on clear, timed shelve requires future unshelve time, timed shelve expires via shelving-check, explicit unshelve emits, add-comment appends to audit, comments append-only through multiple operations, full lifecycle walk emits every expected EmissionKind. MessageTemplateTests (11) — no-token passthrough, single+multiple token substitution, bad quality becomes {?}, unknown path becomes {?}, null value becomes {?}, tokens with slashes+dots, empty + null template, ExtractTokenPaths returns every distinct path, whitespace inside tokens trimmed. ScriptedAlarmEngineTests (13) — load compiles+subscribes, compile failures aggregated, upstream change emits Activated, clearing emits Cleared, message template resolves at emission, ack persists to store, startup recovery preserves ack but rederives active, shelved activation state-advances but suppresses emission, runtime exception isolates to owning alarm, disable prevents activation until re-enable, AddComment appends audit without state change, SetVirtualTag from predicate rejected (state unchanged), Dispose releases upstream subscriptions. ScriptedAlarmSourceTests (5) — empty filter matches all, equipment-prefix filter, Unsubscribe stops events, AcknowledgeAsync routes with default user, null arguments rejected. FakeUpstream fixture gives tests an in-memory driver mock with subscription count tracking.
Full Phase 7 test count after Stream C: 146 green (63 Scripting + 36 VirtualTags + 47 ScriptedAlarms). Stream D (historian alarm sink with SQLite store-and-forward + Galaxy.Host IPC) consumes ScriptedAlarmEvent + similar Galaxy / AB CIP emissions to produce the unified alarm timeline. Stream G wires the OPC UA method calls and AlarmSource into DriverNodeManager dispatch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ships the evaluation engine that consumes compiled scripts from Stream A, subscribes to upstream driver tags, runs on change + on timer, cascades evaluations through dependent virtual tags in topological order, and emits changes through a driver-capability-shaped adapter the DriverNodeManager can dispatch to per ADR-002.
DependencyGraph owns the directed dep-graph where nodes are tag paths (driver tags implicit leaves, virtual tags registered internal nodes) and edges run from a virtual tag to each tag it reads. Kahn algorithm produces the topological sort. Tarjan iterative SCC detects every cycle in one pass so publish-time rejection surfaces all offending cycles together. Both iterative so 10k-deep chains do not StackOverflow. Re-adding a node overwrites prior dependency set cleanly (supports config-publish reloads).
VirtualTagDefinition is the operator-authored config row (Path, DataType, ScriptSource, ChangeTriggered, TimerInterval, Historize). Stream E config DB materializes these on publish.
ITagUpstreamSource is the abstraction the engine pulls driver tag values from. Stream G bridges this to IReadable + ISubscribable on live drivers; tests use FakeUpstream that tracks subscription count for leak-test assertions.
IHistoryWriter is the per-tag Historize sink. NullHistoryWriter default when caller does not pass one.
VirtualTagContext is the per-evaluation ScriptContext. Reads from engine last-known-value cache, writes route through SetVirtualTag callback so cross-tag side effects participate in change cascades. Injectable Now clock for deterministic tests.
VirtualTagEngine orchestrates. Load compiles every script via ScriptSandbox, builds the dep graph via DependencyExtractor, checks for cycles, reports every compile failure in one error, subscribes to each referenced upstream path, seeds the value cache. EvaluateAllAsync runs topological order. EvaluateOneAsync is timer path. Read returns cached value. Subscribe registers observer. OnUpstreamChange updates cache, fans out, schedules transitive dependents (change-driven=false tags skipped). EvaluateInternalAsync holds a SemaphoreSlim so cascades do not interleave. Script exceptions and timeouts map per-tag to BadInternalError. Coercion from script double to config Int32 uses Convert.ToInt32.
TimerTriggerScheduler groups tags by interval into shared Timers. Tags without TimerInterval not scheduled.
VirtualTagSource implements IReadable + ISubscribable per ADR-002. ReadAsync returns cache. SubscribeAsync fires initial-data callback per OPC UA convention. IWritable deliberately not implemented — OPC UA writes to virtual tags rejected in DriverNodeManager per Phase 7 decision 6.
36 unit tests across 4 files: DependencyGraphTests 12, VirtualTagEngineTests 13, VirtualTagSourceTests 6, TimerTriggerSchedulerTests 4. Coverage includes cycle detection (self-loop, 2-node, 3-node, multiple disjoint), 2-level change cascade, per-tag error isolation (one tag throws, others keep working), timeout isolation, Historize toggle, ChangeTriggered=false ignore, reload cleans subscriptions, Dispose releases resources, SetVirtualTag fires observers, type coercion, 10k deep graph no stack overflow, initial-data callback, Unsubscribe stops events.
Fixed two bugs during implementation. Monitor.Enter/Exit cannot be held across await (Monitor ownership is thread-local and lost across suspension) — switched to SemaphoreSlim. Kahn edge-direction was inverted — for dependency ordering (X depends on Y means Y comes before X) in-degree should be count of a node own deps, not count of nodes pointing to it; was incrementing inDegree[dep] instead of inDegree[nodeId], causing false cycle detection on valid DAGs.
Full Phase 7 test count after Stream B: 99 green (63 Scripting + 36 VirtualTags). Streams C and G will plug engine + source into live OPC UA dispatch path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ScriptContext abstract base defines the API user scripts see as ctx — GetTag(string) returns DataValueSnapshot so scripts branch on quality naturally, SetVirtualTag(string, object?) is the only write path virtual tags have (OPC UA client writes to virtual nodes rejected separately in DriverNodeManager per ADR-002), Now + Logger + Deadband static helper round out the surface. Concrete subclasses in Streams B + C plug in actual tag backends + per-script Serilog loggers.
ScriptSandbox.Build(contextType) produces the ScriptOptions for every compile — explicit allow-list of six assemblies (System.Private.CoreLib / System.Linq / Core.Abstractions / Core.Scripting / Serilog / the context type's own assembly), with a matching import list so scripts don't need using clauses. Allow-list is plan-level — expanding it is not a casual change.
DependencyExtractor uses CSharpSyntaxWalker to find every ctx.GetTag("literal") and ctx.SetVirtualTag("literal", ...) call, rejects every non-literal path (variable, concatenation, interpolation, method-returned). Rejections carry the exact TextSpan so the Admin UI can point at the offending token. Reads + writes are returned as two separate sets so the virtual-tag engine (Stream B) knows both the subscription targets and the write targets.
Sandbox enforcement turned out needing a second-pass semantic analyzer because .NET 10's type forwarding makes assembly-level restriction leaky — System.Net.Http.HttpClient resolves even with WithReferences limited to six assemblies. ForbiddenTypeAnalyzer runs after Roslyn's Compile() against the SemanticModel, walks every ObjectCreationExpression / InvocationExpression / MemberAccessExpression / IdentifierName, resolves to the containing type's namespace, and rejects any prefix-match against the deny-list (System.IO, System.Net, System.Diagnostics, System.Reflection, System.Threading.Thread, System.Runtime.InteropServices, Microsoft.Win32). Rejections throw ScriptSandboxViolationException with the aggregated list + source spans so the Admin UI surfaces every violation in one round-trip instead of whack-a-mole. System.Environment explicitly stays allowed (read-only process state, doesn't persist or leak outside) and that compromise is pinned by a dedicated test.
ScriptGlobals<TContext> wraps the context as a named field so scripts see ctx instead of the bare globalsType-member-access convention Roslyn defaults to — keeps script ergonomics (ctx.GetTag) consistent with the AST walker's parse shape and the Admin UI's hand-written type stub (coming in Stream F). Generic on TContext so Stream C's alarm-predicate context with an Alarm property inherits cleanly.
ScriptEvaluator<TContext, TResult>.Compile is the three-step gate: (1) Roslyn compile — throws CompilationErrorException on syntax/type errors with Location-carrying diagnostics; (2) ForbiddenTypeAnalyzer semantic pass — catches type-forwarding sandbox escapes; (3) delegate creation. Runtime exceptions from user code propagate unwrapped — the virtual-tag engine in Stream B catches + maps per-tag to BadInternalError quality per Phase 7 decision #11.
29 unit tests covering every surface: DependencyExtractorTests has 14 theories — single/multiple/deduplicated reads, separate write tracking, rejection of variable/concatenated/interpolated/method-returned/empty/whitespace paths, ignoring non-ctx methods named GetTag, empty-source no-op, source span carried in rejections, multiple bad paths reported in one pass, nested literal extraction. ScriptSandboxTests has 15 — happy-path compile + run, SetVirtualTag round-trip, rejection of File.IO + HttpClient + Process.Start + Reflection.Assembly.Load via ScriptSandboxViolationException, Environment.GetEnvironmentVariable explicitly allowed (pinned compromise), script-exception propagation, ctx.Now reachable, Deadband static reachable, LINQ Where/Sum reachable, DataValueSnapshot usable in scripts including quality branches, compile error carries source location.
Next two PRs within Stream A: A.2 adds the compile cache (source-hash keyed) + per-evaluation timeout wrapper; A.3 wires the dedicated scripts-*.log Serilog rolling sink with structured-property filtering + the companion-warning enricher to the main log.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New project tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/ with four pieces. AbLegacyServerFixture — TCP probe against localhost:44818 (or AB_LEGACY_ENDPOINT override), distinct from AB_SERVER_ENDPOINT so both CIP + PCCC containers can run simultaneously. Single-public-ctor to satisfy xunit collection-fixture constraint. AbLegacyServerProfile + KnownProfiles carry the per-family (SLC500 / MicroLogix / PLC-5) ComposeProfile + Notes; drives per-theory parameterisation. AbLegacyFactAttribute / AbLegacyTheoryAttribute match the AB CIP skip-attribute pattern.
Docker/docker-compose.yml reuses the AB CIP otopcua-ab-server:libplctag-release image — `build:` block points at ../../AbCip.IntegrationTests/Docker context so `docker compose build` from here produces / reuses the same multi-stage build. Three compose profiles (slc500 / micrologix / plc5) with per-family `--plc` + `--tag=<file>[<size>]` flags matching the PCCC tag syntax (different from CIP's `Name:Type[size]`).
AbLegacyReadSmokeTests — one parametric theory reading N7:0 across all three families + one SLC500 write-then-read on N7:5. Targets the shape the driver would use against real hardware. Verified 2026-04-20 against a live SLC500 container: TCP probe passes + container accepts connections + libplctag negotiates session, but read/write returns BadCommunicationError (libplctag status 0x80050000). Root-caused to ab_server's PCCC server-side opcode coverage being narrower than libplctag's PCCC client expects — not a driver-side bug, not a scaffold bug, just an ab_server upstream limitation. Documented honestly in Docker/README.md + AbLegacy-Test-Fixture.md rather than skipping the tests or weakening assertions; tests now skip cleanly when container is absent, fail with clear message when container is up but the protocol gap surfaces. Operator resolves by filing an ab_server upstream patch, pointing AB_LEGACY_ENDPOINT at real hardware, or scaffolding an RSEmulate 500 golden-box tier.
Docker/README.md — Known limitations section leads with the PCCC round-trip gap (test date, failure signature, possible root causes, three resolution paths) before the pre-existing limitations (T/C file decomposition, ST file quirks, indirect addressing, DF1 serial). Reader can't miss the "scaffolded but blocked on upstream" framing.
docs/drivers/AbLegacy-Test-Fixture.md — TL;DR flipped from "no integration fixture" to "Docker scaffold in place; wire-level round-trip currently blocked by ab_server PCCC gap". What-the-fixture-is gains an Integration section. Follow-up candidates rewritten: #1 is now "fix ab_server PCCC upstream", #2 is RSEmulate 500 golden-box (with cost callouts matching our existing Logix Emulate + TwinCAT XAR scaffolds — license + Hyper-V conflict + binary project format), #3 is lab rig. Key-files list adds the four new files. docs/drivers/README.md coverage-map row updated from "no integration fixture" to "Docker scaffold via ab_server PCCC; wire-level round-trip currently blocked, docs call out resolution paths".
Solution file picks up the new tests/.../AbLegacy.IntegrationTests entry. AbLegacyDataType.Int used throughout (not Int16 — the enum uses SLC file-type naming). Build 0 errors; 2 smoke tests skip cleanly without container + fail with clear errors when container up (proving the infrastructure works end-to-end + the gap is specifically the ab_server protocol coverage, not the scaffold).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>