diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx index 0b6f6d5..33fe998 100644 --- a/ZB.MOM.WW.OtOpcUa.slnx +++ b/ZB.MOM.WW.OtOpcUa.slnx @@ -1,5 +1,6 @@ + @@ -7,6 +8,7 @@ + diff --git a/docs/v2/implementation/entry-gate-phase-1.md b/docs/v2/implementation/entry-gate-phase-1.md new file mode 100644 index 0000000..8d2a126 --- /dev/null +++ b/docs/v2/implementation/entry-gate-phase-1.md @@ -0,0 +1,56 @@ +# Phase 1 — Entry Gate Record + +**Phase**: 1 — Configuration project + Core.Abstractions + Admin scaffold +**Branch**: `phase-1-configuration` +**Date**: 2026-04-17 +**Implementation lead**: Claude (executing on behalf of dohertj2) + +## Entry conditions + +| Check | Required | Actual | Pass | +|-------|----------|--------|------| +| Phase 0 exit gate cleared | Rename complete, all v1 tests pass under OtOpcUa names | Phase 0 merged to `v2` at commit `45ffa3e` | ✅ | +| `v2` branch is clean | Clean | Clean post-merge | ✅ | +| Phase 0 PR merged | — | Merged via `--no-ff` to v2 | ✅ | +| SQL Server 2019+ instance available | For development | NOT YET AVAILABLE — see deviation below | ⚠️ | +| LDAP/GLAuth dev instance available | For Admin auth integration testing | Existing v1 GLAuth at `C:\publish\glauth\` | ✅ | +| ScadaLink CentralUI source accessible | For parity reference | `C:\Users\dohertj2\Desktop\scadalink-design\` per memory | ✅ | +| Phase 1-relevant design docs reviewed | All read by impl lead | ✅ Read in preceding sessions | ✅ | +| Decisions read | #1–142 covered cumulatively | ✅ | ✅ | + +## Deviation: SQL Server dev instance not yet stood up + +The Phase 1 entry gate requires a SQL Server 2019+ dev instance for the `Configuration` project's EF Core migrations + tests. This is per `dev-environment.md` Step 1, which is currently TODO. + +**Decision**: proceed with **Stream A only** (Core.Abstractions) in this continuation. Stream A has zero infrastructure dependencies — it's a `.NET 10` project with BCL-only references defining capability interfaces and DTOs. Streams B (Configuration), C (Core), D (Server), and E (Admin) all have infrastructure dependencies (SQL Server, GLAuth, Galaxy) and require the dev environment standup to be productive. + +The SQL Server standup is a one-line `docker run` per `dev-environment.md` §"Bootstrap Order — Inner-loop Developer Machine" step 5. It can happen in parallel with subsequent Stream A work but is not a blocker for Stream A itself. + +**This continuation will execute only Stream A.** Streams B–E require their own continuations after the dev environment is stood up. + +## Phase 1 work scope (for reference) + +Per `phase-1-configuration-and-admin-scaffold.md`: + +| Stream | Scope | Status this continuation | +|--------|-------|--------------------------| +| **A. Core.Abstractions** | 11 capability interfaces + DTOs + DriverTypeRegistry | ▶ EXECUTING | +| B. Configuration | EF Core schema, stored procs, LiteDB cache, generation-diff applier | DEFERRED — needs SQL Server | +| C. Core | `LmxNodeManager → GenericDriverNodeManager` rename, `IAddressSpaceBuilder`, driver hosting | DEFERRED — depends on Stream A + needs Galaxy | +| D. Server | `Microsoft.Extensions.Hosting` host, credential-bound bootstrap | DEFERRED — depends on Stream B | +| E. Admin | Blazor Server scaffold mirroring ScadaLink | DEFERRED — depends on Stream B | + +## Baseline metrics (carried from Phase 0 exit) + +- **Total tests**: 822 (pass + fail) +- **Pass count**: 821 (improved from baseline 820 — one flaky test happened to pass at Phase 0 exit) +- **Fail count**: 1 (the second pre-existing failure may flap; either 1 or 2 failures is consistent with baseline) +- **Build warnings**: 30 (lower than original baseline 167) +- **Build errors**: 0 + +Phase 1 must not introduce new failures or new errors against this baseline. + +## Signoff + +Implementation lead: Claude (Opus 4.7) — 2026-04-17 +Reviewer: pending — Stream A PR will require a second reviewer per overview.md exit-gate rules diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DataValueSnapshot.cs b/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DataValueSnapshot.cs new file mode 100644 index 0000000..e247e90 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DataValueSnapshot.cs @@ -0,0 +1,21 @@ +namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +/// +/// Driver-agnostic value snapshot returned by and pushed +/// by . Mirrors the OPC UA DataValue +/// shape so the node-manager can pass through quality, source timestamp, and +/// server timestamp without translation. +/// +/// +/// Per docs/v2/plan.md decision #13 — every driver maps to the same +/// OPC UA StatusCode space; this DTO is the universal carrier. +/// +/// The raw value; null when indicates Bad. +/// OPC UA status code (numeric value matches the OPC UA spec). +/// Driver-side timestamp when the value was sampled at the source. Null if unavailable. +/// Driver-side timestamp when the driver received / processed the value. +public sealed record DataValueSnapshot( + object? Value, + uint StatusCode, + DateTime? SourceTimestampUtc, + DateTime ServerTimestampUtc); diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverAttributeInfo.cs b/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverAttributeInfo.cs new file mode 100644 index 0000000..bbe649d --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverAttributeInfo.cs @@ -0,0 +1,28 @@ +namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +/// +/// Driver-agnostic per-attribute (tag) descriptor used by the generic node-manager +/// to build OPC UA address-space variables. Every driver maps its native attribute +/// metadata into this DTO during discovery. +/// +/// +/// Per docs/v2/plan.md §5a (LmxNodeManager reusability) — DriverAttributeInfo +/// replaces the v1 Galaxy-specific GalaxyAttributeInfo in the generic node-manager +/// so the same node-manager class works against every driver. +/// +/// +/// Driver-side full reference for read/write addressing +/// (e.g. for Galaxy: "DelmiaReceiver_001.DownloadPath"). +/// +/// Driver-agnostic data type; maps to OPC UA built-in type at build time. +/// True when this attribute is a 1-D array. +/// Declared array length when is true; null otherwise. +/// Write-authorization tier for this attribute. +/// True when this attribute is expected to feed historian / HistoryRead. +public sealed record DriverAttributeInfo( + string FullName, + DriverDataType DriverDataType, + bool IsArray, + uint? ArrayDim, + SecurityClassification SecurityClass, + bool IsHistorized); diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverDataType.cs b/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverDataType.cs new file mode 100644 index 0000000..f980d95 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverDataType.cs @@ -0,0 +1,28 @@ +namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +/// +/// Driver-agnostic data type for an attribute or signal. +/// Maps to OPC UA built-in types at the address-space build layer. +/// +/// +/// Per docs/v2/driver-specs.md driver DataType columns, every driver maps its +/// native types into this enumeration. Mirrors the OPC UA built-in type set commonly +/// seen across Modbus / S7 / AB CIP / TwinCAT / FANUC / Galaxy. +/// +public enum DriverDataType +{ + Boolean, + Int16, + Int32, + Int64, + UInt16, + UInt32, + UInt64, + Float32, + Float64, + String, + DateTime, + + /// Galaxy-style attribute reference encoded as an OPC UA String. + Reference, +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverHealth.cs b/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverHealth.cs new file mode 100644 index 0000000..4ad1b3a --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverHealth.cs @@ -0,0 +1,38 @@ +namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +/// +/// Health snapshot a driver returns to the Core. Drives the status dashboard, +/// ServiceLevel computation, and Bad-quality fan-out decisions. +/// +/// Current driver-instance state. +/// Timestamp of the most recent successful equipment read; null if never. +/// Most recent error message; null when state is Healthy. +public sealed record DriverHealth( + DriverState State, + DateTime? LastSuccessfulRead, + string? LastError); + +/// Driver-instance lifecycle state. +public enum DriverState +{ + /// Driver has not been initialized yet. + Unknown, + + /// Driver is in the middle of or . + Initializing, + + /// Driver is connected and serving data. + Healthy, + + /// Driver is connected but reporting degraded data (e.g. some equipment unreachable, some tags Bad). + Degraded, + + /// Driver lost connection to its data source; reconnecting in the background. + Reconnecting, + + /// + /// Driver hit an unrecoverable error and stopped trying. + /// Operator must reinitialize via Admin UI; nodes report Bad quality. + /// + Faulted, +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeRegistry.cs b/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeRegistry.cs new file mode 100644 index 0000000..6655886 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeRegistry.cs @@ -0,0 +1,94 @@ +namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +/// +/// Process-singleton registry of driver types known to this OtOpcUa instance. +/// Per-driver assemblies register their type metadata at startup; the Core uses +/// the registry to validate DriverInstance.DriverType values from the central config DB. +/// +/// +/// Per docs/v2/plan.md decisions #91 (JSON content validation in Admin app, not SQL CLR) +/// and #111 (driver type → namespace kind mapping enforced by sp_ValidateDraft). +/// The registry is the source of truth for both checks. +/// +/// Thread-safety: registration happens at startup (single thread); lookups happen on every +/// config-apply (multi-threaded). The internal dictionary is replaced atomically via +/// on register; readers see a stable snapshot. +/// +public sealed class DriverTypeRegistry +{ + private IReadOnlyDictionary _types = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + /// Register a driver type. Throws if the type name is already registered. + public void Register(DriverTypeMetadata metadata) + { + ArgumentNullException.ThrowIfNull(metadata); + + var snapshot = _types; + if (snapshot.ContainsKey(metadata.TypeName)) + { + throw new InvalidOperationException( + $"Driver type '{metadata.TypeName}' is already registered. " + + $"Each driver type may be registered only once per process."); + } + + var next = new Dictionary(snapshot, StringComparer.OrdinalIgnoreCase) + { + [metadata.TypeName] = metadata, + }; + Interlocked.Exchange(ref _types, next); + } + + /// Look up a driver type by name. Throws if unknown. + public DriverTypeMetadata Get(string driverType) + { + ArgumentException.ThrowIfNullOrWhiteSpace(driverType); + + if (_types.TryGetValue(driverType, out var metadata)) + return metadata; + + throw new KeyNotFoundException( + $"Driver type '{driverType}' is not registered. " + + $"Known types: {string.Join(", ", _types.Keys)}."); + } + + /// Try to look up a driver type by name. Returns null if unknown (no exception). + public DriverTypeMetadata? TryGet(string driverType) + { + ArgumentException.ThrowIfNullOrWhiteSpace(driverType); + return _types.GetValueOrDefault(driverType); + } + + /// Snapshot of all registered driver types. + public IReadOnlyCollection All() => _types.Values.ToList(); +} + +/// Per-driver-type metadata used by the Core, validator, and Admin UI. +/// Driver type name (matches DriverInstance.DriverType column values). +/// Which namespace kinds this driver type may be bound to. +/// JSON Schema (Draft 2020-12) the driver's DriverConfig column must validate against. +/// JSON Schema for DeviceConfig (multi-device drivers); null if the driver has no device layer. +/// JSON Schema for TagConfig; required for every driver since every driver has tags. +public sealed record DriverTypeMetadata( + string TypeName, + NamespaceKindCompatibility AllowedNamespaceKinds, + string DriverConfigJsonSchema, + string? DeviceConfigJsonSchema, + string TagConfigJsonSchema); + +/// Bitmask of namespace kinds a driver type may populate. Per decision #111. +[Flags] +public enum NamespaceKindCompatibility +{ + /// Driver does not populate any namespace (invalid; should never appear in registry). + None = 0, + + /// Driver may populate Equipment-kind namespaces (UNS path, Equipment rows). + Equipment = 1, + + /// Driver may populate SystemPlatform-kind namespaces (Galaxy hierarchy, FolderPath). + SystemPlatform = 2, + + /// Driver may populate the future Simulated namespace (replay driver — not in v2.0). + Simulated = 4, +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAddressSpaceBuilder.cs b/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAddressSpaceBuilder.cs new file mode 100644 index 0000000..21bb44c --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAddressSpaceBuilder.cs @@ -0,0 +1,45 @@ +namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +/// +/// Streaming builder API a driver uses to register OPC UA nodes during discovery. +/// Core owns the tree; driver streams AddFolder / AddVariable calls +/// as it discovers nodes — no buffering of the whole tree. +/// +/// +/// Per docs/v2/plan.md decision #52 — drivers register nodes via this builder +/// rather than returning a tree object. Supports incremental / large address spaces +/// without forcing the driver to buffer the whole tree. +/// +public interface IAddressSpaceBuilder +{ + /// + /// Add a folder node. Returns a child builder scoped to inside this folder, so subsequent + /// calls on the child place nodes under it. + /// + /// OPC UA browse name (the segment of the path under the parent). + /// Human-readable display name. May equal . + IAddressSpaceBuilder Folder(string browseName, string displayName); + + /// + /// Add a variable node corresponding to a tag. Driver-side full reference + data-type + /// metadata come from the DTO. + /// + /// OPC UA browse name (the segment of the path under the parent folder). + /// Human-readable display name. May equal . + /// Driver-side metadata for the variable. + IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo); + + /// + /// Add a property to the current node (folder or variable). Properties are static metadata + /// read once at build time (e.g. OPC 40010 Identification fields per the schemas-repo + /// _base equipment-class template). + /// + void AddProperty(string browseName, DriverDataType dataType, object? value); +} + +/// Opaque handle for a registered variable. Used by Core for subscription routing. +public interface IVariableHandle +{ + /// Driver-side full reference for read/write addressing. + string FullReference { get; } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs b/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs new file mode 100644 index 0000000..2282d0f --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs @@ -0,0 +1,54 @@ +namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +/// +/// Driver capability for alarm events. Optional — only drivers whose backends expose +/// alarm conditions implement this. Currently: Galaxy (MxAccess alarms), FOCAS +/// (CNC alarms), OPC UA Client (A&C events from upstream server). +/// +public interface IAlarmSource +{ + /// + /// Subscribe to alarm events for a node-set (typically: a folder or equipment subtree). + /// The driver fires for every alarm transition. + /// + Task SubscribeAlarmsAsync( + IReadOnlyList sourceNodeIds, + CancellationToken cancellationToken); + + /// Cancel an alarm subscription returned by . + Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken); + + /// Acknowledge one or more active alarms by source node ID + condition ID. + Task AcknowledgeAsync( + IReadOnlyList acknowledgements, + CancellationToken cancellationToken); + + /// Server-pushed alarm transition (raise / clear / change). + event EventHandler? OnAlarmEvent; +} + +/// Opaque alarm-subscription identity returned by . +public interface IAlarmSubscriptionHandle +{ + /// Driver-internal subscription identifier (for diagnostics + post-mortem). + string DiagnosticId { get; } +} + +/// One alarm acknowledgement in a batch. +public sealed record AlarmAcknowledgeRequest( + string SourceNodeId, + string ConditionId, + string? Comment); + +/// Event payload for . +public sealed record AlarmEventArgs( + IAlarmSubscriptionHandle SubscriptionHandle, + string SourceNodeId, + string ConditionId, + string AlarmType, + string Message, + AlarmSeverity Severity, + DateTime SourceTimestampUtc); + +/// Mirrors the NodePermissions alarm-severity enum in docs/v2/acl-design.md. +public enum AlarmSeverity { Low, Medium, High, Critical } diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriver.cs new file mode 100644 index 0000000..92d3695 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriver.cs @@ -0,0 +1,60 @@ +namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +/// +/// Required capability for every driver instance. Owns lifecycle, metadata, health. +/// Other capabilities (, , +/// , , , +/// , , +/// ) are composable — a driver implements only what its +/// backend actually supports. +/// +/// +/// Per docs/v2/plan.md decisions #4 (composable capability interfaces) and #53 +/// (capability discovery via is checks — no redundant flag enum). +/// +public interface IDriver +{ + /// Stable logical ID of this driver instance, sourced from the central config DB. + string DriverInstanceId { get; } + + /// Driver type name (e.g. "Galaxy", "ModbusTcp", "AbCip"). Matches DriverInstance.DriverType. + string DriverType { get; } + + /// Initialize the driver from its DriverConfig JSON; open connections; prepare for first use. + Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken); + + /// + /// Apply a config change in place without tearing down the driver process. + /// Used by IGenerationApplier when only this driver's config changed in the new generation. + /// + /// + /// Per docs/v2/driver-stability.md §"In-process only (Tier A/B)" — Reinitialize is the + /// only Core-initiated recovery path for in-process drivers; if it fails, the driver instance + /// is marked Faulted and its nodes go Bad quality, but the server process keeps running. + /// + Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken); + + /// Stop the driver, close connections, release resources. Called on shutdown or driver removal. + Task ShutdownAsync(CancellationToken cancellationToken); + + /// Current health snapshot, polled by Core for the status dashboard and ServiceLevel. + DriverHealth GetHealth(); + + /// + /// Approximate driver-attributable footprint in bytes (caches, queues, symbol tables). + /// Polled every 30s by Core; on cache-budget breach, Core asks the driver to flush via + /// . + /// + /// + /// Per docs/v2/driver-stability.md §"In-process only (Tier A/B) — driver-instance + /// allocation tracking". Tier C drivers (process-isolated) report through the same + /// interface but the cache-flush is internal to their host. + /// + long GetMemoryFootprint(); + + /// + /// Drop optional caches (symbol cache, browse cache, etc.) to bring footprint back below budget. + /// Required-for-correctness state must NOT be flushed. + /// + Task FlushOptionalCachesAsync(CancellationToken cancellationToken); +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriverConfigEditor.cs b/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriverConfigEditor.cs new file mode 100644 index 0000000..ac98589 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriverConfigEditor.cs @@ -0,0 +1,30 @@ +namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +/// +/// Optional plug-point a driver implements to provide a custom Admin UI editor for its +/// DriverConfig JSON. Drivers that don't implement this fall back to the generic +/// JSON editor with schema-driven validation against the registered JSON schema. +/// +/// +/// Per docs/v2/plan.md decision #27 — driver-specific config editors are deferred +/// to each driver's implementation phase; v2.0 ships with the generic JSON editor as the +/// default. This interface is the future plug-point so phase-specific editors can land +/// incrementally. +/// +/// The actual UI rendering happens in the Admin Blazor Server app (see +/// docs/v2/admin-ui.md). This interface in Core.Abstractions is the +/// contract between the driver and the Admin app — the Admin app discovers +/// implementations and slots them into the Driver Detail screen. +/// +public interface IDriverConfigEditor +{ + /// Driver type name this editor handles (e.g. "Galaxy", "ModbusTcp"). + string DriverType { get; } + + /// + /// Type of the Razor component (must derive from ComponentBase in the Admin app's + /// `Components/Shared/` folder) that renders the editor. Returned as Type so the + /// Core.Abstractions project doesn't need a Blazor reference. + /// + Type EditorComponentType { get; } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IHistoryProvider.cs b/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IHistoryProvider.cs new file mode 100644 index 0000000..af48bbc --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IHistoryProvider.cs @@ -0,0 +1,50 @@ +namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +/// +/// Driver capability for historical-data reads (OPC UA HistoryRead). Optional — +/// only drivers whose backends carry historian data implement this. Currently: +/// Galaxy (Wonderware Historian via the optional plugin), OPC UA Client (forward +/// to upstream server). +/// +public interface IHistoryProvider +{ + /// + /// Read raw historical samples for a single attribute over a time range. + /// The Core wraps this with continuation-point handling. + /// + Task ReadRawAsync( + string fullReference, + DateTime startUtc, + DateTime endUtc, + uint maxValuesPerNode, + CancellationToken cancellationToken); + + /// + /// Read processed (aggregated) samples — interval-bucketed average / min / max / etc. + /// Optional — drivers that only support raw history can throw . + /// + Task ReadProcessedAsync( + string fullReference, + DateTime startUtc, + DateTime endUtc, + TimeSpan interval, + HistoryAggregateType aggregate, + CancellationToken cancellationToken); +} + +/// Result of a HistoryRead call. +/// Returned samples in chronological order. +/// Opaque token for the next call when more samples are available; null when complete. +public sealed record HistoryReadResult( + IReadOnlyList Samples, + byte[]? ContinuationPoint); + +/// Aggregate function for processed history reads. Mirrors OPC UA Part 13 standard aggregates. +public enum HistoryAggregateType +{ + Average, + Minimum, + Maximum, + Total, + Count, +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IHostConnectivityProbe.cs b/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IHostConnectivityProbe.cs new file mode 100644 index 0000000..3a446b1 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IHostConnectivityProbe.cs @@ -0,0 +1,41 @@ +namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +/// +/// Optional driver capability for per-host connectivity reporting. Currently used by +/// the Galaxy driver (Platform / AppEngine ScanState) but generalized so future drivers +/// with multi-host topology (e.g. an OPC UA Client gateway proxying multiple upstream +/// servers) can opt in. +/// +/// +/// Per docs/v2/plan.md §5a — the Galaxy driver's GalaxyRuntimeProbeManager +/// becomes IHostConnectivityProbe after the v2 refactor. +/// +public interface IHostConnectivityProbe +{ + /// + /// Snapshot of host-level connectivity. The Core uses this to drive Bad-quality + /// fan-out scoped to the affected host's subtree (not the whole driver namespace). + /// + IReadOnlyList GetHostStatuses(); + + /// Fired when a host transitions Running ↔ Stopped (or similar lifecycle change). + event EventHandler? OnHostStatusChanged; +} + +/// Per-host connectivity snapshot. +/// Driver-side host identifier (e.g. for Galaxy: Platform or AppEngine name). +/// Current state. +/// Timestamp of the last state transition. +public sealed record HostConnectivityStatus( + string HostName, + HostState State, + DateTime LastChangedUtc); + +/// Event payload for . +public sealed record HostStatusChangedEventArgs( + string HostName, + HostState OldState, + HostState NewState); + +/// Host lifecycle state. Generalization of Galaxy's Platform/Engine ScanState. +public enum HostState { Unknown, Running, Stopped, Faulted } diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IReadable.cs b/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IReadable.cs new file mode 100644 index 0000000..1aeca63 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IReadable.cs @@ -0,0 +1,25 @@ +namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +/// +/// Driver capability for on-demand reads. Required for any driver whose nodes are +/// readable from OPC UA clients (essentially all of them — every committed v2 driver +/// implements this). +/// +/// +/// Reads are idempotent — Polly retry pipelines can safely retry on transient failures +/// (per docs/v2/plan.md decisions #34 and #44). +/// +public interface IReadable +{ + /// + /// Read a batch of attributes by their full driver-side reference. + /// Returns one snapshot per requested reference, in the same order. + /// + /// + /// Per-reference failures should be reported via the snapshot's + /// (Bad-coded), not as exceptions. The whole call should throw only if the driver itself is unreachable. + /// + Task> ReadAsync( + IReadOnlyList fullReferences, + CancellationToken cancellationToken); +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IRediscoverable.cs b/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IRediscoverable.cs new file mode 100644 index 0000000..39e8c3e --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IRediscoverable.cs @@ -0,0 +1,29 @@ +namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +/// +/// Optional driver capability — drivers whose backend has a native change signal +/// (Galaxy time_of_last_deploy, OPC UA server change notifications, TwinCAT +/// symbol-version-changed) implement this to tell Core when to re-run discovery. +/// +/// +/// Per docs/v2/plan.md decision #54 — static drivers (Modbus, S7, etc. whose tags +/// only change via a published config generation) don't implement IRediscoverable. +/// The Core just sees absence of the interface and skips change-detection wiring for that driver. +/// +public interface IRediscoverable +{ + /// + /// Fired when the driver's backend signals that the address space may have changed. + /// The Core's response is to re-run and + /// diff the result against the current address space. + /// + event EventHandler? OnRediscoveryNeeded; +} + +/// Event payload for . +/// Driver-supplied reason string for the diagnostic log (e.g. "Galaxy time_of_last_deploy advanced", "TwinCAT symbol-version-changed 0x0702"). +/// +/// Optional hint about which subtree changed. Null means "the whole address space may have changed". +/// A non-null value (e.g. a folder path) lets the Core scope the rebuild surgically. +/// +public sealed record RediscoveryEventArgs(string Reason, string? ScopeHint); diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ISubscribable.cs b/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ISubscribable.cs new file mode 100644 index 0000000..2de7dbe --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ISubscribable.cs @@ -0,0 +1,47 @@ +namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +/// +/// Driver capability for data-change subscriptions — covers both native subscriptions +/// (Galaxy MXAccess advisory, OPC UA monitored items, TwinCAT ADS notifications) and +/// driver-internal polled subscriptions (Modbus, AB CIP, S7, FOCAS). The driver owns +/// its polling loop where applicable; the Core just sees +/// callbacks regardless of mechanism. +/// +public interface ISubscribable +{ + /// + /// Subscribe to data changes for a batch of attributes. + /// The driver MAY fire immediately with the current value + /// (initial-data callback per OPC UA convention) and again on every change. + /// + /// An opaque subscription handle the caller passes to . + Task SubscribeAsync( + IReadOnlyList fullReferences, + TimeSpan publishingInterval, + CancellationToken cancellationToken); + + /// Cancel a subscription returned by . + Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken); + + /// + /// Server-pushed data-change notification. Fires whenever a subscribed attribute changes, + /// and (per OPC UA convention) on subscription establishment for current values. + /// + event EventHandler? OnDataChange; +} + +/// Opaque subscription identity returned by . +public interface ISubscriptionHandle +{ + /// Driver-internal subscription identifier (for diagnostics + post-mortem). + string DiagnosticId { get; } +} + +/// Event payload for . +/// The handle returned by the original call. +/// Driver-side full reference of the changed attribute. +/// New value + quality + timestamps. +public sealed record DataChangeEventArgs( + ISubscriptionHandle SubscriptionHandle, + string FullReference, + DataValueSnapshot Snapshot); diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ITagDiscovery.cs b/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ITagDiscovery.cs new file mode 100644 index 0000000..aae78ee --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ITagDiscovery.cs @@ -0,0 +1,15 @@ +namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +/// +/// Driver capability for discovering tags and hierarchy from the backend. +/// Streams discovered nodes into rather than +/// buffering the entire tree (decision #52 — supports incremental / large address spaces). +/// +public interface ITagDiscovery +{ + /// + /// Discover the driver's tag set and stream nodes to the builder. + /// The driver decides ordering (root → leaf typically) and may yield as many calls as needed. + /// + Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken); +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IWritable.cs b/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IWritable.cs new file mode 100644 index 0000000..4abf60e --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IWritable.cs @@ -0,0 +1,34 @@ +namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +/// +/// Driver capability for on-demand writes. Optional — read-only drivers (a hypothetical +/// historian-only adapter, for example) can omit this. +/// +/// +/// Per docs/v2/plan.md decisions #44 + #45 — writes are NOT auto-retried by default. +/// A timeout may fire after the device already accepted the command; replaying non-idempotent +/// field actions (pulses, alarm acks, recipe steps, counter increments) can cause duplicate +/// operations. Per-tag opt-in via Tag.WriteIdempotent = true in the central config DB +/// enables retry; otherwise the OPC UA client decides whether to re-issue. +/// +public interface IWritable +{ + /// + /// Write a batch of values to the driver. Returns one status per requested write, + /// in the same order. + /// + /// Pairs of full reference + value to write. + /// Cancellation token; the driver should abort the batch if cancelled. + Task> WriteAsync( + IReadOnlyList writes, + CancellationToken cancellationToken); +} + +/// One write request in a batch. +/// Driver-side full reference (matches ). +/// Value to write; type must be compatible with the attribute's . +public sealed record WriteRequest(string FullReference, object? Value); + +/// Result of one write in a batch. +/// OPC UA status code (numeric value matches the OPC UA spec). +public sealed record WriteResult(uint StatusCode); diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/SecurityClassification.cs b/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/SecurityClassification.cs new file mode 100644 index 0000000..8e37537 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/SecurityClassification.cs @@ -0,0 +1,23 @@ +namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +/// +/// Security classification for write authorization on a tag, mirroring +/// the v1 Galaxy SecurityClassification model documented in docs/DataTypeMapping.md. +/// Generalized so non-Galaxy drivers can declare per-tag write protection levels. +/// +/// +/// Maps to NodePermissions write tiers in docs/v2/acl-design.md: +/// FreeAccess + Operate require WriteOperate; Tune requires WriteTune; +/// Configure requires WriteConfigure; SecuredWrite + VerifiedWrite + ViewOnly +/// are read-only from OPC UA (v1 behavior preserved). +/// +public enum SecurityClassification +{ + FreeAccess = 0, + Operate = 1, + SecuredWrite = 2, + VerifiedWrite = 3, + Tune = 4, + Configure = 5, + ViewOnly = 6, +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj b/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj new file mode 100644 index 0000000..dd7ca21 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj @@ -0,0 +1,17 @@ + + + + net10.0 + enable + enable + latest + true + true + $(NoWarn);CS1591 + + + + + + + diff --git a/tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/DriverTypeRegistryTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/DriverTypeRegistryTests.cs new file mode 100644 index 0000000..2a50d9d --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/DriverTypeRegistryTests.cs @@ -0,0 +1,107 @@ +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests; + +public sealed class DriverTypeRegistryTests +{ + private static DriverTypeMetadata SampleMetadata( + string typeName = "Modbus", + NamespaceKindCompatibility allowed = NamespaceKindCompatibility.Equipment) => + new(typeName, allowed, + DriverConfigJsonSchema: "{\"type\": \"object\"}", + DeviceConfigJsonSchema: "{\"type\": \"object\"}", + TagConfigJsonSchema: "{\"type\": \"object\"}"); + + [Fact] + public void Register_ThenGet_RoundTrips() + { + var registry = new DriverTypeRegistry(); + var metadata = SampleMetadata(); + + registry.Register(metadata); + + registry.Get("Modbus").ShouldBe(metadata); + } + + [Fact] + public void Get_IsCaseInsensitive() + { + var registry = new DriverTypeRegistry(); + registry.Register(SampleMetadata("Galaxy")); + + registry.Get("galaxy").ShouldNotBeNull(); + registry.Get("GALAXY").ShouldNotBeNull(); + } + + [Fact] + public void Get_UnknownType_Throws() + { + var registry = new DriverTypeRegistry(); + registry.Register(SampleMetadata("Modbus")); + + Should.Throw(() => registry.Get("UnregisteredType")); + } + + [Fact] + public void TryGet_UnknownType_ReturnsNull() + { + var registry = new DriverTypeRegistry(); + registry.Register(SampleMetadata("Modbus")); + + registry.TryGet("UnregisteredType").ShouldBeNull(); + } + + [Fact] + public void Register_DuplicateType_Throws() + { + var registry = new DriverTypeRegistry(); + registry.Register(SampleMetadata("Modbus")); + + Should.Throw(() => registry.Register(SampleMetadata("Modbus"))); + } + + [Fact] + public void Register_DuplicateTypeIsCaseInsensitive() + { + var registry = new DriverTypeRegistry(); + registry.Register(SampleMetadata("Modbus")); + + Should.Throw(() => registry.Register(SampleMetadata("modbus"))); + } + + [Fact] + public void All_ReturnsRegisteredTypes() + { + var registry = new DriverTypeRegistry(); + registry.Register(SampleMetadata("Modbus")); + registry.Register(SampleMetadata("S7")); + registry.Register(SampleMetadata("Galaxy", NamespaceKindCompatibility.SystemPlatform)); + + var all = registry.All(); + + all.Count.ShouldBe(3); + all.Select(m => m.TypeName).ShouldBe(new[] { "Modbus", "S7", "Galaxy" }, ignoreOrder: true); + } + + [Fact] + public void NamespaceKindCompatibility_FlagsAreBitmask() + { + // Per decision #111 — driver types like OpcUaClient may be valid for multiple namespace kinds. + var both = NamespaceKindCompatibility.Equipment | NamespaceKindCompatibility.SystemPlatform; + + both.HasFlag(NamespaceKindCompatibility.Equipment).ShouldBeTrue(); + both.HasFlag(NamespaceKindCompatibility.SystemPlatform).ShouldBeTrue(); + both.HasFlag(NamespaceKindCompatibility.Simulated).ShouldBeFalse(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Get_RejectsEmptyTypeName(string? typeName) + { + var registry = new DriverTypeRegistry(); + Should.Throw(() => registry.Get(typeName!)); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/InterfaceIndependenceTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/InterfaceIndependenceTests.cs new file mode 100644 index 0000000..a050e12 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/InterfaceIndependenceTests.cs @@ -0,0 +1,71 @@ +using System.Reflection; +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests; + +/// +/// Asserts that Core.Abstractions stays a true contract project — it must not depend on +/// any implementation type, any other OtOpcUa project, or anything beyond BCL + System types. +/// Per docs/v2/plan.md decision #59 (Core.Abstractions internal-only for now; design as +/// if public to minimize churn later). +/// +public sealed class InterfaceIndependenceTests +{ + private static readonly Assembly Assembly = typeof(IDriver).Assembly; + + [Fact] + public void Assembly_HasNoReferencesOutsideBcl() + { + // Allowed reference assembly name prefixes — BCL + the assembly itself. + var allowed = new[] + { + "System", + "Microsoft.Win32", + "netstandard", + "mscorlib", + "ZB.MOM.WW.OtOpcUa.Core.Abstractions", + }; + + var referenced = Assembly.GetReferencedAssemblies(); + var disallowed = referenced + .Where(r => !allowed.Any(a => r.Name!.StartsWith(a, StringComparison.Ordinal))) + .ToList(); + + disallowed.ShouldBeEmpty( + $"Core.Abstractions must reference only BCL/System assemblies. " + + $"Found disallowed references: {string.Join(", ", disallowed.Select(a => a.Name))}"); + } + + [Fact] + public void AllPublicTypes_LiveInRootNamespace() + { + // Per the decision-#59 "design as if public" rule — no nested sub-namespaces; one flat surface. + var publicTypes = Assembly.GetExportedTypes(); + var nonRoot = publicTypes + .Where(t => t.Namespace != "ZB.MOM.WW.OtOpcUa.Core.Abstractions") + .ToList(); + + nonRoot.ShouldBeEmpty( + $"Core.Abstractions should expose all public types in the root namespace. " + + $"Found types in other namespaces: {string.Join(", ", nonRoot.Select(t => $"{t.FullName}"))}"); + } + + [Theory] + [InlineData(typeof(IDriver))] + [InlineData(typeof(ITagDiscovery))] + [InlineData(typeof(IReadable))] + [InlineData(typeof(IWritable))] + [InlineData(typeof(ISubscribable))] + [InlineData(typeof(IAlarmSource))] + [InlineData(typeof(IHistoryProvider))] + [InlineData(typeof(IRediscoverable))] + [InlineData(typeof(IHostConnectivityProbe))] + [InlineData(typeof(IDriverConfigEditor))] + [InlineData(typeof(IAddressSpaceBuilder))] + public void EveryCapabilityInterface_IsPublic(Type type) + { + type.IsPublic.ShouldBeTrue($"{type.Name} must be public — drivers in separate assemblies implement it."); + type.IsInterface.ShouldBeTrue($"{type.Name} must be an interface, not a class."); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.csproj b/tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.csproj new file mode 100644 index 0000000..38a82e8 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + false + true + ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + +