chore: organize solution into module folders (Core/Server/Drivers/Client/Tooling)
Group all 69 projects into category subfolders under src/ and tests/ so the Rider Solution Explorer mirrors the module structure. Folders: Core, Server, Drivers (with a nested Driver CLIs subfolder), Client, Tooling. - Move every project folder on disk with git mv (history preserved as renames). - Recompute relative paths in 57 .csproj files: cross-category ProjectReferences, the lib/ HintPath+None refs in Driver.Historian.Wonderware, and the external mxaccessgw refs in Driver.Galaxy and its test project. - Rebuild ZB.MOM.WW.OtOpcUa.slnx with nested solution folders. - Re-prefix project paths in functional scripts (e2e, compliance, smoke SQL, integration, install). Build green (0 errors); unit tests pass. Docs left for a separate pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
@@ -0,0 +1,73 @@
|
||||
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>
|
||||
/// <param name="IsAlarm">
|
||||
/// True when this attribute represents an alarm condition (Galaxy: has an
|
||||
/// <c>AlarmExtension</c> primitive). The generic node-manager enriches the variable with an
|
||||
/// OPC UA <c>AlarmConditionState</c> when true. Defaults to false so existing non-Galaxy
|
||||
/// drivers aren't forced to flow a flag they don't produce.
|
||||
/// </param>
|
||||
/// <param name="WriteIdempotent">
|
||||
/// True when a timed-out or failed write to this attribute is safe to replay. Per
|
||||
/// <c>docs/v2/plan.md</c> decisions #44, #45, #143 — writes are NOT auto-retried by default
|
||||
/// because replaying a pulse / alarm-ack / counter-increment / recipe-step advance can
|
||||
/// duplicate field actions. Drivers flag only tags whose semantics make retry safe
|
||||
/// (holding registers with level-set values, set-point writes to analog tags) — the
|
||||
/// capability invoker respects this flag when deciding whether to apply Polly retry.
|
||||
/// </param>
|
||||
/// <param name="Source">
|
||||
/// Per ADR-002 — discriminates which runtime subsystem owns this node's dispatch.
|
||||
/// Defaults to <see cref="NodeSourceKind.Driver"/> so existing callers are unchanged.
|
||||
/// </param>
|
||||
/// <param name="VirtualTagId">
|
||||
/// Set when <paramref name="Source"/> is <see cref="NodeSourceKind.Virtual"/> — stable
|
||||
/// logical id the VirtualTagEngine addresses by. Null otherwise.
|
||||
/// </param>
|
||||
/// <param name="ScriptedAlarmId">
|
||||
/// Set when <paramref name="Source"/> is <see cref="NodeSourceKind.ScriptedAlarm"/> —
|
||||
/// stable logical id the ScriptedAlarmEngine addresses by. Null otherwise.
|
||||
/// </param>
|
||||
public sealed record DriverAttributeInfo(
|
||||
string FullName,
|
||||
DriverDataType DriverDataType,
|
||||
bool IsArray,
|
||||
uint? ArrayDim,
|
||||
SecurityClassification SecurityClass,
|
||||
bool IsHistorized,
|
||||
bool IsAlarm = false,
|
||||
bool WriteIdempotent = false,
|
||||
NodeSourceKind Source = NodeSourceKind.Driver,
|
||||
string? VirtualTagId = null,
|
||||
string? ScriptedAlarmId = null);
|
||||
|
||||
/// <summary>
|
||||
/// Per ADR-002 — discriminates which runtime subsystem owns this node's Read/Write/
|
||||
/// Subscribe dispatch. <c>Driver</c> = a real IDriver capability surface;
|
||||
/// <c>Virtual</c> = a Phase 7 <see cref="DriverAttributeInfo"/>.VirtualTagId'd tag
|
||||
/// computed by the VirtualTagEngine; <c>ScriptedAlarm</c> = a scripted Part 9 alarm
|
||||
/// materialized by the ScriptedAlarmEngine.
|
||||
/// </summary>
|
||||
public enum NodeSourceKind
|
||||
{
|
||||
Driver = 0,
|
||||
Virtual = 1,
|
||||
ScriptedAlarm = 2,
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Enumerates the driver-capability surface points guarded by Phase 6.1 resilience pipelines.
|
||||
/// Each value corresponds to one method (or tightly-related method group) on the
|
||||
/// <c>Core.Abstractions</c> capability interfaces (<see cref="IReadable"/>, <see cref="IWritable"/>,
|
||||
/// <see cref="ITagDiscovery"/>, <see cref="ISubscribable"/>, <see cref="IHostConnectivityProbe"/>,
|
||||
/// <see cref="IAlarmSource"/>, <see cref="IHistoryProvider"/>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Per <c>docs/v2/plan.md</c> decision #143 (per-capability retry policy): Read / HistoryRead /
|
||||
/// Discover / Probe / AlarmSubscribe auto-retry; <see cref="Write"/> does NOT retry unless the
|
||||
/// tag-definition carries <see cref="WriteIdempotentAttribute"/>. Alarm-acknowledge is treated
|
||||
/// as a write for retry semantics (an alarm-ack is not idempotent at the plant-floor acknowledgement
|
||||
/// level even if the OPC UA spec permits re-issue).
|
||||
/// </remarks>
|
||||
public enum DriverCapability
|
||||
{
|
||||
/// <summary>Batch <see cref="IReadable.ReadAsync"/>. Retries by default.</summary>
|
||||
Read,
|
||||
|
||||
/// <summary>Batch <see cref="IWritable.WriteAsync"/>. Does not retry unless tag is <see cref="WriteIdempotentAttribute">idempotent</see>.</summary>
|
||||
Write,
|
||||
|
||||
/// <summary><see cref="ITagDiscovery.DiscoverAsync"/>. Retries by default.</summary>
|
||||
Discover,
|
||||
|
||||
/// <summary><see cref="ISubscribable.SubscribeAsync"/> and unsubscribe. Retries by default.</summary>
|
||||
Subscribe,
|
||||
|
||||
/// <summary><see cref="IHostConnectivityProbe"/> probe loop. Retries by default.</summary>
|
||||
Probe,
|
||||
|
||||
/// <summary><see cref="IAlarmSource.SubscribeAlarmsAsync"/>. Retries by default.</summary>
|
||||
AlarmSubscribe,
|
||||
|
||||
/// <summary><see cref="IAlarmSource.AcknowledgeAsync"/>. Does NOT retry — ack is a write-shaped operation (decision #143).</summary>
|
||||
AlarmAcknowledge,
|
||||
|
||||
/// <summary><see cref="IHistoryProvider"/> reads (Raw/Processed/AtTime/Events). Retries by default.</summary>
|
||||
HistoryRead,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Stability tier of a driver type. Determines which cross-cutting runtime protections
|
||||
/// apply — per-tier retry defaults, memory-tracking thresholds, and whether out-of-process
|
||||
/// supervision with process-level recycle is in play.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Per <c>docs/v2/driver-stability.md</c> §2-4 and <c>docs/v2/plan.md</c> decisions #63-74.
|
||||
///
|
||||
/// <list type="bullet">
|
||||
/// <item><b>A</b> — managed, known-good SDK; low blast radius. In-process. Fast retries.
|
||||
/// Examples: OPC UA Client (OPCFoundation stack), S7 (S7NetPlus).</item>
|
||||
/// <item><b>B</b> — native or semi-trusted SDK with an in-process footprint. Examples: Modbus.</item>
|
||||
/// <item><b>C</b> — unmanaged SDK with COM/STA constraints, leak risk, or other out-of-process
|
||||
/// requirements. Must run as a separate Host process behind a Proxy with a supervisor that
|
||||
/// can recycle the process on hard-breach. Example: Galaxy (MXAccess COM).</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>Process-kill protections (<c>MemoryRecycle</c>, <c>ScheduledRecycleScheduler</c>) are
|
||||
/// Tier C only per decisions #73-74 and #145 — killing an in-process Tier A/B driver also kills
|
||||
/// every OPC UA session and every co-hosted driver, blast-radius worse than the leak.</para>
|
||||
/// </remarks>
|
||||
public enum DriverTier
|
||||
{
|
||||
/// <summary>Managed SDK, in-process, low blast radius.</summary>
|
||||
A,
|
||||
|
||||
/// <summary>Native or semi-trusted SDK, in-process.</summary>
|
||||
B,
|
||||
|
||||
/// <summary>Unmanaged SDK, out-of-process required with Proxy+Host+Supervisor.</summary>
|
||||
C,
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
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>
|
||||
/// <param name="Tier">
|
||||
/// Stability tier per <c>docs/v2/driver-stability.md</c> §2-4 and <c>docs/v2/plan.md</c>
|
||||
/// decisions #63-74. Drives the shared resilience pipeline defaults
|
||||
/// (<see cref="Tier"/> × capability → <c>CapabilityPolicy</c>), the <c>MemoryTracking</c>
|
||||
/// hybrid-formula constants, and whether process-level <c>MemoryRecycle</c> / scheduled-
|
||||
/// recycle protections apply (Tier C only). Every registered driver type must declare one.
|
||||
/// </param>
|
||||
public sealed record DriverTypeMetadata(
|
||||
string TypeName,
|
||||
NamespaceKindCompatibility AllowedNamespaceKinds,
|
||||
string DriverConfigJsonSchema,
|
||||
string? DeviceConfigJsonSchema,
|
||||
string TagConfigJsonSchema,
|
||||
DriverTier Tier);
|
||||
|
||||
/// <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,
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Point-in-time state of a single historian cluster node, included inside
|
||||
/// <see cref="HistorianHealthSnapshot.Nodes"/> when the backend is clustered.
|
||||
/// </summary>
|
||||
/// <param name="Name">Node identifier — backend-specific (typically a hostname).</param>
|
||||
/// <param name="IsHealthy">True when the node is currently considered usable for reads.</param>
|
||||
/// <param name="CooldownUntil">When the next retry against an unhealthy node is allowed; null when no cooldown is active.</param>
|
||||
/// <param name="FailureCount">Consecutive failures observed against this node since the last success.</param>
|
||||
/// <param name="LastError">Diagnostic text from the last failure against this node; null when no failures.</param>
|
||||
/// <param name="LastFailureTime">UTC of the last failure against this node; null when no failures.</param>
|
||||
public sealed record HistorianClusterNodeState(
|
||||
string Name,
|
||||
bool IsHealthy,
|
||||
DateTime? CooldownUntil,
|
||||
int FailureCount,
|
||||
string? LastError,
|
||||
DateTime? LastFailureTime);
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Point-in-time runtime health of a historian data source. Returned by
|
||||
/// <see cref="IHistorianDataSource.GetHealthSnapshot"/> and projected onto the
|
||||
/// server status dashboard.
|
||||
/// </summary>
|
||||
/// <param name="TotalQueries">Lifetime count of read calls received.</param>
|
||||
/// <param name="TotalSuccesses">Subset of <paramref name="TotalQueries"/> that completed without error.</param>
|
||||
/// <param name="TotalFailures">Subset of <paramref name="TotalQueries"/> that ended in error.</param>
|
||||
/// <param name="ConsecutiveFailures">Failures since the last success — non-zero means the source is currently degraded.</param>
|
||||
/// <param name="LastSuccessTime">UTC of the most recent successful read; null if none yet.</param>
|
||||
/// <param name="LastFailureTime">UTC of the most recent failed read; null if none yet.</param>
|
||||
/// <param name="LastError">Diagnostic text from the most recent failure; null when no failures recorded.</param>
|
||||
/// <param name="ProcessConnectionOpen">True when the source's process-data connection is currently established.</param>
|
||||
/// <param name="EventConnectionOpen">True when the source's event-data connection is currently established. Some backends share one connection — implementations may report the same value here as <paramref name="ProcessConnectionOpen"/>.</param>
|
||||
/// <param name="ActiveProcessNode">Cluster node currently serving process reads; null when no node is active or the backend is non-clustered.</param>
|
||||
/// <param name="ActiveEventNode">Cluster node currently serving event reads; null when no node is active or the backend is non-clustered.</param>
|
||||
/// <param name="Nodes">Per-cluster-node state. Empty when the backend is non-clustered.</param>
|
||||
public sealed record HistorianHealthSnapshot(
|
||||
long TotalQueries,
|
||||
long TotalSuccesses,
|
||||
long TotalFailures,
|
||||
int ConsecutiveFailures,
|
||||
DateTime? LastSuccessTime,
|
||||
DateTime? LastFailureTime,
|
||||
string? LastError,
|
||||
bool ProcessConnectionOpen,
|
||||
bool EventConnectionOpen,
|
||||
string? ActiveProcessNode,
|
||||
string? ActiveEventNode,
|
||||
IReadOnlyList<HistorianClusterNodeState> Nodes);
|
||||
@@ -0,0 +1,74 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Server-side historian data source. Registered with the server's history router
|
||||
/// and resolved per OPC UA namespace, independent of any driver's lifecycle.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Distinct from <see cref="IHistoryProvider"/>:
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="IHistoryProvider"/> is a *driver capability* — the server
|
||||
/// dispatches to it via the driver instance.</item>
|
||||
/// <item><see cref="IHistorianDataSource"/> is a *server registration* — the
|
||||
/// server resolves it via namespace and calls it directly, so a single
|
||||
/// historian (e.g. Wonderware) can serve many drivers' nodes, and drivers can
|
||||
/// restart without dropping history availability.</item>
|
||||
/// </list>
|
||||
/// All values returned use the shared <see cref="DataValueSnapshot"/> /
|
||||
/// <see cref="HistoricalEvent"/> shapes; backend-specific quality / type encodings
|
||||
/// are translated to OPC UA <c>StatusCode</c> uints inside the data source.
|
||||
/// </remarks>
|
||||
public interface IHistorianDataSource : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Read raw historical samples for a single tag over a time range.
|
||||
/// </summary>
|
||||
Task<HistoryReadResult> ReadRawAsync(
|
||||
string fullReference,
|
||||
DateTime startUtc,
|
||||
DateTime endUtc,
|
||||
uint maxValuesPerNode,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Read processed (interval-bucketed) samples — average / min / max / count / etc.
|
||||
/// A bucket with no source data returns a sample whose
|
||||
/// <see cref="DataValueSnapshot.StatusCode"/> indicates BadNoData.
|
||||
/// </summary>
|
||||
Task<HistoryReadResult> ReadProcessedAsync(
|
||||
string fullReference,
|
||||
DateTime startUtc,
|
||||
DateTime endUtc,
|
||||
TimeSpan interval,
|
||||
HistoryAggregateType aggregate,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Read one sample per requested timestamp — OPC UA HistoryReadAtTime service.
|
||||
/// Implementations interpolate or return prior-boundary samples per their
|
||||
/// backend's policy. The returned list MUST be the same length and order as
|
||||
/// <paramref name="timestampsUtc"/>; gaps are returned as Bad-quality snapshots.
|
||||
/// </summary>
|
||||
Task<HistoryReadResult> ReadAtTimeAsync(
|
||||
string fullReference,
|
||||
IReadOnlyList<DateTime> timestampsUtc,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Read historical alarm / event records — OPC UA HistoryReadEvents service.
|
||||
/// Distinct from any live event stream; sources here come from the historian's
|
||||
/// event log. <paramref name="sourceName"/> is null to return all sources.
|
||||
/// </summary>
|
||||
Task<HistoricalEventsResult> ReadEventsAsync(
|
||||
string? sourceName,
|
||||
DateTime startUtc,
|
||||
DateTime endUtc,
|
||||
int maxEvents,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Point-in-time health snapshot for diagnostics and dashboards. Pure
|
||||
/// observation; never blocks on backend I/O.
|
||||
/// </summary>
|
||||
HistorianHealthSnapshot GetHealthSnapshot();
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
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; }
|
||||
|
||||
/// <summary>
|
||||
/// Annotate this variable with an OPC UA <c>AlarmConditionState</c>. Drivers with
|
||||
/// <see cref="DriverAttributeInfo.IsAlarm"/> = true call this during discovery so the
|
||||
/// concrete address-space builder can materialize a sibling condition node. The returned
|
||||
/// sink receives lifecycle transitions raised through <see cref="IAlarmSource.OnAlarmEvent"/>
|
||||
/// — the generic node manager wires the subscription; the concrete builder decides how
|
||||
/// to surface the state (e.g. OPC UA <c>AlarmConditionState.Activate</c>,
|
||||
/// <c>Acknowledge</c>, <c>Deactivate</c>).
|
||||
/// </summary>
|
||||
IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metadata used to materialize an OPC UA <c>AlarmConditionState</c> sibling for a variable.
|
||||
/// Populated by the driver's discovery step; concrete builders decide how to surface it.
|
||||
/// </summary>
|
||||
/// <param name="SourceName">Human-readable alarm name used for the <c>SourceName</c> event field.</param>
|
||||
/// <param name="InitialSeverity">Severity at address-space build time; updates arrive via <see cref="IAlarmConditionSink"/>.</param>
|
||||
/// <param name="InitialDescription">Initial description; updates arrive via <see cref="IAlarmConditionSink"/>.</param>
|
||||
/// <param name="InAlarmRef">
|
||||
/// Driver-side full reference for the boolean attribute that toggles when the
|
||||
/// alarm condition becomes active. Consumed by the server-level alarm-condition
|
||||
/// service to subscribe to active/inactive transitions. Null when the driver
|
||||
/// reports alarm transitions through some other channel.
|
||||
/// </param>
|
||||
/// <param name="PriorityRef">
|
||||
/// Driver-side full reference for the integer attribute carrying the alarm's
|
||||
/// current priority / severity. Live updates flow through the same subscription
|
||||
/// pipeline as <paramref name="InAlarmRef"/>. Null when the driver does not
|
||||
/// expose live priority changes.
|
||||
/// </param>
|
||||
/// <param name="DescAttrNameRef">
|
||||
/// Driver-side full reference for the string attribute carrying the human-readable
|
||||
/// description / message. Null when the driver does not expose a live description.
|
||||
/// </param>
|
||||
/// <param name="AckedRef">
|
||||
/// Driver-side full reference for the boolean attribute that toggles when the
|
||||
/// alarm is acknowledged. Null when acknowledgement is not observable on the
|
||||
/// driver side.
|
||||
/// </param>
|
||||
/// <param name="AckMsgWriteRef">
|
||||
/// Driver-side full reference the server writes to acknowledge the condition,
|
||||
/// typically the alarm's <c>.AckMsg</c> attribute. Null when the driver does not
|
||||
/// accept acknowledgement writes (or routes them through a separate API).
|
||||
/// </param>
|
||||
public sealed record AlarmConditionInfo(
|
||||
string SourceName,
|
||||
AlarmSeverity InitialSeverity,
|
||||
string? InitialDescription,
|
||||
string? InAlarmRef = null,
|
||||
string? PriorityRef = null,
|
||||
string? DescAttrNameRef = null,
|
||||
string? AckedRef = null,
|
||||
string? AckMsgWriteRef = null);
|
||||
|
||||
/// <summary>
|
||||
/// Sink a concrete address-space builder returns from <see cref="IVariableHandle.MarkAsAlarmCondition"/>.
|
||||
/// The generic node manager routes per-alarm <see cref="IAlarmSource.OnAlarmEvent"/> payloads here —
|
||||
/// the sink translates the transition into an OPC UA condition state change or whatever the
|
||||
/// concrete builder's backing address space supports.
|
||||
/// </summary>
|
||||
public interface IAlarmConditionSink
|
||||
{
|
||||
/// <summary>Push an alarm transition (Active / Acknowledged / Inactive) for this condition.</summary>
|
||||
void OnTransition(AlarmEventArgs args);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
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&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>
|
||||
/// <param name="SubscriptionHandle">Subscription this event belongs to.</param>
|
||||
/// <param name="SourceNodeId">Driver-side identifier for the alarm source.</param>
|
||||
/// <param name="ConditionId">Stable id correlating raise / ack / clear of the same condition.</param>
|
||||
/// <param name="AlarmType">Driver-defined alarm type name (e.g. AnalogLimitAlarm.HiHi).</param>
|
||||
/// <param name="Message">Human-readable alarm description.</param>
|
||||
/// <param name="Severity">Four-bucket severity ladder.</param>
|
||||
/// <param name="SourceTimestampUtc">When this transition occurred.</param>
|
||||
/// <param name="OperatorComment">
|
||||
/// Operator-supplied comment recorded by the upstream alarm system on Acknowledge
|
||||
/// transitions. Null on raise / clear, or when the upstream path can't surface
|
||||
/// the comment (the Galaxy sub-attribute fallback path collapses comments into a
|
||||
/// single string write — null on that path; the driver-native gateway path
|
||||
/// populates this).
|
||||
/// </param>
|
||||
/// <param name="OriginalRaiseTimestampUtc">
|
||||
/// When the alarm originally entered the active state. Preserved across
|
||||
/// Acknowledge transitions so OPC UA Part 9 conditions keep the original raise
|
||||
/// time in <c>Time</c>. Null when the upstream path doesn't surface it.
|
||||
/// </param>
|
||||
/// <param name="AlarmCategory">
|
||||
/// Upstream alarm taxonomy bucket (e.g. <c>Process</c> / <c>Safety</c> /
|
||||
/// <c>Diagnostics</c>). Maps to OPC UA <c>ConditionClassName</c> downstream when
|
||||
/// a class mapping is configured. Null when the upstream path doesn't carry it.
|
||||
/// </param>
|
||||
public sealed record AlarmEventArgs(
|
||||
IAlarmSubscriptionHandle SubscriptionHandle,
|
||||
string SourceNodeId,
|
||||
string ConditionId,
|
||||
string AlarmType,
|
||||
string Message,
|
||||
AlarmSeverity Severity,
|
||||
DateTime SourceTimestampUtc,
|
||||
string? OperatorComment = null,
|
||||
DateTime? OriginalRaiseTimestampUtc = null,
|
||||
string? AlarmCategory = null);
|
||||
|
||||
/// <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 }
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Process-level supervisor contract a Tier C driver's out-of-process topology provides
|
||||
/// (e.g. <c>Driver.Galaxy.Proxy/Supervisor/</c>). Concerns: restart the Host process when a
|
||||
/// hard fault is detected (memory breach, wedge, scheduled recycle window).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Per <c>docs/v2/plan.md</c> decisions #68, #73-74, and #145. Tier A/B drivers do NOT have
|
||||
/// a supervisor because they run in-process — recycling would kill every OPC UA session and
|
||||
/// every co-hosted driver. The Core.Stability layer only invokes this interface for Tier C
|
||||
/// instances after asserting the tier via <see cref="DriverTypeMetadata.Tier"/>.
|
||||
/// </remarks>
|
||||
public interface IDriverSupervisor
|
||||
{
|
||||
/// <summary>Driver instance this supervisor governs.</summary>
|
||||
string DriverInstanceId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Request the supervisor to recycle (terminate + restart) the Host process. Implementations
|
||||
/// are expected to be idempotent under repeat calls during an in-flight recycle.
|
||||
/// </summary>
|
||||
/// <param name="reason">Human-readable reason — flows into the supervisor's logs.</param>
|
||||
/// <param name="cancellationToken">Cancels the recycle request; an in-flight restart is not interrupted.</param>
|
||||
Task RecycleAsync(string reason, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
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>
|
||||
/// Read one sample per requested timestamp — OPC UA HistoryReadAtTime service. The
|
||||
/// driver interpolates (or returns the prior-boundary sample) when no exact match
|
||||
/// exists. Optional; drivers that can't interpolate throw <see cref="NotSupportedException"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Default implementation throws. Drivers opt in by overriding; keeps existing
|
||||
/// <c>IHistoryProvider</c> implementations compiling without forcing a ReadAtTime path
|
||||
/// they may not have a backend for.
|
||||
/// </remarks>
|
||||
Task<HistoryReadResult> ReadAtTimeAsync(
|
||||
string fullReference,
|
||||
IReadOnlyList<DateTime> timestampsUtc,
|
||||
CancellationToken cancellationToken)
|
||||
=> throw new NotSupportedException(
|
||||
$"{GetType().Name} does not implement ReadAtTimeAsync. " +
|
||||
"Drivers whose backends support at-time reads override this method.");
|
||||
|
||||
/// <summary>
|
||||
/// Read historical alarm/event records — OPC UA HistoryReadEvents service. Distinct
|
||||
/// from the live event stream — historical rows come from an event historian (Galaxy's
|
||||
/// Alarm Provider history log, etc.) rather than the driver's active subscription.
|
||||
/// </summary>
|
||||
/// <param name="sourceName">
|
||||
/// Optional filter: null means "all sources", otherwise restrict to events from that
|
||||
/// source-object name. Drivers may ignore the filter if the backend doesn't support it.
|
||||
/// </param>
|
||||
/// <param name="startUtc">Inclusive lower bound on <c>EventTimeUtc</c>.</param>
|
||||
/// <param name="endUtc">Exclusive upper bound on <c>EventTimeUtc</c>.</param>
|
||||
/// <param name="maxEvents">Upper cap on returned events — the driver's backend enforces this.</param>
|
||||
/// <param name="cancellationToken">Request cancellation.</param>
|
||||
/// <remarks>
|
||||
/// Default implementation throws. Only drivers with an event historian (Galaxy via the
|
||||
/// Wonderware Alarm & Events log) override. Modbus / the OPC UA Client driver stay
|
||||
/// with the default and let callers see <c>BadHistoryOperationUnsupported</c>.
|
||||
/// </remarks>
|
||||
Task<HistoricalEventsResult> ReadEventsAsync(
|
||||
string? sourceName,
|
||||
DateTime startUtc,
|
||||
DateTime endUtc,
|
||||
int maxEvents,
|
||||
CancellationToken cancellationToken)
|
||||
=> throw new NotSupportedException(
|
||||
$"{GetType().Name} does not implement ReadEventsAsync. " +
|
||||
"Drivers whose backends have an event historian override this method.");
|
||||
}
|
||||
|
||||
/// <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,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One row returned by <see cref="IHistoryProvider.ReadEventsAsync"/> — a historical
|
||||
/// alarm/event record, not the OPC UA live-event stream. Fields match the minimum set the
|
||||
/// Server needs to populate a <c>HistoryEventFieldList</c> for HistoryReadEvents responses.
|
||||
/// </summary>
|
||||
/// <param name="EventId">Stable unique id for the event — driver-specific format.</param>
|
||||
/// <param name="SourceName">Source object that emitted the event. May differ from the <c>sourceName</c> filter the caller passed (fuzzy matches).</param>
|
||||
/// <param name="EventTimeUtc">Process-side timestamp — when the event actually occurred.</param>
|
||||
/// <param name="ReceivedTimeUtc">Historian-side timestamp — when the historian persisted the row; may lag <paramref name="EventTimeUtc"/> by the historian's buffer flush cadence.</param>
|
||||
/// <param name="Message">Human-readable message text.</param>
|
||||
/// <param name="Severity">OPC UA severity (1-1000). Drivers map their native priority scale onto this range.</param>
|
||||
public sealed record HistoricalEvent(
|
||||
string EventId,
|
||||
string? SourceName,
|
||||
DateTime EventTimeUtc,
|
||||
DateTime ReceivedTimeUtc,
|
||||
string? Message,
|
||||
ushort Severity);
|
||||
|
||||
/// <summary>Result of a <see cref="IHistoryProvider.ReadEventsAsync"/> call.</summary>
|
||||
/// <param name="Events">Events in chronological order by <c>EventTimeUtc</c>.</param>
|
||||
/// <param name="ContinuationPoint">Opaque token for the next call when more events are available; null when complete.</param>
|
||||
public sealed record HistoricalEventsResult(
|
||||
IReadOnlyList<HistoricalEvent> Events,
|
||||
byte[]? ContinuationPoint);
|
||||
@@ -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 }
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Optional driver capability that maps a per-tag full reference to the underlying host
|
||||
/// name responsible for serving it. Drivers with a one-host topology (Galaxy on one
|
||||
/// MXAccess endpoint, OpcUaClient against one remote server, S7 against one PLC) do NOT
|
||||
/// need to implement this — the dispatch layer falls back to
|
||||
/// <see cref="IDriver.DriverInstanceId"/> as a single-host key.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Multi-host drivers (Modbus with N PLCs, hypothetical AB CIP across a rack, etc.)
|
||||
/// implement this so the Phase 6.1 resilience pipeline can be keyed on
|
||||
/// <c>(DriverInstanceId, ResolvedHostName, DriverCapability)</c> per decision #144. One
|
||||
/// dead PLC behind a multi-device Modbus driver then trips only its own breaker; healthy
|
||||
/// siblings keep serving.</para>
|
||||
///
|
||||
/// <para>Implementations must be fast + allocation-free on the hot path — <c>ReadAsync</c>
|
||||
/// / <c>WriteAsync</c> call this once per tag. A simple <c>Dictionary<string, string></c>
|
||||
/// lookup is typical.</para>
|
||||
///
|
||||
/// <para>When the fullRef doesn't map to a known host (caller passes an unregistered
|
||||
/// reference, or the tag was removed mid-flight), implementations should return the
|
||||
/// driver's default-host string rather than throwing — the invoker falls back to a
|
||||
/// single-host pipeline for that call, which is safer than tearing down the request.</para>
|
||||
/// </remarks>
|
||||
public interface IPerCallHostResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolve the host name for the given driver-side full reference. Returned value is
|
||||
/// used as the <c>hostName</c> argument to the Phase 6.1 <c>CapabilityInvoker</c> so
|
||||
/// per-host breaker isolation + per-host bulkhead accounting both kick in.
|
||||
/// </summary>
|
||||
string ResolveHost(string fullReference);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1,59 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Every OPC UA operation surface the Phase 6.2 authorization evaluator gates, per
|
||||
/// <c>docs/v2/implementation/phase-6-2-authorization-runtime.md</c> §Stream C and
|
||||
/// decision #143. The evaluator maps each operation onto the corresponding
|
||||
/// <c>NodePermissions</c> bit(s) to decide whether the calling session is allowed.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Write is split out into <see cref="WriteOperate"/> / <see cref="WriteTune"/> /
|
||||
/// <see cref="WriteConfigure"/> because the underlying driver-reported
|
||||
/// <see cref="SecurityClassification"/> already carries that distinction — the
|
||||
/// evaluator maps the requested tag's security class to the matching operation value
|
||||
/// before checking the permission bit.
|
||||
/// </remarks>
|
||||
public enum OpcUaOperation
|
||||
{
|
||||
/// <summary>
|
||||
/// <c>Browse</c> + <c>TranslateBrowsePathsToNodeIds</c>. Ancestor visibility implied
|
||||
/// when any descendant has a grant; denied ancestors filter from browse results.
|
||||
/// </summary>
|
||||
Browse,
|
||||
|
||||
/// <summary><c>Read</c> on a variable node.</summary>
|
||||
Read,
|
||||
|
||||
/// <summary><c>Write</c> when the target has <see cref="SecurityClassification.Operate"/> / <see cref="SecurityClassification.FreeAccess"/>.</summary>
|
||||
WriteOperate,
|
||||
|
||||
/// <summary><c>Write</c> when the target has <see cref="SecurityClassification.Tune"/>.</summary>
|
||||
WriteTune,
|
||||
|
||||
/// <summary><c>Write</c> when the target has <see cref="SecurityClassification.Configure"/>.</summary>
|
||||
WriteConfigure,
|
||||
|
||||
/// <summary><c>HistoryRead</c> — uses its own <c>NodePermissions.HistoryRead</c> bit; Read alone is NOT sufficient (decision in Phase 6.2 Compliance).</summary>
|
||||
HistoryRead,
|
||||
|
||||
/// <summary><c>HistoryUpdate</c> — annotation / insert / delete on historian.</summary>
|
||||
HistoryUpdate,
|
||||
|
||||
/// <summary><c>CreateMonitoredItems</c>. Per-item denial in mixed-authorization batches.</summary>
|
||||
CreateMonitoredItems,
|
||||
|
||||
/// <summary><c>TransferSubscriptions</c>. Re-evaluates transferred items against current auth state.</summary>
|
||||
TransferSubscriptions,
|
||||
|
||||
/// <summary><c>Call</c> on a Method node.</summary>
|
||||
Call,
|
||||
|
||||
/// <summary>Alarm <c>Acknowledge</c>.</summary>
|
||||
AlarmAcknowledge,
|
||||
|
||||
/// <summary>Alarm <c>Confirm</c>.</summary>
|
||||
AlarmConfirm,
|
||||
|
||||
/// <summary>Alarm <c>Shelve</c> / <c>Unshelve</c>.</summary>
|
||||
AlarmShelve,
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Shared poll-based subscription engine for drivers whose underlying protocol has no
|
||||
/// native push model (Modbus, AB CIP, S7, FOCAS). Owns one background Task per subscription
|
||||
/// that periodically invokes the supplied reader, diffs each snapshot against the last
|
||||
/// known value, and dispatches a change callback per changed tag. Extracted from
|
||||
/// <c>ModbusDriver</c> (AB CIP PR 1) so poll-based drivers don't each re-ship the loop,
|
||||
/// floor logic, and lifecycle plumbing.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>The engine is read-path agnostic: it calls the supplied <c>reader</c> delegate
|
||||
/// and trusts the driver to map protocol errors into <see cref="DataValueSnapshot.StatusCode"/>.
|
||||
/// Callbacks fire on: (a) the first poll after subscribe (initial-data push per the OPC UA
|
||||
/// Part 4 convention), (b) any subsequent poll where the boxed value or status code differs
|
||||
/// from the previously-seen snapshot.</para>
|
||||
///
|
||||
/// <para>Exceptions thrown by the reader on the initial poll or any subsequent poll are
|
||||
/// swallowed — the loop continues on the next tick. The driver's own health surface is
|
||||
/// where transient poll failures should be reported; the engine intentionally does not
|
||||
/// double-book that responsibility.</para>
|
||||
/// </remarks>
|
||||
public sealed class PollGroupEngine : IAsyncDisposable
|
||||
{
|
||||
private readonly Func<IReadOnlyList<string>, CancellationToken, Task<IReadOnlyList<DataValueSnapshot>>> _reader;
|
||||
private readonly Action<ISubscriptionHandle, string, DataValueSnapshot> _onChange;
|
||||
private readonly TimeSpan _minInterval;
|
||||
private readonly ConcurrentDictionary<long, SubscriptionState> _subscriptions = new();
|
||||
private long _nextId;
|
||||
|
||||
/// <summary>Default floor for publishing intervals — matches the Modbus 100 ms cap.</summary>
|
||||
public static readonly TimeSpan DefaultMinInterval = TimeSpan.FromMilliseconds(100);
|
||||
|
||||
/// <param name="reader">Driver-supplied batch reader; snapshots MUST be returned in the same
|
||||
/// order as the input references.</param>
|
||||
/// <param name="onChange">Callback invoked per changed tag — the driver forwards to its own
|
||||
/// <see cref="ISubscribable.OnDataChange"/> event.</param>
|
||||
/// <param name="minInterval">Interval floor; anything below is clamped. Defaults to 100 ms
|
||||
/// per <see cref="DefaultMinInterval"/>.</param>
|
||||
public PollGroupEngine(
|
||||
Func<IReadOnlyList<string>, CancellationToken, Task<IReadOnlyList<DataValueSnapshot>>> reader,
|
||||
Action<ISubscriptionHandle, string, DataValueSnapshot> onChange,
|
||||
TimeSpan? minInterval = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(reader);
|
||||
ArgumentNullException.ThrowIfNull(onChange);
|
||||
_reader = reader;
|
||||
_onChange = onChange;
|
||||
_minInterval = minInterval ?? DefaultMinInterval;
|
||||
}
|
||||
|
||||
/// <summary>Register a new polled subscription and start its background loop.</summary>
|
||||
public ISubscriptionHandle Subscribe(IReadOnlyList<string> fullReferences, TimeSpan publishingInterval)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(fullReferences);
|
||||
var id = Interlocked.Increment(ref _nextId);
|
||||
var cts = new CancellationTokenSource();
|
||||
var interval = publishingInterval < _minInterval ? _minInterval : publishingInterval;
|
||||
var handle = new PollSubscriptionHandle(id);
|
||||
var state = new SubscriptionState(handle, [.. fullReferences], interval, cts);
|
||||
_subscriptions[id] = state;
|
||||
_ = Task.Run(() => PollLoopAsync(state, cts.Token), cts.Token);
|
||||
return handle;
|
||||
}
|
||||
|
||||
/// <summary>Cancel the background loop for a handle returned by <see cref="Subscribe"/>.</summary>
|
||||
/// <returns><c>true</c> when the handle was known to the engine and has been torn down.</returns>
|
||||
public bool Unsubscribe(ISubscriptionHandle handle)
|
||||
{
|
||||
if (handle is PollSubscriptionHandle h && _subscriptions.TryRemove(h.Id, out var state))
|
||||
{
|
||||
try { state.Cts.Cancel(); } catch { }
|
||||
state.Cts.Dispose();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Snapshot of active subscription count — exposed for driver diagnostics.</summary>
|
||||
public int ActiveSubscriptionCount => _subscriptions.Count;
|
||||
|
||||
private async Task PollLoopAsync(SubscriptionState state, CancellationToken ct)
|
||||
{
|
||||
// Initial-data push: every subscribed tag fires once at subscribe time regardless of
|
||||
// whether it has changed, satisfying OPC UA Part 4 initial-value semantics.
|
||||
try { await PollOnceAsync(state, forceRaise: true, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { return; }
|
||||
catch { /* first-read error tolerated — loop continues */ }
|
||||
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try { await Task.Delay(state.Interval, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { return; }
|
||||
|
||||
try { await PollOnceAsync(state, forceRaise: false, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { return; }
|
||||
catch { /* transient poll error — loop continues, driver health surface logs it */ }
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PollOnceAsync(SubscriptionState state, bool forceRaise, CancellationToken ct)
|
||||
{
|
||||
var snapshots = await _reader(state.TagReferences, ct).ConfigureAwait(false);
|
||||
for (var i = 0; i < state.TagReferences.Count; i++)
|
||||
{
|
||||
var tagRef = state.TagReferences[i];
|
||||
var current = snapshots[i];
|
||||
var lastSeen = state.LastValues.TryGetValue(tagRef, out var prev) ? prev : default;
|
||||
|
||||
if (forceRaise || !Equals(lastSeen?.Value, current.Value) || lastSeen?.StatusCode != current.StatusCode)
|
||||
{
|
||||
state.LastValues[tagRef] = current;
|
||||
_onChange(state.Handle, tagRef, current);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Cancel every active subscription. Idempotent.</summary>
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
foreach (var state in _subscriptions.Values)
|
||||
{
|
||||
try { state.Cts.Cancel(); } catch { }
|
||||
state.Cts.Dispose();
|
||||
}
|
||||
_subscriptions.Clear();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed record SubscriptionState(
|
||||
PollSubscriptionHandle Handle,
|
||||
IReadOnlyList<string> TagReferences,
|
||||
TimeSpan Interval,
|
||||
CancellationTokenSource Cts)
|
||||
{
|
||||
public ConcurrentDictionary<string, DataValueSnapshot> LastValues { get; }
|
||||
= new(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private sealed record PollSubscriptionHandle(long Id) : ISubscriptionHandle
|
||||
{
|
||||
public string DiagnosticId => $"poll-sub-{Id}";
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Opts a tag-definition record into auto-retry on <see cref="IWritable.WriteAsync"/> failures.
|
||||
/// Absence of this attribute means writes are <b>not</b> retried — a timed-out write may have
|
||||
/// already succeeded at the device, and replaying pulses, alarm acks, counter increments, or
|
||||
/// recipe-step advances can duplicate irreversible field actions.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Per <c>docs/v2/plan.md</c> decisions #44, #45, and #143. Applied to tag-definition POCOs
|
||||
/// (e.g. <c>ModbusTagDefinition</c>, <c>S7TagDefinition</c>, OPC UA client tag rows) at the
|
||||
/// property or record level. The <c>CapabilityInvoker</c> in <c>ZB.MOM.WW.OtOpcUa.Core.Resilience</c>
|
||||
/// reads this attribute via reflection once at driver-init time and caches the result; no
|
||||
/// per-write reflection cost.
|
||||
/// </remarks>
|
||||
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = true)]
|
||||
public sealed class WriteIdempotentAttribute : Attribute
|
||||
{
|
||||
}
|
||||
+17
@@ -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>
|
||||
Reference in New Issue
Block a user