Files
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AB Legacy PR 3 — ITagDiscovery + ISubscribable + IHostConnectivityProbe + IPerCallHostResolver. Fills out the AbLegacy capability surface — the driver now implements the same 7-interface set as AbCip (IDriver + IReadable + IWritable + ITagDiscovery + ISubscribable + IHostConnectivityProbe + IPerCallHostResolver). ITagDiscovery emits pre-declared tags under an AbLegacy root folder with a per-device sub-folder keyed on HostAddress (DeviceName fallback to HostAddress when null). Writable tags surface as SecurityClassification.Operate, non-writable as ViewOnly. No controller-side enumeration — PCCC has no @tags equivalent on SLC / MicroLogix / PLC-5 (symbol table isn't exposed the way Logix exposes it), so the pre-declared path is the only discovery mechanism. ISubscribable consumes the shared PollGroupEngine extracted in AB CIP PR 1 — reader delegate points at ReadAsync (already handles lazy runtime init + caching), onChange bridges into the driver's OnDataChange event. 100ms interval floor. Initial-data push on first poll. Makes AbLegacy the third consumer of PollGroupEngine (after Modbus and AbCip). IHostConnectivityProbe — per-device probe loop when ProbeOptions.Enabled + ProbeAddress configured (defaults to S:0 status file word 0). Lazy-init on first tick, re-init on wire failure (destroyed native handle gets recreated rather than silently staying broken). Success transitions device to Running, exception to Stopped, same-state spurious event guard under per-device lock. GetHostStatuses returns one entry per device with current state + last-change timestamp for Admin /hosts surfacing. IPerCallHostResolver maps tag full-ref → DeviceHostAddress for the Phase 6.1 (DriverInstanceId, ResolvedHostName) bulkhead/breaker keying per plan decision #144. Unknown refs fall back to first device's address (invoker handles at capability level as BadNodeIdUnknown); no devices → DriverInstanceId. ShutdownAsync cancels + disposes each probe CTS, disposes PollGroupEngine cancelling active subscriptions, disposes every cached runtime. DeviceState gains ProbeLock / HostState / HostStateChangedUtc / ProbeCts / ProbeInitialized matching AbCip's DeviceState shape. 10 new unit tests in AbLegacyCapabilityTests covering — pre-declared tags emit under AbLegacy/device folder with correct SecurityClassification, subscription initial poll raises OnDataChange with correct value, unsubscribe halts polling (value change post-unsub produces no further events), GetHostStatuses returns one entry per device, probe Running transition on successful read, probe Stopped transition on read exception, probe disabled when ProbeAddress null, ResolveHost returns declared device for known tag, falls back to first device for unknown, falls back to DriverInstanceId when no devices. Total AbLegacy unit tests now 92/92 passing (+10 from PR 2's 82); full solution builds 0 errors; AbCip + Modbus + other drivers untouched. AB Legacy driver now complete end-to-end — SLC 500 / MicroLogix / PLC-5 / LogixPccc all shippable with read / write / discovery / subscribe / probe / host-resolve, feature-parity with AbCip minus IAlarmSource (same deferral per plan).
FOCAS PR 2 — IReadable + IWritable + real FwlibFocasClient P/Invoke. Closes task #193 early now that strangesast/fwlib provides the licensed DLL references. Skips shipping with the Unimplemented stub as the default — FwlibFocasClientFactory is now the production default, UnimplementedFocasClientFactory stays as an opt-in for tests/deployments without FWLIB access. FwlibNative — narrow P/Invoke surface for the 7 calls the driver actually makes: cnc_allclibhndl3 (open Ethernet handle), cnc_freelibhndl (close), pmc_rdpmcrng + pmc_wrpmcrng (PMC range I/O), cnc_rdparam + cnc_wrparam (CNC parameters), cnc_rdmacro + cnc_wrmacro (macro variables), cnc_statinfo (probe). DllImport targets Fwlib32.dll; deployment places it next to the executable or on PATH. IODBPMC/IODBPSD/ODBM/ODBST marshaled with LayoutKind.Sequential + Pack=1 + fixed byte-array unions (avoids LayoutKind.Explicit complexity; managed-side BitConverter extracts typed values from the byte buffer). Internal helpers FocasPmcAddrType.FromLetter (G=0/F=1/Y=2/X=3/A=4/R=5/T=6/K=7/C=8/D=9/E=10 per Fanuc FOCAS/2 spec) + FocasPmcDataType.FromFocasDataType (Byte=0 / Word=1 / Long=2 / Float=4 / Double=5) exposed for testing without the DLL loaded. FwlibFocasClient is the concrete IFocasClient backed by P/Invoke. Construction is licence-safe — .NET P/Invoke is lazy so instantiating the class does NOT load Fwlib32.dll; DLL loads on first wire call (Connect/Read/Write/Probe). When missing, calls throw DllNotFoundException which the driver surfaces as BadCommunicationError via the normal exception path. Session-scoped handle from cnc_allclibhndl3; Dispose calls cnc_freelibhndl. Dispatch on FocasAreaKind — Pmc reads use pmc_rdpmcrng with the right ADR_* + data-type codes + parses the union via BinaryPrimitives LittleEndian, Parameter reads use cnc_rdparam + IODBPSD, Macro reads use cnc_rdmacro + compute scaled double as McrVal / 10^DecVal. Write paths mirror reads. PMC Bit writes throw NotSupportedException pointing at task #181 (read-modify-write gap — same as Modbus / AbCip / AbLegacy / TwinCAT). Macro writes accept int + pass decimal-point count 0 (decimal precision writes are a future enhancement). Probe calls cnc_statinfo with ODBST result. Driver wiring — FocasDriver now IDriver + IReadable + IWritable. Per-device connection caching via EnsureConnectedAsync + DeviceState.Client. ReadAsync/WriteAsync dispatch through the injected IFocasClient — ordered snapshots preserve per-tag status, OperationCanceledException rethrows, FormatException/InvalidCastException → BadTypeMismatch, OverflowException → BadOutOfRange, NotSupportedException → BadNotSupported, anything else → BadCommunicationError + Degraded health. Connect-failure disposes the half-open client. ShutdownAsync disposes every cached client. Default factory switched — constructor now defaults to FwlibFocasClientFactory (backed by real Fwlib32.dll) rather than UnimplementedFocasClientFactory. UnimplementedFocasClientFactory stays as an opt-in. 41 new tests — 14 in FocasReadWriteTests (ordered unknown-ref handling, successful PMC/Parameter/Macro reads routing through correct FocasAreaKind, repeat-read reuses connection, FOCAS error mapping, exception paths, batched order across areas, non-writable rejection, successful write logging, status mapping, batch ordering, cancellation, shutdown disposes), 27 in FwlibNativeHelperTests (12 letter-mapping cases + 3 unknown rejections + 6 data-type mapping + 4 encode helpers + Bit-write NotSupported). Total FOCAS unit tests now 106/106 passing (+41 from PR 1's 65); full solution builds 0 errors; Modbus / AbCip / AbLegacy / TwinCAT / other drivers untouched. FOCAS driver is real-wire-capable from day one — deployment drops Fwlib32.dll beside the server + driver talks to live FS 0i/16i/18i/21i/30i/31i/32i controllers.
Phase 3 PR 40 — LiveStackSmokeTests: write-roundtrip + subscribe-receives-OnDataChange against the live Galaxy. Finishes LMX #5 by exercising the IWritable + ISubscribable capability paths end-to-end through the Proxy → OtOpcUaGalaxyHost service → MXAccess → real Galaxy.
Phase 1 Streams B–E scaffold + Phase 2 Streams A–C scaffold — 8 new projects with ~70 new tests, all green alongside the 494 v1 IntegrationTests baseline (parity preserved: no v1 tests broken; legacy OtOpcUa.Host untouched). Phase 1 finish: Configuration project (16 entities + 10 enums + DbContext + DesignTimeDbContextFactory + InitialSchema/StoredProcedures/AuthorizationGrants migrations — 8 procs including sp_PublishGeneration with MERGE on ExternalIdReservation per decision #124, sp_RollbackToGeneration cloning rows into a new published generation, sp_ValidateDraft with cross-cluster-namespace + EquipmentUuid-immutability + ZTag/SAPID reservation pre-flight, sp_ComputeGenerationDiff with CHECKSUM-based row signature — plus OtOpcUaNode/OtOpcUaAdmin SQL roles with EXECUTE grants scoped to per-principal-class proc sets and DENY UPDATE/DELETE/INSERT/SELECT on dbo schema); managed DraftValidator covering UNS segment regex, path length, EquipmentUuid immutability across generations, same-cluster namespace binding (decision #122), reservation pre-flight, EquipmentId derivation (decision #125), driver↔namespace compatibility — returning every failing rule in one pass; LiteDB local cache with round-trip + ring pruning + corruption-fast-fail; GenerationApplier with per-entity Added/Removed/Modified diff and dependency-ordered callbacks (namespace → driver → device → equipment → poll-group → tag, Removed before Added); Core project with GenericDriverNodeManager (scaffold for the Phase 2 Galaxy port) and DriverHost lifecycle registry; Server project using Microsoft.Extensions.Hosting BackgroundService replacing TopShelf, with NodeBootstrap that falls back to LiteDB cache when the central DB is unreachable (decision #79); Admin project scaffolded as Blazor Server with Bootstrap 5 sidebar layout, cookie auth, three admin roles (ConfigViewer/ConfigEditor/FleetAdmin), Cluster + Generation services fronting the stored procs. Phase 2 scaffold: Driver.Galaxy.Shared (netstandard2.0) with full MessagePack IPC contract surface — Hello version negotiation, Open/CloseSession, Heartbeat, DiscoverHierarchy + GalaxyObjectInfo/GalaxyAttributeInfo, Read/WriteValues, Subscribe/Unsubscribe/OnDataChange, AlarmSubscribe/Event/Ack, HistoryRead, HostConnectivityStatus, Recycle — plus length-prefixed framing (decision #28) with a 16 MiB cap and thread-safe FrameWriter/FrameReader; Driver.Galaxy.Host (net48) implementing the Tier C cross-cutting protections from driver-stability.md — strict PipeAcl (allow configured server SID only, explicit deny on LocalSystem + Administrators), PipeServer with caller-SID verification via pipe.RunAsClient + WindowsIdentity.GetCurrent and per-process shared-secret Hello, Galaxy-specific MemoryWatchdog (warn at max(1.5×baseline, +200 MB), soft-recycle at max(2×baseline, +200 MB), hard ceiling 1.5 GB, slope ≥5 MB/min over 30-min rolling window), RecyclePolicy (1 soft recycle per hour cap + 03:00 local daily scheduled), PostMortemMmf (1000-entry ring buffer in %ProgramData%\OtOpcUa\driver-postmortem\galaxy.mmf, survives hard crash, readable cross-process), MxAccessHandle : SafeHandle (ReleaseHandle loops Marshal.ReleaseComObject until refcount=0 then calls optional unregister callback), StaPump with responsiveness probe (BlockingCollection dispatcher for Phase 1 — real Win32 GetMessage/DispatchMessage pump slots in with the same semantics when the Galaxy code lift happens), IsExternalInit shim for init setters on .NET 4.8; Driver.Galaxy.Proxy (net10) implementing IDriver + ITagDiscovery forwarding over the IPC channel with MX data-type and security-classification mapping, plus Supervisor pieces — Backoff (5s → 15s → 60s capped, reset-on-stable-run), CircuitBreaker (3 crashes per 5 min opens; 1h → 4h → manual cooldown escalation; sticky alert doesn't auto-clear), HeartbeatMonitor (2s cadence, 3 consecutive misses = host dead per driver-stability.md). Infrastructure: docker SQL Server remapped to host port 14330 to coexist with the native MSSQL14 Galaxy ZB DB instance on 1433; NuGetAuditSuppress applied per-project for two System.Security.Cryptography.Xml advisories that only reach via EF Core Design with PrivateAssets=all (fix ships in 11.0.0-preview); .slnx gains 14 project registrations. Deferred with explicit TODOs in docs/v2/implementation/phase-2-partial-exit-evidence.md: Phase 1 Stream E Admin UI pages (Generations listing + draft-diff-publish, Equipment CRUD with OPC 40010 fields, UNS Areas/Lines tabs, ACLs + permission simulator, Generic JSON config editor, SignalR real-time, Release-Reservation + Merge-Equipment workflows, LDAP login page, AppServer smoke test per decision #142), Phase 2 Stream D (Galaxy MXAccess code lift out of legacy OtOpcUa.Host, dual-service installer, appsettings → DriverConfig migration script, legacy Host deletion — blocked by parity), Phase 2 Stream E (v1 IntegrationTests against v2 topology, Client.CLI walkthrough diff, four 2026-04-13 stability findings regression tests, adversarial review — requires live MXAccess runtime).
TwinCAT follow-up — Native ADS notifications for ISubscribable. Closes task #189 — upgrades TwinCATDriver's subscription path from polling (shared PollGroupEngine) to native AdsClient.AddDeviceNotificationExAsync so the PLC pushes changes on its own cycle rather than the driver polling. Strictly better for latency + CPU — TC2 and TC3 runtimes notify on value change with sub-millisecond latency from the PLC cycle. ITwinCATClient gains AddNotificationAsync — takes symbolPath + TwinCATDataType + optional bitIndex + cycleTime + onChange callback + CancellationToken; returns an ITwinCATNotificationHandle whose Dispose tears the notification down on the wire. Bit-within-word reads supported — the parent word value arrives via the notification, driver extracts the bit before invoking the callback (same ExtractBit path as the read surface from PR 2). AdsTwinCATClient — subscribes to AdsClient.AdsNotificationEx in the ctor, maintains a ConcurrentDictionary<uint, NotificationRegistration> keyed on the server-side notification handle. AddDeviceNotificationExAsync returns Task<ResultHandle> with Handle + ErrorCode; non-NoError throws InvalidOperationException so the driver can catch + retry. Notification event args carry Handle + Value + DataType; lookup in _notifications dict routes the value through any bit-extraction + calls the consumer callback. Consumer-side exceptions are swallowed so a misbehaving callback can't crash the ADS notification thread. Dispose unsubscribes from AdsNotificationEx + clears the dict + disposes AdsClient. NotificationRegistration is ITwinCATNotificationHandle — Dispose fires DeleteDeviceNotificationAsync as fire-and-forget with CancellationToken.None (caller has already committed to teardown; blocking would slow shutdown). TwinCATDriverOptions.UseNativeNotifications — new bool, default true. When true the driver uses native notifications; when false it falls through to the shared PollGroupEngine (same semantics as other libplctag-backed drivers, also a safety valve for targets with notification limits). TwinCATDriver.SubscribeAsync dual-path — if UseNativeNotifications false delegate into _poll.Subscribe (unchanged behavior from PR 3). If true, iterate fullReferences, resolve each to its device's client via EnsureConnectedAsync (reuses PR 2's per-device connection cache), parse the SymbolPath via TwinCATSymbolPath (preserves bit-in-word support), call ITwinCATClient.AddNotificationAsync with a closure over the FullReference (not the ADS symbol — OPC UA subscribers addressed the driver-side name). Per-registration callback bridges (_, value) → OnDataChange event with a fresh DataValueSnapshot (Good status, current UtcNow timestamps). Any mid-registration failure triggers a try/catch that disposes every already-registered handle before rethrowing, keeping the driver in a clean never-existed state rather than half-registered. UnsubscribeAsync dispatches on handle type — NativeSubscriptionHandle disposes all its cached ITwinCATNotificationHandles; anything else delegates to _poll.Unsubscribe for the poll fallback. ShutdownAsync tears down native subs first (so AdsClient-level cleanup happens before the client itself disposes), then PollGroupEngine, then per-device probe CTS + client. NativeSubscriptionHandle DiagnosticId prefixes with twincat-native-sub- so Admin UI + logs can distinguish the paths. 9 new unit tests in TwinCATNativeNotificationTests — native subscribe registers one notification per tag, pushed value via FireNotification fires OnDataChange with the right FullReference (driver-side, not ADS symbol), unsubscribe disposes all notifications, unsubscribe halts future notifications, partial-failure cleanup via FailAfterNAddsFake (first succeeds, second throws → first gets torn down + Notifications count returns to 0 + AddCallCount=2 proving the test actually exercised both calls), shutdown disposes subscriptions, poll fallback works when UseNativeNotifications=false (no native handles created + initial-data push still fires), handle DiagnosticId distinguishes native vs poll. Existing poll-mode ISubscribable tests in TwinCATCapabilityTests updated with UseNativeNotifications=false so they continue testing the poll path specifically — both poll + native paths have test coverage now. TwinCATDriverTests got Probe.Enabled=false added because the default factory creates a real AdsClient which was flakily affected by parallel test execution sharing AMS router state. Total TwinCAT unit tests now 93/93 passing (+8 from PR 3's 85 counting the new native tests + 2 existing tests that got options tweaks). Full solution builds 0 errors; Modbus / AbCip / AbLegacy / other drivers untouched. TwinCAT driver is now feature-complete end-to-end — read / write / discover / native-subscribe / probe / host-resolve, with poll-mode as a safety valve. Unblocks closing task #120 for TwinCAT; remaining sub-task: FOCAS + task #188 (symbol-browsing — lower priority than FOCAS since real config flows still use pre-declared tags).