Phase 1 Stream A — Core.Abstractions project + 11 capability interfaces + DriverTypeRegistry + interface-independence tests

New project src/ZB.MOM.WW.OtOpcUa.Core.Abstractions (.NET 10, BCL-only dependencies, GenerateDocumentationFile=true, TreatWarningsAsErrors=true) defining the contract surface every driver implements. Per docs/v2/plan.md decisions #4 (composable capability interfaces), #52 (streaming IAddressSpaceBuilder), #53 (capability discovery via `is` checks no flag enum), #54 (optional IRediscoverable sub-interface), #59 (Core.Abstractions internal-only for now design as if public).

Eleven capability interfaces:
- IDriver — required lifecycle / health / config-apply / memory-footprint accounting (per driver-stability.md Tier A/B allocation tracking)
- ITagDiscovery — discovers tags streaming to IAddressSpaceBuilder
- IReadable — on-demand reads idempotent for Polly retry
- IWritable — writes NOT auto-retried by default per decisions #44 + #45
- ISubscribable — data-change subscriptions covering both native (Galaxy MXAccess advisory, OPC UA monitored items, TwinCAT ADS) and driver-internal polled (Modbus, AB CIP, S7, FOCAS) mechanisms; OnDataChange callback regardless of source
- IAlarmSource — alarm events + acknowledge + AlarmSeverity enum mirroring acl-design.md NodePermissions alarm-severity values
- IHistoryProvider — HistoryReadRaw + HistoryReadProcessed with continuation points
- IRediscoverable — opt-in change-detection signal; static drivers don't implement
- IHostConnectivityProbe — generalized from Galaxy's GalaxyRuntimeProbeManager per plan §5a
- IDriverConfigEditor — Admin UI plug-point for per-driver custom config editors deferred to each driver's phase per decision #27
- IAddressSpaceBuilder — streaming builder API for driver-driven address-space construction

Plus DTOs: DriverDataType, SecurityClassification (mirroring v1 Galaxy model), DriverAttributeInfo (replaces Galaxy-specific GalaxyAttributeInfo per plan §5a), DriverHealth + DriverState, DataValueSnapshot (universal OPC UA quality + timestamp carrier per decision #13), HostConnectivityStatus + HostState + HostStatusChangedEventArgs, RediscoveryEventArgs, DataChangeEventArgs, AlarmEventArgs + AlarmAcknowledgeRequest + AlarmSeverity, WriteRequest + WriteResult, HistoryReadResult + HistoryAggregateType, ISubscriptionHandle + IAlarmSubscriptionHandle + IVariableHandle.

DriverTypeRegistry singleton with Register / Get / TryGet / All; thread-safe via Interlocked.Exchange snapshot replacement on registration; case-insensitive lookups; rejects duplicate registrations; rejects empty type names. DriverTypeMetadata record carries TypeName + AllowedNamespaceKinds (NamespaceKindCompatibility flags enum per decision #111) + per-config-tier JSON Schemas the validator checks at draft-publish time (decision #91).

Tests project tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests (xUnit v3 1.1.0 matching existing test projects). 24 tests covering: 1) interface independence reflection check (no references outside BCL/System; all public types in root namespace; every capability interface is public); 2) DriverTypeRegistry round-trip, case-insensitive lookups, KeyNotFoundException on unknown, null on TryGet of unknown, InvalidOperationException on duplicate registration (case-insensitive too), All() enumeration, NamespaceKindCompatibility bitmask combinations, ArgumentException on empty type names.

Build: 0 errors, 4 warnings (only pre-existing transitive package vulnerability + analyzer hints). Full test suite: 845 passing / 1 failing — strict improvement over Phase 0 baseline (821/1) by the 24 new Core.Abstractions tests; no regressions in any other test project.

Phase 1 entry-gate record (docs/v2/implementation/entry-gate-phase-1.md) documents the deviation: only Stream A executed in this continuation since Streams B-E need SQL Server / GLAuth / Galaxy infrastructure standup per dev-environment.md Step 1, which is currently TODO.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-04-17 14:15:55 -04:00
parent 45ffa3e7d4
commit 980ea5190c
23 changed files with 941 additions and 0 deletions

View File

@@ -1,5 +1,6 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/ZB.MOM.WW.OtOpcUa.Historian.Aveva.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.Shared/ZB.MOM.WW.OtOpcUa.Client.Shared.csproj"/>
@@ -7,6 +8,7 @@
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.UI/ZB.MOM.WW.OtOpcUa.Client.UI.csproj"/>
</Folder>
<Folder Name="/tests/">
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Tests/ZB.MOM.WW.OtOpcUa.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests/ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.IntegrationTests/ZB.MOM.WW.OtOpcUa.IntegrationTests.csproj"/>

View File

@@ -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 | #1142 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 BE 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

View File

@@ -0,0 +1,21 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// Driver-agnostic value snapshot returned by <see cref="IReadable"/> and pushed
/// by <see cref="ISubscribable.OnDataChange"/>. Mirrors the OPC UA <c>DataValue</c>
/// shape so the node-manager can pass through quality, source timestamp, and
/// server timestamp without translation.
/// </summary>
/// <remarks>
/// Per <c>docs/v2/plan.md</c> decision #13 — every driver maps to the same
/// OPC UA StatusCode space; this DTO is the universal carrier.
/// </remarks>
/// <param name="Value">The raw value; null when <see cref="StatusCode"/> indicates Bad.</param>
/// <param name="StatusCode">OPC UA status code (numeric value matches the OPC UA spec).</param>
/// <param name="SourceTimestampUtc">Driver-side timestamp when the value was sampled at the source. Null if unavailable.</param>
/// <param name="ServerTimestampUtc">Driver-side timestamp when the driver received / processed the value.</param>
public sealed record DataValueSnapshot(
object? Value,
uint StatusCode,
DateTime? SourceTimestampUtc,
DateTime ServerTimestampUtc);

View File

@@ -0,0 +1,28 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// Per <c>docs/v2/plan.md</c> §5a (LmxNodeManager reusability) — <c>DriverAttributeInfo</c>
/// replaces the v1 Galaxy-specific <c>GalaxyAttributeInfo</c> in the generic node-manager
/// so the same node-manager class works against every driver.
/// </remarks>
/// <param name="FullName">
/// Driver-side full reference for read/write addressing
/// (e.g. for Galaxy: <c>"DelmiaReceiver_001.DownloadPath"</c>).
/// </param>
/// <param name="DriverDataType">Driver-agnostic data type; maps to OPC UA built-in type at build time.</param>
/// <param name="IsArray">True when this attribute is a 1-D array.</param>
/// <param name="ArrayDim">Declared array length when <see cref="IsArray"/> is true; null otherwise.</param>
/// <param name="SecurityClass">Write-authorization tier for this attribute.</param>
/// <param name="IsHistorized">True when this attribute is expected to feed historian / HistoryRead.</param>
public sealed record DriverAttributeInfo(
string FullName,
DriverDataType DriverDataType,
bool IsArray,
uint? ArrayDim,
SecurityClassification SecurityClass,
bool IsHistorized);

View File

@@ -0,0 +1,28 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// Driver-agnostic data type for an attribute or signal.
/// Maps to OPC UA built-in types at the address-space build layer.
/// </summary>
/// <remarks>
/// Per <c>docs/v2/driver-specs.md</c> 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.
/// </remarks>
public enum DriverDataType
{
Boolean,
Int16,
Int32,
Int64,
UInt16,
UInt32,
UInt64,
Float32,
Float64,
String,
DateTime,
/// <summary>Galaxy-style attribute reference encoded as an OPC UA String.</summary>
Reference,
}

View File

@@ -0,0 +1,38 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// Health snapshot a driver returns to the Core. Drives the status dashboard,
/// ServiceLevel computation, and Bad-quality fan-out decisions.
/// </summary>
/// <param name="State">Current driver-instance state.</param>
/// <param name="LastSuccessfulRead">Timestamp of the most recent successful equipment read; null if never.</param>
/// <param name="LastError">Most recent error message; null when state is Healthy.</param>
public sealed record DriverHealth(
DriverState State,
DateTime? LastSuccessfulRead,
string? LastError);
/// <summary>Driver-instance lifecycle state.</summary>
public enum DriverState
{
/// <summary>Driver has not been initialized yet.</summary>
Unknown,
/// <summary>Driver is in the middle of <see cref="IDriver.InitializeAsync"/> or <see cref="IDriver.ReinitializeAsync"/>.</summary>
Initializing,
/// <summary>Driver is connected and serving data.</summary>
Healthy,
/// <summary>Driver is connected but reporting degraded data (e.g. some equipment unreachable, some tags Bad).</summary>
Degraded,
/// <summary>Driver lost connection to its data source; reconnecting in the background.</summary>
Reconnecting,
/// <summary>
/// Driver hit an unrecoverable error and stopped trying.
/// Operator must reinitialize via Admin UI; nodes report Bad quality.
/// </summary>
Faulted,
}

View File

@@ -0,0 +1,94 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// 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 <c>DriverInstance.DriverType</c> values from the central config DB.
/// </summary>
/// <remarks>
/// Per <c>docs/v2/plan.md</c> 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
/// <see cref="System.Threading.Interlocked"/> on register; readers see a stable snapshot.
/// </remarks>
public sealed class DriverTypeRegistry
{
private IReadOnlyDictionary<string, DriverTypeMetadata> _types =
new Dictionary<string, DriverTypeMetadata>(StringComparer.OrdinalIgnoreCase);
/// <summary>Register a driver type. Throws if the type name is already registered.</summary>
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<string, DriverTypeMetadata>(snapshot, StringComparer.OrdinalIgnoreCase)
{
[metadata.TypeName] = metadata,
};
Interlocked.Exchange(ref _types, next);
}
/// <summary>Look up a driver type by name. Throws if unknown.</summary>
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)}.");
}
/// <summary>Try to look up a driver type by name. Returns null if unknown (no exception).</summary>
public DriverTypeMetadata? TryGet(string driverType)
{
ArgumentException.ThrowIfNullOrWhiteSpace(driverType);
return _types.GetValueOrDefault(driverType);
}
/// <summary>Snapshot of all registered driver types.</summary>
public IReadOnlyCollection<DriverTypeMetadata> All() => _types.Values.ToList();
}
/// <summary>Per-driver-type metadata used by the Core, validator, and Admin UI.</summary>
/// <param name="TypeName">Driver type name (matches <c>DriverInstance.DriverType</c> column values).</param>
/// <param name="AllowedNamespaceKinds">Which namespace kinds this driver type may be bound to.</param>
/// <param name="DriverConfigJsonSchema">JSON Schema (Draft 2020-12) the driver's <c>DriverConfig</c> column must validate against.</param>
/// <param name="DeviceConfigJsonSchema">JSON Schema for <c>DeviceConfig</c> (multi-device drivers); null if the driver has no device layer.</param>
/// <param name="TagConfigJsonSchema">JSON Schema for <c>TagConfig</c>; required for every driver since every driver has tags.</param>
public sealed record DriverTypeMetadata(
string TypeName,
NamespaceKindCompatibility AllowedNamespaceKinds,
string DriverConfigJsonSchema,
string? DeviceConfigJsonSchema,
string TagConfigJsonSchema);
/// <summary>Bitmask of namespace kinds a driver type may populate. Per decision #111.</summary>
[Flags]
public enum NamespaceKindCompatibility
{
/// <summary>Driver does not populate any namespace (invalid; should never appear in registry).</summary>
None = 0,
/// <summary>Driver may populate Equipment-kind namespaces (UNS path, Equipment rows).</summary>
Equipment = 1,
/// <summary>Driver may populate SystemPlatform-kind namespaces (Galaxy hierarchy, FolderPath).</summary>
SystemPlatform = 2,
/// <summary>Driver may populate the future Simulated namespace (replay driver — not in v2.0).</summary>
Simulated = 4,
}

View File

@@ -0,0 +1,45 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// Streaming builder API a driver uses to register OPC UA nodes during discovery.
/// Core owns the tree; driver streams <c>AddFolder</c> / <c>AddVariable</c> calls
/// as it discovers nodes — no buffering of the whole tree.
/// </summary>
/// <remarks>
/// Per <c>docs/v2/plan.md</c> 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.
/// </remarks>
public interface IAddressSpaceBuilder
{
/// <summary>
/// Add a folder node. Returns a child builder scoped to inside this folder, so subsequent
/// calls on the child place nodes under it.
/// </summary>
/// <param name="browseName">OPC UA browse name (the segment of the path under the parent).</param>
/// <param name="displayName">Human-readable display name. May equal <paramref name="browseName"/>.</param>
IAddressSpaceBuilder Folder(string browseName, string displayName);
/// <summary>
/// Add a variable node corresponding to a tag. Driver-side full reference + data-type
/// metadata come from the <see cref="DriverAttributeInfo"/> DTO.
/// </summary>
/// <param name="browseName">OPC UA browse name (the segment of the path under the parent folder).</param>
/// <param name="displayName">Human-readable display name. May equal <paramref name="browseName"/>.</param>
/// <param name="attributeInfo">Driver-side metadata for the variable.</param>
IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo);
/// <summary>
/// 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
/// <c>_base</c> equipment-class template).
/// </summary>
void AddProperty(string browseName, DriverDataType dataType, object? value);
}
/// <summary>Opaque handle for a registered variable. Used by Core for subscription routing.</summary>
public interface IVariableHandle
{
/// <summary>Driver-side full reference for read/write addressing.</summary>
string FullReference { get; }
}

View File

@@ -0,0 +1,54 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// 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&amp;C events from upstream server).
/// </summary>
public interface IAlarmSource
{
/// <summary>
/// Subscribe to alarm events for a node-set (typically: a folder or equipment subtree).
/// The driver fires <see cref="OnAlarmEvent"/> for every alarm transition.
/// </summary>
Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
IReadOnlyList<string> sourceNodeIds,
CancellationToken cancellationToken);
/// <summary>Cancel an alarm subscription returned by <see cref="SubscribeAlarmsAsync"/>.</summary>
Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken);
/// <summary>Acknowledge one or more active alarms by source node ID + condition ID.</summary>
Task AcknowledgeAsync(
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements,
CancellationToken cancellationToken);
/// <summary>Server-pushed alarm transition (raise / clear / change).</summary>
event EventHandler<AlarmEventArgs>? OnAlarmEvent;
}
/// <summary>Opaque alarm-subscription identity returned by <see cref="IAlarmSource.SubscribeAlarmsAsync"/>.</summary>
public interface IAlarmSubscriptionHandle
{
/// <summary>Driver-internal subscription identifier (for diagnostics + post-mortem).</summary>
string DiagnosticId { get; }
}
/// <summary>One alarm acknowledgement in a batch.</summary>
public sealed record AlarmAcknowledgeRequest(
string SourceNodeId,
string ConditionId,
string? Comment);
/// <summary>Event payload for <see cref="IAlarmSource.OnAlarmEvent"/>.</summary>
public sealed record AlarmEventArgs(
IAlarmSubscriptionHandle SubscriptionHandle,
string SourceNodeId,
string ConditionId,
string AlarmType,
string Message,
AlarmSeverity Severity,
DateTime SourceTimestampUtc);
/// <summary>Mirrors the <c>NodePermissions</c> alarm-severity enum in <c>docs/v2/acl-design.md</c>.</summary>
public enum AlarmSeverity { Low, Medium, High, Critical }

View File

@@ -0,0 +1,60 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// Required capability for every driver instance. Owns lifecycle, metadata, health.
/// Other capabilities (<see cref="ITagDiscovery"/>, <see cref="IReadable"/>,
/// <see cref="IWritable"/>, <see cref="ISubscribable"/>, <see cref="IAlarmSource"/>,
/// <see cref="IHistoryProvider"/>, <see cref="IRediscoverable"/>,
/// <see cref="IHostConnectivityProbe"/>) are composable — a driver implements only what its
/// backend actually supports.
/// </summary>
/// <remarks>
/// Per <c>docs/v2/plan.md</c> decisions #4 (composable capability interfaces) and #53
/// (capability discovery via <c>is</c> checks — no redundant flag enum).
/// </remarks>
public interface IDriver
{
/// <summary>Stable logical ID of this driver instance, sourced from the central config DB.</summary>
string DriverInstanceId { get; }
/// <summary>Driver type name (e.g. "Galaxy", "ModbusTcp", "AbCip"). Matches <c>DriverInstance.DriverType</c>.</summary>
string DriverType { get; }
/// <summary>Initialize the driver from its <c>DriverConfig</c> JSON; open connections; prepare for first use.</summary>
Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken);
/// <summary>
/// Apply a config change in place without tearing down the driver process.
/// Used by <c>IGenerationApplier</c> when only this driver's config changed in the new generation.
/// </summary>
/// <remarks>
/// Per <c>docs/v2/driver-stability.md</c> §"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.
/// </remarks>
Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken);
/// <summary>Stop the driver, close connections, release resources. Called on shutdown or driver removal.</summary>
Task ShutdownAsync(CancellationToken cancellationToken);
/// <summary>Current health snapshot, polled by Core for the status dashboard and ServiceLevel.</summary>
DriverHealth GetHealth();
/// <summary>
/// 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
/// <see cref="FlushOptionalCachesAsync"/>.
/// </summary>
/// <remarks>
/// Per <c>docs/v2/driver-stability.md</c> §"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.
/// </remarks>
long GetMemoryFootprint();
/// <summary>
/// Drop optional caches (symbol cache, browse cache, etc.) to bring footprint back below budget.
/// Required-for-correctness state must NOT be flushed.
/// </summary>
Task FlushOptionalCachesAsync(CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,30 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// Optional plug-point a driver implements to provide a custom Admin UI editor for its
/// <c>DriverConfig</c> JSON. Drivers that don't implement this fall back to the generic
/// JSON editor with schema-driven validation against the registered JSON schema.
/// </summary>
/// <remarks>
/// Per <c>docs/v2/plan.md</c> 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
/// <c>docs/v2/admin-ui.md</c>). This interface in <c>Core.Abstractions</c> is the
/// contract between the driver and the Admin app — the Admin app discovers
/// implementations and slots them into the Driver Detail screen.
/// </remarks>
public interface IDriverConfigEditor
{
/// <summary>Driver type name this editor handles (e.g. "Galaxy", "ModbusTcp").</summary>
string DriverType { get; }
/// <summary>
/// Type of the Razor component (must derive from <c>ComponentBase</c> in the Admin app's
/// `Components/Shared/` folder) that renders the editor. Returned as <c>Type</c> so the
/// <c>Core.Abstractions</c> project doesn't need a Blazor reference.
/// </summary>
Type EditorComponentType { get; }
}

View File

@@ -0,0 +1,50 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// 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).
/// </summary>
public interface IHistoryProvider
{
/// <summary>
/// Read raw historical samples for a single attribute over a time range.
/// The Core wraps this with continuation-point handling.
/// </summary>
Task<HistoryReadResult> ReadRawAsync(
string fullReference,
DateTime startUtc,
DateTime endUtc,
uint maxValuesPerNode,
CancellationToken cancellationToken);
/// <summary>
/// Read processed (aggregated) samples — interval-bucketed average / min / max / etc.
/// Optional — drivers that only support raw history can throw <see cref="NotSupportedException"/>.
/// </summary>
Task<HistoryReadResult> ReadProcessedAsync(
string fullReference,
DateTime startUtc,
DateTime endUtc,
TimeSpan interval,
HistoryAggregateType aggregate,
CancellationToken cancellationToken);
}
/// <summary>Result of a HistoryRead call.</summary>
/// <param name="Samples">Returned samples in chronological order.</param>
/// <param name="ContinuationPoint">Opaque token for the next call when more samples are available; null when complete.</param>
public sealed record HistoryReadResult(
IReadOnlyList<DataValueSnapshot> Samples,
byte[]? ContinuationPoint);
/// <summary>Aggregate function for processed history reads. Mirrors OPC UA Part 13 standard aggregates.</summary>
public enum HistoryAggregateType
{
Average,
Minimum,
Maximum,
Total,
Count,
}

View File

@@ -0,0 +1,41 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// Per <c>docs/v2/plan.md</c> §5a — the Galaxy driver's <c>GalaxyRuntimeProbeManager</c>
/// becomes <c>IHostConnectivityProbe</c> after the v2 refactor.
/// </remarks>
public interface IHostConnectivityProbe
{
/// <summary>
/// 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).
/// </summary>
IReadOnlyList<HostConnectivityStatus> GetHostStatuses();
/// <summary>Fired when a host transitions Running ↔ Stopped (or similar lifecycle change).</summary>
event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
}
/// <summary>Per-host connectivity snapshot.</summary>
/// <param name="HostName">Driver-side host identifier (e.g. for Galaxy: Platform or AppEngine name).</param>
/// <param name="State">Current state.</param>
/// <param name="LastChangedUtc">Timestamp of the last state transition.</param>
public sealed record HostConnectivityStatus(
string HostName,
HostState State,
DateTime LastChangedUtc);
/// <summary>Event payload for <see cref="IHostConnectivityProbe.OnHostStatusChanged"/>.</summary>
public sealed record HostStatusChangedEventArgs(
string HostName,
HostState OldState,
HostState NewState);
/// <summary>Host lifecycle state. Generalization of Galaxy's Platform/Engine ScanState.</summary>
public enum HostState { Unknown, Running, Stopped, Faulted }

View File

@@ -0,0 +1,25 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// 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).
/// </summary>
/// <remarks>
/// Reads are idempotent — Polly retry pipelines can safely retry on transient failures
/// (per <c>docs/v2/plan.md</c> decisions #34 and #44).
/// </remarks>
public interface IReadable
{
/// <summary>
/// Read a batch of attributes by their full driver-side reference.
/// Returns one snapshot per requested reference, in the same order.
/// </summary>
/// <remarks>
/// Per-reference failures should be reported via the snapshot's <see cref="DataValueSnapshot.StatusCode"/>
/// (Bad-coded), not as exceptions. The whole call should throw only if the driver itself is unreachable.
/// </remarks>
Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
IReadOnlyList<string> fullReferences,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,29 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// Optional driver capability — drivers whose backend has a native change signal
/// (Galaxy <c>time_of_last_deploy</c>, OPC UA server change notifications, TwinCAT
/// symbol-version-changed) implement this to tell Core when to re-run discovery.
/// </summary>
/// <remarks>
/// Per <c>docs/v2/plan.md</c> decision #54 — static drivers (Modbus, S7, etc. whose tags
/// only change via a published config generation) don't implement <c>IRediscoverable</c>.
/// The Core just sees absence of the interface and skips change-detection wiring for that driver.
/// </remarks>
public interface IRediscoverable
{
/// <summary>
/// Fired when the driver's backend signals that the address space may have changed.
/// The Core's response is to re-run <see cref="ITagDiscovery.DiscoverAsync"/> and
/// diff the result against the current address space.
/// </summary>
event EventHandler<RediscoveryEventArgs>? OnRediscoveryNeeded;
}
/// <summary>Event payload for <see cref="IRediscoverable.OnRediscoveryNeeded"/>.</summary>
/// <param name="Reason">Driver-supplied reason string for the diagnostic log (e.g. "Galaxy time_of_last_deploy advanced", "TwinCAT symbol-version-changed 0x0702").</param>
/// <param name="ScopeHint">
/// 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.
/// </param>
public sealed record RediscoveryEventArgs(string Reason, string? ScopeHint);

View File

@@ -0,0 +1,47 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// 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 <see cref="OnDataChange"/>
/// callbacks regardless of mechanism.
/// </summary>
public interface ISubscribable
{
/// <summary>
/// Subscribe to data changes for a batch of attributes.
/// The driver MAY fire <see cref="OnDataChange"/> immediately with the current value
/// (initial-data callback per OPC UA convention) and again on every change.
/// </summary>
/// <returns>An opaque subscription handle the caller passes to <see cref="UnsubscribeAsync"/>.</returns>
Task<ISubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> fullReferences,
TimeSpan publishingInterval,
CancellationToken cancellationToken);
/// <summary>Cancel a subscription returned by <see cref="SubscribeAsync"/>.</summary>
Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken);
/// <summary>
/// Server-pushed data-change notification. Fires whenever a subscribed attribute changes,
/// and (per OPC UA convention) on subscription establishment for current values.
/// </summary>
event EventHandler<DataChangeEventArgs>? OnDataChange;
}
/// <summary>Opaque subscription identity returned by <see cref="ISubscribable.SubscribeAsync"/>.</summary>
public interface ISubscriptionHandle
{
/// <summary>Driver-internal subscription identifier (for diagnostics + post-mortem).</summary>
string DiagnosticId { get; }
}
/// <summary>Event payload for <see cref="ISubscribable.OnDataChange"/>.</summary>
/// <param name="SubscriptionHandle">The handle returned by the original <see cref="ISubscribable.SubscribeAsync"/> call.</param>
/// <param name="FullReference">Driver-side full reference of the changed attribute.</param>
/// <param name="Snapshot">New value + quality + timestamps.</param>
public sealed record DataChangeEventArgs(
ISubscriptionHandle SubscriptionHandle,
string FullReference,
DataValueSnapshot Snapshot);

View File

@@ -0,0 +1,15 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// Driver capability for discovering tags and hierarchy from the backend.
/// Streams discovered nodes into <see cref="IAddressSpaceBuilder"/> rather than
/// buffering the entire tree (decision #52 — supports incremental / large address spaces).
/// </summary>
public interface ITagDiscovery
{
/// <summary>
/// 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.
/// </summary>
Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,34 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// Driver capability for on-demand writes. Optional — read-only drivers (a hypothetical
/// historian-only adapter, for example) can omit this.
/// </summary>
/// <remarks>
/// Per <c>docs/v2/plan.md</c> decisions #44 + #45 — <b>writes are NOT auto-retried by default</b>.
/// 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 <c>Tag.WriteIdempotent = true</c> in the central config DB
/// enables retry; otherwise the OPC UA client decides whether to re-issue.
/// </remarks>
public interface IWritable
{
/// <summary>
/// Write a batch of values to the driver. Returns one status per requested write,
/// in the same order.
/// </summary>
/// <param name="writes">Pairs of full reference + value to write.</param>
/// <param name="cancellationToken">Cancellation token; the driver should abort the batch if cancelled.</param>
Task<IReadOnlyList<WriteResult>> WriteAsync(
IReadOnlyList<WriteRequest> writes,
CancellationToken cancellationToken);
}
/// <summary>One write request in a batch.</summary>
/// <param name="FullReference">Driver-side full reference (matches <see cref="DriverAttributeInfo.FullName"/>).</param>
/// <param name="Value">Value to write; type must be compatible with the attribute's <see cref="DriverDataType"/>.</param>
public sealed record WriteRequest(string FullReference, object? Value);
/// <summary>Result of one write in a batch.</summary>
/// <param name="StatusCode">OPC UA status code (numeric value matches the OPC UA spec).</param>
public sealed record WriteResult(uint StatusCode);

View File

@@ -0,0 +1,23 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// Security classification for write authorization on a tag, mirroring
/// the v1 Galaxy SecurityClassification model documented in <c>docs/DataTypeMapping.md</c>.
/// Generalized so non-Galaxy drivers can declare per-tag write protection levels.
/// </summary>
/// <remarks>
/// Maps to <c>NodePermissions</c> write tiers in <c>docs/v2/acl-design.md</c>:
/// FreeAccess + Operate require <c>WriteOperate</c>; Tune requires <c>WriteTune</c>;
/// Configure requires <c>WriteConfigure</c>; SecuredWrite + VerifiedWrite + ViewOnly
/// are read-only from OPC UA (v1 behavior preserved).
/// </remarks>
public enum SecurityClassification
{
FreeAccess = 0,
Operate = 1,
SecuredWrite = 2,
VerifiedWrite = 3,
Tune = 4,
Configure = 5,
ViewOnly = 6,
}

View File

@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
</PropertyGroup>
<ItemGroup>
<None Include="README.md" />
</ItemGroup>
</Project>

View File

@@ -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<KeyNotFoundException>(() => 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<InvalidOperationException>(() => registry.Register(SampleMetadata("Modbus")));
}
[Fact]
public void Register_DuplicateTypeIsCaseInsensitive()
{
var registry = new DriverTypeRegistry();
registry.Register(SampleMetadata("Modbus"));
Should.Throw<InvalidOperationException>(() => 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<ArgumentException>(() => registry.Get(typeName!));
}
}

View File

@@ -0,0 +1,71 @@
using System.Reflection;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests;
/// <summary>
/// Asserts that <c>Core.Abstractions</c> stays a true contract project — it must not depend on
/// any implementation type, any other OtOpcUa project, or anything beyond BCL + System types.
/// Per <c>docs/v2/plan.md</c> decision #59 (Core.Abstractions internal-only for now; design as
/// if public to minimize churn later).
/// </summary>
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.");
}
}

View File

@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3" Version="1.1.0"/>
<PackageReference Include="Shouldly" Version="4.3.0"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
</ItemGroup>
</Project>