docs: fill missing XML docs and strip tracking IDs from src comments
Clears CommentChecker findings in src/ (3,280 -> 209, the remainder being false positives like PLC-5/IEEE-754/M0.0) by adding missing summaries/params/returns, normalising inheritdoc usage, and removing Gitea/review tracking IDs while keeping the rationale they annotated. Comment-only: comment-stripped sources are identical to the parent commit.
This commit is contained in:
@@ -56,7 +56,7 @@ public class ReadCommand : CommandBase
|
||||
{
|
||||
// The SDK's Session.ReadValue surfaces a Bad node status (BadNodeIdUnknown,
|
||||
// BadUserAccessDenied, ...) as an exception. A read command's job is to REPORT that status,
|
||||
// not crash with a stack trace (#577) — session-level failures still propagate.
|
||||
// not crash with a stack trace — session-level failures still propagate.
|
||||
await console.Output.WriteLineAsync($"Node: {NodeId}");
|
||||
await console.Output.WriteLineAsync($"Value: ");
|
||||
await console.Output.WriteLineAsync($"Status: {(StatusCode)ex.StatusCode} ({StatusCodes.GetBrowseName(ex.StatusCode)})");
|
||||
|
||||
@@ -24,6 +24,7 @@ internal static class DisposeBridge
|
||||
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>Runs <paramref name="work" /> to completion (or the timeout), swallowing every failure.</summary>
|
||||
/// <param name="work">The asynchronous work to run; it receives a token that is cancelled when the timeout elapses.</param>
|
||||
public static void Run(Func<CancellationToken, Task> work)
|
||||
{
|
||||
using var cts = new CancellationTokenSource(Timeout);
|
||||
|
||||
@@ -462,7 +462,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
ThrowIfDisposed();
|
||||
ThrowIfNotConnected();
|
||||
|
||||
// The server's ShelvingState child is SDK-created with a numeric namespace-0 id, so a synthesized
|
||||
// The server's ShelvingState child is SDK-created with a numeric id in namespace 0, so a synthesized
|
||||
// "<condition>.ShelvingState" node id never exists. The shelve methods are called on the condition
|
||||
// object itself with the AlarmConditionType_ShelvingState_* method ids, which the server dispatches
|
||||
// to the condition's ShelvingState methods.
|
||||
@@ -884,6 +884,8 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
/// <summary>Builds the event filter (select clauses for the alarm and condition fields) used by alarm subscriptions.</summary>
|
||||
/// <returns>The populated <see cref="EventFilter"/>.</returns>
|
||||
internal static EventFilter CreateAlarmEventFilter()
|
||||
{
|
||||
var filter = new EventFilter();
|
||||
|
||||
@@ -58,7 +58,7 @@ public sealed class AkkaClusterOptions
|
||||
/// <see cref="RoleParser.ResolveEffectiveRoles(IReadOnlyList{string}?)"/>. That fallback was
|
||||
/// described here — and relied upon in the field — for a year before it was implemented; a
|
||||
/// node configured only through the env var joined the cluster carrying no roles at all.
|
||||
/// See Gitea #532 and <c>docs/deployments/wonder-app-vd03-makino-z-34184.md</c>.
|
||||
/// See <c>docs/deployments/wonder-app-vd03-makino-z-34184.md</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The two sources normalize differently: values bound from <c>Cluster:Roles</c> are taken
|
||||
|
||||
@@ -44,7 +44,7 @@ public sealed class AkkaClusterOptionsValidator : OptionsValidatorBase<AkkaClust
|
||||
/// DI-constructed by <c>AddValidatedOptions</c> (a plain <c>AddSingleton</c>), so constructor
|
||||
/// dependencies are safe here. Both <paramref name="effectiveRoles"/> and
|
||||
/// <paramref name="configuration"/> are optional (default <see langword="null"/>) so a caller
|
||||
/// with neither to hand — including every test in this file that pre-dates R3-04 — keeps
|
||||
/// with neither to hand — including every test in this file that pre-dates the post-overlay guard — keeps
|
||||
/// constructing this validator parameterlessly; the post-overlay guard below (choice 4) is
|
||||
/// simply skipped when either is null.
|
||||
/// </summary>
|
||||
@@ -116,7 +116,7 @@ public sealed class AkkaClusterOptionsValidator : OptionsValidatorBase<AkkaClust
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R3-04 (choice 4, the residual divergence surface): <c>Program.cs</c> resolves
|
||||
/// Post-overlay guard (choice 4, the residual divergence surface): <c>Program.cs</c> resolves
|
||||
/// <see cref="EffectiveRoles"/> BEFORE the per-role appsettings overlay loads (the overlay's
|
||||
/// filename depends on the answer), so if a role overlay — or a <c>${secret:}</c> expansion —
|
||||
/// ever set <c>Cluster:Roles</c> itself, that value would silently disagree with the
|
||||
|
||||
@@ -46,7 +46,9 @@ public sealed class ClusterBootstrapCoordinator : IHostedService
|
||||
_log = log ?? throw new ArgumentNullException(nameof(log));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <summary>Starts the bootstrap join in the background when the bootstrap guard is enabled.</summary>
|
||||
/// <param name="cancellationToken">Not used; the join runs under the coordinator's own cancellation source.</param>
|
||||
/// <returns>A completed task; the join itself continues asynchronously.</returns>
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_options.BootstrapGuard.Enabled)
|
||||
@@ -59,7 +61,9 @@ public sealed class ClusterBootstrapCoordinator : IHostedService
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <summary>Cancels the background join and waits for it to finish.</summary>
|
||||
/// <param name="cancellationToken">Not used; shutdown is driven by the coordinator's own cancellation source.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _cts.CancelAsync().ConfigureAwait(false);
|
||||
|
||||
@@ -63,7 +63,7 @@ public static class ClusterBootstrapGuard
|
||||
|
||||
/// <summary>
|
||||
/// Analyzes the configured seed list to decide this node's bootstrap role. The guard engages only
|
||||
/// for the Phase-6 pair shape (exactly two seeds, one of them this node); any other shape
|
||||
/// for the pair shape (exactly two seeds, one of them this node); any other shape
|
||||
/// (single-seed legacy site node, three+ seeds, self absent) returns <see cref="BootstrapRole.Applies"/>
|
||||
/// = false and the caller joins the configured seeds unchanged.
|
||||
/// </summary>
|
||||
|
||||
@@ -23,7 +23,7 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
|
||||
private readonly string? _clusterRole;
|
||||
private readonly string? _clusterId;
|
||||
private readonly object _lock = new();
|
||||
// R3-04 (01/C-9, census row 17) — the two dictionaries below have DIFFERENT correct comparers,
|
||||
// The two dictionaries below have DIFFERENT correct comparers,
|
||||
// and that split is deliberate, not an oversight (review finding, 2026-08-20).
|
||||
//
|
||||
// _membersByRole answers "every member carrying (a spelling of) this role" — OrdinalIgnoreCase,
|
||||
@@ -53,7 +53,7 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
|
||||
// sharing a bind IP) stay distinct. Production hosts have unique DNS names so the port
|
||||
// suffix is harmless redundancy.
|
||||
_localNode = CommonsNodeId.Parse($"{options.Value.PublicHostname}:{options.Value.Port}");
|
||||
// OrdinalIgnoreCase (Gitea #532): Cluster:Roles binds verbatim, so a node authored with 'Driver'
|
||||
// OrdinalIgnoreCase: Cluster:Roles binds verbatim, so a node authored with 'Driver'
|
||||
// used to answer HasRole("driver") false while gossiping a role Akka would still match against
|
||||
// its own ordinal comparison — the node was a driver to the cluster and not to itself.
|
||||
_localRoles = new HashSet<string>(options.Value.Roles, StringComparer.OrdinalIgnoreCase);
|
||||
@@ -128,7 +128,7 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
// Cluster-002 (review R1 m6): seed only USABLE members. A member already Leaving / Exiting / Down
|
||||
// Seed only USABLE members. A member already Leaving / Exiting / Down
|
||||
// (or still Joining) at seed time is not one, and a departing member never fires a later
|
||||
// MemberLeft / MemberExited / MemberDowned that HandleMemberEvent would drop it on — it would
|
||||
// linger until MemberRemoved, the very delay the member-event handling exists to avoid. A
|
||||
@@ -165,7 +165,7 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
|
||||
set.Add(up.Member);
|
||||
}
|
||||
break;
|
||||
// Cluster-002: a leaving / exiting / downed member is no longer a usable member, so
|
||||
// A leaving / exiting / downed member is no longer a usable member, so
|
||||
// drop it as soon as that transition lands rather than waiting for MemberRemoved
|
||||
// (several seconds later). MembersWithRole now has a production caller (the
|
||||
// historization scope-peer check), which must not defer to a departing partner.
|
||||
@@ -210,8 +210,8 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
|
||||
|
||||
// Record the event's own address. The leader used to be resolved by looking the address up
|
||||
// in Cluster.State.Members, but that snapshot is published asynchronously and can lag the
|
||||
// event: a miss stored null and nothing re-ran, so RoleLeader(role) stayed null for good
|
||||
// (Gitea #590). Only the address is ever read, so no Member lookup is needed.
|
||||
// event: a miss stored null and nothing re-ran, so RoleLeader(role) stayed null for good.
|
||||
// Only the address is ever read, so no Member lookup is needed.
|
||||
_roleLeaders[evt.Role] = evt.Leader;
|
||||
if (evt.Leader is not null)
|
||||
next = ToNodeId(evt.Leader);
|
||||
|
||||
@@ -21,14 +21,18 @@ public sealed class ConfigSourceOptionsValidator : IValidateOptions<ConfigSource
|
||||
/// DI-constructed by <c>AddValidatedOptions</c> (a plain <c>AddSingleton</c>), so a
|
||||
/// constructor dependency is safe here. This validator's only use of configuration is this
|
||||
/// node's roles, so it takes <see cref="EffectiveRoles"/> — the single role authority
|
||||
/// (archreview R3-04) — directly rather than <see cref="IConfiguration"/>.
|
||||
/// — directly rather than <see cref="IConfiguration"/>.
|
||||
/// </summary>
|
||||
/// <param name="effectiveRoles">The node's effective roles, used to decide which fetch settings are required.</param>
|
||||
public ConfigSourceOptionsValidator(EffectiveRoles effectiveRoles)
|
||||
{
|
||||
_effectiveRoles = effectiveRoles;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Validates the <see cref="ConfigSourceOptions"/> for this node's roles.</summary>
|
||||
/// <param name="name">The options instance name (unused).</param>
|
||||
/// <param name="options">The options to validate.</param>
|
||||
/// <returns>A success result, or a failure listing every violated rule.</returns>
|
||||
public ValidateOptionsResult Validate(string? name, ConfigSourceOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
@@ -92,8 +96,8 @@ public sealed class ConfigSourceOptionsValidator : IValidateOptions<ConfigSource
|
||||
|
||||
// Per-cluster mesh Phase 4: a driver-only node has no central ConfigDb connection to fall back
|
||||
// on, so Direct is only ever valid on a fused admin+driver node (admin holds the SQL
|
||||
// connection). EffectiveRoles is the single role authority (archreview R3-04): its precedence
|
||||
// already applies the OTOPCUA_ROLES fallback (Gitea #532) and its normalization already folds
|
||||
// connection). EffectiveRoles is the single role authority: its precedence
|
||||
// already applies the OTOPCUA_ROLES fallback and its normalization already folds
|
||||
// case, so neither an env-only driver-only node nor one spelled 'Driver' can sail through here
|
||||
// on Direct to read its configuration from nothing.
|
||||
var isDriver = _effectiveRoles.HasDriver;
|
||||
|
||||
@@ -69,6 +69,7 @@ public sealed class EffectiveRoles
|
||||
|
||||
/// <summary>True when <paramref name="role"/> is among <see cref="Gossiped"/>, compared case-insensitively.</summary>
|
||||
/// <param name="role">The role to test for.</param>
|
||||
/// <returns><see langword="true"/> when the role is present; otherwise <see langword="false"/>.</returns>
|
||||
public bool Has(string role) =>
|
||||
Gossiped.Any(r => string.Equals(r, role, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ public sealed class MeshTransportOptionsValidator : IValidateOptions<MeshTranspo
|
||||
/// DI-constructed by <c>AddValidatedOptions</c> (a plain <c>AddSingleton</c>), so constructor
|
||||
/// dependencies are safe here. <see cref="IConfiguration"/> is still needed for the
|
||||
/// <c>ConfigServe:GrpcListenPort</c> cross-read (the admin-side obligation below) —
|
||||
/// <see cref="EffectiveRoles"/> is the single role authority (archreview R3-04) this rule reads
|
||||
/// <see cref="EffectiveRoles"/> is the single role authority this rule reads
|
||||
/// roles through instead of re-deriving them.
|
||||
/// </summary>
|
||||
/// <param name="configuration">The application configuration, read for <c>ConfigServe:GrpcListenPort</c>.</param>
|
||||
@@ -34,7 +34,10 @@ public sealed class MeshTransportOptionsValidator : IValidateOptions<MeshTranspo
|
||||
_effectiveRoles = effectiveRoles;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Validates the <see cref="MeshTransportOptions"/> for this node's roles.</summary>
|
||||
/// <param name="name">The options instance name (unused).</param>
|
||||
/// <param name="options">The options to validate.</param>
|
||||
/// <returns>A success result, or a failure listing every violated rule.</returns>
|
||||
public ValidateOptionsResult Validate(string? name, MeshTransportOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
@@ -71,7 +74,7 @@ public sealed class MeshTransportOptionsValidator : IValidateOptions<MeshTranspo
|
||||
// dials nodes rather than being dialled, and resolves per-cluster keys at runtime through
|
||||
// MeshTransportOptions.ClusterPsks / the Secrets store, so requiring a single GrpcPsk and an
|
||||
// endpoint list of it would demand configuration it never reads. EffectiveRoles is the single
|
||||
// role authority (archreview R3-04) — its precedence already applies the OTOPCUA_ROLES
|
||||
// role authority — its precedence already applies the OTOPCUA_ROLES
|
||||
// fallback and its normalization already folds case, so a node configured only through the env
|
||||
// var, or spelled 'Driver', is still the driver that has to dial central.
|
||||
var isDriver = _effectiveRoles.HasDriver;
|
||||
|
||||
@@ -24,7 +24,7 @@ public static class RoleParser
|
||||
/// <summary>Returns true when <paramref name="role"/> is a cluster-scoped role — starts with
|
||||
/// <see cref="ClusterRolePrefix"/> and carries a non-empty ClusterId suffix.</summary>
|
||||
/// <remarks>
|
||||
/// The prefix test is <b>case-insensitive</b> (Gitea #532). It was ordinal, which meant a
|
||||
/// The prefix test is <b>case-insensitive</b>. It was ordinal, which meant a
|
||||
/// <c>Cluster:Roles</c> entry spelled <c>Cluster-SITE-A</c> — that path binds verbatim, unlike
|
||||
/// the <see cref="Parse"/> path, which lower-cases — classified as an ordinary role. The node
|
||||
/// then resolved a <see langword="null"/> ClusterId while still gossiping a cluster-scoped role,
|
||||
@@ -33,6 +33,7 @@ public static class RoleParser
|
||||
/// <c>ClusterRoleInfo</c> did not, and got the null.
|
||||
/// </remarks>
|
||||
/// <param name="role">The role name to test.</param>
|
||||
/// <returns><see langword="true"/> if the role is a cluster-scoped role.</returns>
|
||||
public static bool IsClusterRole(string role) =>
|
||||
role is not null
|
||||
&& role.StartsWith(ClusterRolePrefix, StringComparison.OrdinalIgnoreCase)
|
||||
@@ -47,9 +48,10 @@ public static class RoleParser
|
||||
/// <c>OTOPCUA_ROLES</c> path (⇒ <c>site-a</c>) — and normalizing one side would silently rename
|
||||
/// the Akka role of a running deployment (docker-dev's rig configures <c>cluster-MAIN</c> /
|
||||
/// <c>cluster-SITE-A</c> verbatim today). Every consumer of a ClusterId therefore compares it
|
||||
/// case-insensitively; see the identity rule on <c>AclScopeRef</c> (Gitea #533).
|
||||
/// case-insensitively; see the identity rule on <c>AclScopeRef</c>.
|
||||
/// </remarks>
|
||||
/// <param name="role">The cluster-scoped role name.</param>
|
||||
/// <returns>The ClusterId portion of the role, with the role prefix removed.</returns>
|
||||
public static string ClusterIdFromRole(string role) => role[ClusterRolePrefix.Length..];
|
||||
|
||||
/// <summary>
|
||||
@@ -72,7 +74,7 @@ public static class RoleParser
|
||||
/// cluster-scoped role (a legacy / single-mesh node).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The single home for a derivation that was open-coded at four call sites before Gitea #532
|
||||
/// The single home for a derivation that was open-coded at four call sites before
|
||||
/// (<c>ClusterRoleInfo</c>, the ControlPlane singleton scope, <c>SplitTopologyTransportValidator</c>
|
||||
/// and <c>ConfigDbApiKeyStore</c>) — each with its own view of whether the classification was
|
||||
/// case-sensitive, which is how the mixed-case gap above survived in three of the four.
|
||||
@@ -91,7 +93,7 @@ public static class RoleParser
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ⚠️ <b>This fallback was documented for a year before it existed</b> (Gitea #532).
|
||||
/// ⚠️ <b>This fallback was documented for a year before it existed</b>.
|
||||
/// <c>AkkaClusterOptions.Roles</c>' own XML doc and <see cref="SplitTopologyTransportValidator"/>
|
||||
/// both described it; only the validator implemented it, privately. A node configured solely
|
||||
/// through <c>OTOPCUA_ROLES</c> — which drives every bit of <c>Program.cs</c>'s conditional
|
||||
@@ -114,7 +116,7 @@ public static class RoleParser
|
||||
/// <exception cref="ArgumentException">Either source names an unknown role.</exception>
|
||||
/// <remarks>
|
||||
/// Thin delegation to <see cref="EffectiveRoles.Resolve(IReadOnlyList{string}?, string?)"/> —
|
||||
/// the single role authority (archreview R3-04). <see cref="EffectiveRoles"/> is the type to
|
||||
/// the single role authority. <see cref="EffectiveRoles"/> is the type to
|
||||
/// reach for directly; this overload is retained so existing callers of the array-returning
|
||||
/// shape keep compiling.
|
||||
/// </remarks>
|
||||
|
||||
@@ -35,7 +35,7 @@ public static class ServiceCollectionExtensions
|
||||
services.AddValidatedOptions<AkkaClusterOptions, AkkaClusterOptionsValidator>(
|
||||
configuration, AkkaClusterOptions.SectionName);
|
||||
|
||||
// R3-04 (structural identity, choice 3): EffectiveRoles is resolved ONCE and registered as a
|
||||
// Structural identity: EffectiveRoles is resolved ONCE and registered as a
|
||||
// singleton, off the SAME IConfiguration this call received — not off a DI-resolved
|
||||
// IConfiguration, which a plain ServiceCollection (as opposed to a WebApplicationBuilder) never
|
||||
// registers. Every consumer (Program.cs's hasAdmin/hasDriver, the four role-scoped validators,
|
||||
@@ -54,7 +54,7 @@ public static class ServiceCollectionExtensions
|
||||
// otherwise; TryAddSingleton is a no-op on a real host, where it is already registered.
|
||||
services.TryAddSingleton(configuration);
|
||||
|
||||
// Gitea #532 (superseded by R3-04): apply the OTOPCUA_ROLES fallback the Roles property has
|
||||
// Apply the OTOPCUA_ROLES fallback the Roles property has
|
||||
// always DOCUMENTED, in the one place every consumer reads through. Before this, a node
|
||||
// configured only through the env var — the knob that drives all of Program.cs's role wiring —
|
||||
// bound an EMPTY Roles array and so joined the cluster role-less: invisible to
|
||||
|
||||
@@ -14,7 +14,7 @@ namespace ZB.MOM.WW.OtOpcUa.Cluster;
|
||||
/// Phase 2/3/5 dark switches keep their DPS branches alive, but DPS is mesh-wide — it delivers
|
||||
/// nothing between a central node and a site node that no longer share a gossip ring. The
|
||||
/// marker of the split topology is a <c>cluster-{ClusterId}</c> role
|
||||
/// (<see cref="RoleParser.IsClusterRole"/>); those roles exist only post-Phase-6. A node that
|
||||
/// (<see cref="RoleParser.IsClusterRole"/>); those roles exist only in the per-cluster mesh topology. A node that
|
||||
/// carries one <b>must</b> use the mesh-crossing transports, and this validator refuses to
|
||||
/// boot it otherwise — the alternative is a node that runs, listens, and silently delivers no
|
||||
/// commands or telemetry, which has no stack trace and no failing node to point at.
|
||||
@@ -44,16 +44,21 @@ public sealed class SplitTopologyTransportValidator : IValidateOptions<MeshTrans
|
||||
/// transport-mode cross-reads (<c>MeshTransport:Mode</c>, <c>Telemetry:Mode</c>,
|
||||
/// <c>TelemetryDial:Mode</c>) — not <c>IClusterRoleInfo</c>, whose implementation needs the
|
||||
/// <c>ActorSystem</c>, which does not exist yet at <c>ValidateOnStart</c> time.
|
||||
/// <see cref="EffectiveRoles"/> is the single role authority (archreview R3-04) — this rule no
|
||||
/// <see cref="EffectiveRoles"/> is the single role authority — this rule no
|
||||
/// longer re-derives the effective, normalized role list itself.
|
||||
/// </summary>
|
||||
/// <param name="configuration">The host configuration used to cross-read the transport-mode settings.</param>
|
||||
/// <param name="effectiveRoles">The node's effective, normalized role list.</param>
|
||||
public SplitTopologyTransportValidator(IConfiguration configuration, EffectiveRoles effectiveRoles)
|
||||
{
|
||||
_configuration = configuration;
|
||||
_effectiveRoles = effectiveRoles;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Fails startup when a split-topology node is left on a transport mode that cannot work there.</summary>
|
||||
/// <param name="name">The options instance name; unused.</param>
|
||||
/// <param name="options">The mesh transport options; unused because every input is cross-read from configuration.</param>
|
||||
/// <returns>A success result, or a failure naming the offending transport setting.</returns>
|
||||
public ValidateOptionsResult Validate(string? name, MeshTransportOptions options)
|
||||
{
|
||||
// The options instance is intentionally unused: every value this rule depends on is cross-read
|
||||
|
||||
@@ -93,14 +93,18 @@ public sealed class TelemetryOptionsValidator : IValidateOptions<TelemetryOption
|
||||
/// DI-constructed by <c>AddValidatedOptions</c> (a plain <c>AddSingleton</c>), so a
|
||||
/// constructor dependency is safe here. This validator's only use of configuration is this
|
||||
/// node's roles, so it takes <see cref="EffectiveRoles"/> — the single role authority
|
||||
/// (archreview R3-04) — directly rather than <see cref="IConfiguration"/>.
|
||||
/// — directly rather than <see cref="IConfiguration"/>.
|
||||
/// </summary>
|
||||
/// <param name="effectiveRoles">The node's resolved roles, the single role authority.</param>
|
||||
public TelemetryOptionsValidator(EffectiveRoles effectiveRoles)
|
||||
{
|
||||
_effectiveRoles = effectiveRoles;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Validates the telemetry options against this node's roles.</summary>
|
||||
/// <param name="name">The named options instance, or <see langword="null"/> for the default.</param>
|
||||
/// <param name="options">The options to validate.</param>
|
||||
/// <returns>Success, or a failure listing every invalid setting.</returns>
|
||||
public ValidateOptionsResult Validate(string? name, TelemetryOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
@@ -117,8 +121,8 @@ public sealed class TelemetryOptionsValidator : IValidateOptions<TelemetryOption
|
||||
+ $"Expected '{TelemetryOptions.ModeDps}' or '{TelemetryOptions.ModeGrpc}'.");
|
||||
}
|
||||
|
||||
// EffectiveRoles is the single role authority (archreview R3-04) — its precedence already
|
||||
// applies the OTOPCUA_ROLES fallback (Gitea #532) and its normalization already folds case, so
|
||||
// EffectiveRoles is the single role authority — its precedence already
|
||||
// applies the OTOPCUA_ROLES fallback and its normalization already folds case, so
|
||||
// a node configured only through the env var, or spelled 'Driver', still satisfies the
|
||||
// Grpc-mode requirements below.
|
||||
var isDriver = _effectiveRoles.HasDriver;
|
||||
@@ -157,7 +161,10 @@ public sealed class TelemetryOptionsValidator : IValidateOptions<TelemetryOption
|
||||
/// </summary>
|
||||
public sealed class TelemetryDialOptionsValidator : IValidateOptions<TelemetryDialOptions>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
/// <summary>Validates the telemetry dial options.</summary>
|
||||
/// <param name="name">The named options instance, or <see langword="null"/> for the default.</param>
|
||||
/// <param name="options">The options to validate.</param>
|
||||
/// <returns>Success, or a failure listing every invalid setting.</returns>
|
||||
public ValidateOptionsResult Validate(string? name, TelemetryDialOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
@@ -56,7 +56,8 @@ public sealed class CapturedTreeBrowseSession(CapturedTree tree) : IBrowseSessio
|
||||
return Task.FromResult<IReadOnlyList<AttributeInfo>>(attrs);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Drops this session's reference to the captured tree; the tree itself may be shared.</summary>
|
||||
/// <returns>A completed <see cref="ValueTask"/>.</returns>
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
_tree = null; // drop this session's reference only — tree may be shared (coalescing)
|
||||
@@ -68,7 +69,7 @@ public sealed class CapturedTreeBrowseSession(CapturedTree tree) : IBrowseSessio
|
||||
/// <summary>
|
||||
/// Surfaces a leaf's driver-native type name as the <c>dataType</c> address field, so a
|
||||
/// browse-commit can stamp the driver-typed <c>TagConfig</c> with the type the controller
|
||||
/// actually reported (Gitea #536).
|
||||
/// actually reported.
|
||||
/// </summary>
|
||||
/// <param name="a">The captured attribute.</param>
|
||||
/// <returns>The address fields, or null when the driver reported no native type.</returns>
|
||||
@@ -80,7 +81,7 @@ public sealed class CapturedTreeBrowseSession(CapturedTree tree) : IBrowseSessio
|
||||
/// reverse map would bind a 16-bit INT as a 32-bit DINT and read four bytes for two.
|
||||
/// <para>
|
||||
/// Null when the driver stamped nothing — the mapper then leaves the model's own default in
|
||||
/// place, which is exactly the pre-#536 behaviour rather than a guess.
|
||||
/// place, which is exactly the previous behaviour rather than a guess.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static IReadOnlyDictionary<string, string>? NativeTypeFields(DriverAttributeInfo a)
|
||||
|
||||
@@ -17,6 +17,8 @@ public sealed class CapturingAddressSpaceBuilder : IAddressSpaceBuilder
|
||||
public bool Truncated;
|
||||
public readonly int NodeCap = nodeCap;
|
||||
public readonly Dictionary<string, CapturedNode> ById = new(StringComparer.Ordinal);
|
||||
/// <summary>Reserves a slot for one more node, marking the capture truncated when the cap is reached.</summary>
|
||||
/// <returns><see langword="true"/> when the node may be recorded; <see langword="false"/> when the cap is reached.</returns>
|
||||
public bool TryReserve()
|
||||
{
|
||||
if (Count >= NodeCap) { Truncated = true; return false; }
|
||||
@@ -96,7 +98,10 @@ public sealed class CapturingAddressSpaceBuilder : IAddressSpaceBuilder
|
||||
|
||||
private sealed class CapturedVariableHandle(CapturedNode node) : IVariableHandle
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string FullReference => node.Id;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info)
|
||||
{
|
||||
node.IsAlarmMarked = true;
|
||||
@@ -107,6 +112,7 @@ public sealed class CapturingAddressSpaceBuilder : IAddressSpaceBuilder
|
||||
private sealed class NoopAlarmSink : IAlarmConditionSink
|
||||
{
|
||||
public static readonly NoopAlarmSink Instance = new();
|
||||
/// <inheritdoc />
|
||||
public void OnTransition(AlarmEventArgs args) { } // browse never delivers live transitions
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ public sealed class DiscoveryDriverBrowser : IUniversalDriverBrowser
|
||||
/// 20 s per-call browse timeout, which only covers serving from memory.</summary>
|
||||
public static readonly TimeSpan OpenTimeout = TimeSpan.FromSeconds(60);
|
||||
|
||||
/// <summary>Bound on the cleanup ShutdownAsync (R2-01: no unbounded waits — a driver wedged
|
||||
/// <summary>Bound on the cleanup ShutdownAsync (no unbounded waits — a driver wedged
|
||||
/// in discovery may be equally wedged in shutdown).</summary>
|
||||
public static readonly TimeSpan ShutdownTimeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
@@ -46,7 +46,11 @@ public sealed class DiscoveryDriverBrowser : IUniversalDriverBrowser
|
||||
public DiscoveryDriverBrowser(IDriverFactory driverFactory, ILogger<DiscoveryDriverBrowser> logger)
|
||||
: this(driverFactory, logger, OpenTimeout, ShutdownTimeout) { }
|
||||
|
||||
// Test seam: short timeouts.
|
||||
/// <summary>Test seam: constructor with explicit open and shutdown bounds.</summary>
|
||||
/// <param name="driverFactory">Factory used to construct throwaway/capture driver instances.</param>
|
||||
/// <param name="logger">Logger for shutdown-timeout warnings.</param>
|
||||
/// <param name="openTimeout">Upper bound on opening a capture driver and running discovery.</param>
|
||||
/// <param name="shutdownTimeout">Upper bound on the cleanup shutdown of a capture driver.</param>
|
||||
internal DiscoveryDriverBrowser(IDriverFactory driverFactory, ILogger<DiscoveryDriverBrowser> logger,
|
||||
TimeSpan openTimeout, TimeSpan shutdownTimeout)
|
||||
{
|
||||
@@ -111,6 +115,10 @@ public sealed class DiscoveryDriverBrowser : IUniversalDriverBrowser
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Builds the in-flight de-duplication key for a capture request.</summary>
|
||||
/// <param name="driverType">The driver type name.</param>
|
||||
/// <param name="configJson">The driver configuration JSON.</param>
|
||||
/// <returns>A key combining the lower-cased driver type and a hash of the configuration.</returns>
|
||||
internal static string CaptureKey(string driverType, string configJson) =>
|
||||
driverType.ToLowerInvariant() + ":" +
|
||||
Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(
|
||||
@@ -195,6 +203,10 @@ public sealed class DiscoveryDriverBrowser : IUniversalDriverBrowser
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Applies the driver-specific browse-time patch (if any) to the configuration JSON.</summary>
|
||||
/// <param name="driverType">The driver type name.</param>
|
||||
/// <param name="configJson">The driver configuration JSON.</param>
|
||||
/// <returns>The patched configuration JSON, or the input unchanged when the driver type has no patch.</returns>
|
||||
internal static string PatchForBrowse(string driverType, string configJson)
|
||||
{
|
||||
if (!BrowsePatches.TryGetValue(driverType, out var patch)) return configJson;
|
||||
|
||||
@@ -13,7 +13,7 @@ public enum ApplyAckOutcome { Applied, Failed }
|
||||
/// <param name="FailureReason">Why the apply failed; null on <see cref="ApplyAckOutcome.Applied"/>.</param>
|
||||
/// <param name="CorrelationId">The dispatch's correlation id.</param>
|
||||
/// <param name="Degraded">
|
||||
/// Gitea #601: <see langword="true"/> when the node applied the deployment (the revision advanced) but
|
||||
/// <see langword="true"/> when the node applied the deployment (the revision advanced) but
|
||||
/// its OPC UA address-space apply was DEGRADED — swallowed per-node sink faults, a failed rebuild, or no
|
||||
/// outcome reported in time — so the served address space may be partial. Only meaningful with
|
||||
/// <see cref="ApplyAckOutcome.Applied"/>. Trailing and defaulted so an ack from a node built before
|
||||
@@ -30,7 +30,7 @@ public sealed record ApplyAck(
|
||||
string? DegradedReason = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gitea #601 — prefix of <c>NodeDeploymentState.FailureReason</c> on an <b>Applied</b> row whose
|
||||
/// The prefix of <c>NodeDeploymentState.FailureReason</c> on an <b>Applied</b> row whose
|
||||
/// address-space apply was degraded. Shared by central (which writes it from the ack) and a node with
|
||||
/// a ConfigDb (which writes its own row), so both spell the row identically.
|
||||
/// </summary>
|
||||
|
||||
@@ -11,11 +11,11 @@ public static class DeployTimings
|
||||
/// (<c>ConfigPublishCoordinator.DefaultApplyDeadline</c>).</summary>
|
||||
public static readonly TimeSpan ApplyDeadline = TimeSpan.FromMinutes(2);
|
||||
|
||||
/// <summary>Gitea #601 — how far inside <see cref="ApplyDeadline"/> a node's held Applied ack is released
|
||||
/// <summary>How far inside <see cref="ApplyDeadline"/> a node's held Applied ack is released
|
||||
/// when the address-space outcome has not arrived, leaving room for transport latency.</summary>
|
||||
public static readonly TimeSpan ApplyOutcomeSafetyMargin = TimeSpan.FromSeconds(15);
|
||||
|
||||
/// <summary>Gitea #601 — the node's default apply-outcome timeout: <see cref="ApplyDeadline"/> −
|
||||
/// <summary>The node's default apply-outcome timeout: <see cref="ApplyDeadline"/> −
|
||||
/// <see cref="ApplyOutcomeSafetyMargin"/> (105 s). Long enough that a slow-but-clean rebuild of a large
|
||||
/// address space is not labelled degraded, short enough to ack before central gives up.</summary>
|
||||
/// <remarks>The timer is armed after the artifact read and driver reconcile, so if those alone took
|
||||
|
||||
@@ -38,7 +38,7 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers;
|
||||
/// so a driver-only node has no ConfigDb connection to write rows to. This channel already reaches
|
||||
/// <c>/hosts</c>, already survives the mesh split via the Phase 5 gRPC telemetry stream, and already
|
||||
/// replays a last-value snapshot on every (re)subscribe — so per-host state re-primes after a
|
||||
/// reconnect without a durable store. The table was dropped; see Gitea #521.</para>
|
||||
/// reconnect without a durable store. The table was dropped.</para>
|
||||
/// <para><b>Value-equality caveat.</b> A record's generated <c>Equals</c> compares this list by
|
||||
/// REFERENCE, so two <c>DriverHealthChanged</c> carrying equal-but-distinct lists are not equal.
|
||||
/// Nothing dedups on record equality — <c>DriverInstanceActor</c> keeps its own flattened fingerprint
|
||||
@@ -48,13 +48,13 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers;
|
||||
/// What this driver skipped when it last built its tag table, from
|
||||
/// <c>ISkippedTagReporter.GetSkippedTags()</c>; null when the driver does not implement that
|
||||
/// capability. All twelve production drivers do.
|
||||
/// <para><b>Why this is on the health snapshot (Gitea #541).</b> A skip happens at init, is not an
|
||||
/// <para><b>Why this is on the health snapshot.</b> A skip happens at init, is not an
|
||||
/// error, and does not stop the driver's OTHER tags from reading — so <c>State</c> stays Healthy,
|
||||
/// <c>ErrorCount5Min</c> stays 0, <c>LastError</c> stays null and <c>LastSuccessfulReadUtc</c> keeps
|
||||
/// advancing while the driver ignores part or all of its configuration. On the Makino in Gitea #540
|
||||
/// advancing while the driver ignores part or all of its configuration. On the Makino
|
||||
/// that combination held while 20 of 22 tags were ignored. This field is what contradicts it.</para>
|
||||
/// <para><b>Additive, never a state change.</b> See <c>ISkippedTagReporter</c> for why a skipped tag
|
||||
/// must not latch the driver non-Healthy — it would satisfy both halves of the #527 comms-loss
|
||||
/// must not latch the driver non-Healthy — it would satisfy both halves of the comms-loss
|
||||
/// conjunction and reconnect-loop a healthy device.</para>
|
||||
/// <para><b>Value-equality caveat</b> — the same one <paramref name="HostStatuses"/> carries: the
|
||||
/// record's generated <c>Equals</c> compares the sample list by REFERENCE.</para>
|
||||
@@ -62,7 +62,7 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers;
|
||||
/// <param name="Diagnostics">
|
||||
/// Operator-facing driver diagnostics from <c>IDriverDiagnostics.GetDiagnostics()</c> (e.g. S7's
|
||||
/// negotiated PDU size), bounded and in ordinal key order; null when the driver does not implement the
|
||||
/// capability (Gitea #640). Additive, never a state change. Same by-reference value-equality caveat as
|
||||
/// capability. Additive, never a state change. Same by-reference value-equality caveat as
|
||||
/// <paramref name="HostStatuses"/>.
|
||||
/// </param>
|
||||
public sealed record DriverHealthChanged(
|
||||
|
||||
@@ -216,7 +216,7 @@ public static class MeshTransportDtoMapper
|
||||
if (message.FailureReason is not null)
|
||||
proto.FailureReason = message.FailureReason;
|
||||
|
||||
// #601 — additive fields; a clean ack leaves both unset, byte-identical to a pre-#601 encode.
|
||||
// Additive fields; a clean ack leaves both unset, byte-identical to an encode that predates these fields.
|
||||
proto.Degraded = message.Degraded;
|
||||
if (message.DegradedReason is not null)
|
||||
proto.DegradedReason = message.DegradedReason;
|
||||
@@ -255,7 +255,7 @@ public static class MeshTransportDtoMapper
|
||||
outcome,
|
||||
proto.HasFailureReason ? proto.FailureReason : null,
|
||||
CorrelationId.Parse(proto.CorrelationId),
|
||||
// #601 — absent on an old node's ack, which decodes as false/null: today's clean ack.
|
||||
// Absent on an old node's ack, which decodes as false/null: today's clean ack.
|
||||
Degraded: proto.Degraded,
|
||||
DegradedReason: proto.HasDegradedReason ? proto.DegradedReason : null);
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ public static class OtOpcUaTelemetry
|
||||
public const string PollOverrunDriverInstanceTag = "driver_instance";
|
||||
|
||||
/// <summary>
|
||||
/// Session/publish-level events of an OPC UA Client driver instance (#276): the operator's view of a
|
||||
/// Session/publish-level events of an OPC UA Client driver instance: the operator's view of a
|
||||
/// flapping upstream. Tagged <see cref="PollOverrunDriverInstanceTag"/> (<c>driver_instance</c>) +
|
||||
/// <c>event</c> = <c>publish_response|notification|data_change|bad_status_value|publish_error|
|
||||
/// keepalive_miss|keepalive_recovered|stale_notification_dropped</c>.
|
||||
|
||||
@@ -19,15 +19,15 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
/// <param name="StatusCode">
|
||||
/// Status code of the condition's source data (Part 9 <c>ConditionType.Quality</c>). Carried so a
|
||||
/// comms-lost native source can report a non-<c>Good</c> condition instead of the accidentally-Good
|
||||
/// default (issue #477). It is a pure annotation — it never alters Active/Acked/Retain. Defaults to
|
||||
/// default. It is a pure annotation — it never alters Active/Acked/Retain. Defaults to
|
||||
/// <see cref="OpcUaStatus.Good"/> so scripted-alarm callers (which stay Good in v1) need not supply it.
|
||||
/// Carries the full sub-code since #519, so a comms-lost condition reports
|
||||
/// Carries the full sub-code, so a comms-lost condition reports
|
||||
/// <see cref="OpcUaStatus.BadNoCommunication"/> rather than a bare <see cref="OpcUaStatus.Bad"/>.
|
||||
/// </param>
|
||||
/// <param name="UnshelveAtUtc">
|
||||
/// For <see cref="AlarmShelvingKind.Timed"/>, the UTC instant the engine expires the shelve; otherwise
|
||||
/// <c>null</c>. Carried so the node's Part 9 <c>ShelvingState.UnshelveTime</c> reports the real remaining
|
||||
/// time instead of the SDK's ~24.8-day ceiling (Gitea #597). A timed snapshot without it (a caller that
|
||||
/// time instead of the SDK's ~24.8-day ceiling. A timed snapshot without it (a caller that
|
||||
/// predates the field) falls back to that ceiling — the engine still owns the expiry either way.
|
||||
/// </param>
|
||||
public sealed record AlarmConditionSnapshot(
|
||||
|
||||
@@ -44,6 +44,7 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
|
||||
=> _inner.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc, realm);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void WriteAlarmQuality(string alarmNodeId, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
|
||||
=> _inner.WriteAlarmQuality(alarmNodeId, statusCode, sourceTimestampUtc, realm);
|
||||
|
||||
@@ -52,7 +53,7 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical
|
||||
=> _inner.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, realm, isNative);
|
||||
|
||||
/// <inheritdoc />
|
||||
// Forward the WP4 multi-notifier wiring to the inner sink. Without this the native-alarm fan-out to the
|
||||
// Forward the multi-notifier wiring to the inner sink. Without this the native-alarm fan-out to the
|
||||
// referencing equipment folders ships INERT on every driver-role host (actors inject THIS wrapper, not the
|
||||
// inner SdkAddressSpaceSink) — the F10b / PR#423 forwarding trap the reflection guard exists to catch.
|
||||
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm)
|
||||
@@ -100,7 +101,7 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical
|
||||
=> _inner is ISurgicalAddressSpaceSink surgical
|
||||
&& surgical.UpdateFolderDisplayName(folderNodeId, displayName, realm);
|
||||
|
||||
// R2-07 T7 — surgical remove forwards. Same capability-sniffing pattern as UpdateTagAttributes /
|
||||
// Surgical remove forwards. Same capability-sniffing pattern as UpdateTagAttributes /
|
||||
// UpdateFolderDisplayName: forward when the inner sink supports the surgical capability; return false
|
||||
// otherwise (before the real SdkAddressSpaceSink is swapped in, or any non-surgical inner) so the caller
|
||||
// (AddressSpaceApplier) falls back to a full rebuild. Without these forwards the surgical remove path is
|
||||
|
||||
@@ -11,14 +11,14 @@ public interface IOpcUaAddressSpaceSink
|
||||
/// <summary>Write a Variable node's current value + status code + source timestamp.</summary>
|
||||
/// <param name="nodeId">The OPC UA node ID of the variable.</param>
|
||||
/// <param name="value">The value to write.</param>
|
||||
/// <param name="statusCode">The full OPC UA status code, sub-code included. Passed to the SDK verbatim
|
||||
/// (#519): a driver's <see cref="OpcUaStatus.BadNoCommunication"/> reaches the client as
|
||||
/// <param name="statusCode">The full OPC UA status code, sub-code included. Passed to the SDK verbatim, so
|
||||
/// a driver's <see cref="OpcUaStatus.BadNoCommunication"/> reaches the client as
|
||||
/// <c>BadNoCommunication</c>, not as a generic <see cref="OpcUaStatus.Bad"/>. Use
|
||||
/// <see cref="OpcUaStatus"/>' named constants rather than a literal.</param>
|
||||
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
||||
/// <param name="realm">Which of the two v3 namespaces (<see cref="AddressSpaceRealm.Raw"/> device tree or
|
||||
/// <see cref="AddressSpaceRealm.Uns"/> equipment tree) the <paramref name="nodeId"/> lives in — the sink
|
||||
/// resolves the namespace index from the realm rather than parsing it out of the id string. WP3's fan-out
|
||||
/// resolves the namespace index from the realm rather than parsing it out of the id string. The fan-out
|
||||
/// calls this once per NodeId (raw + every referencing UNS node) with the matching realm.</param>
|
||||
void WriteValue(string nodeId, object? value, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm);
|
||||
|
||||
@@ -81,11 +81,11 @@ public interface IOpcUaAddressSpaceSink
|
||||
/// the condition was materialised under).</param>
|
||||
void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm);
|
||||
|
||||
/// <summary>#477 — annotate a materialised condition's source-data quality OUT OF BAND from any alarm
|
||||
/// <summary>Annotate a materialised condition's source-data quality OUT OF BAND from any alarm
|
||||
/// transition (used by the driver-connectivity path: comms lost → <see cref="OpcUaStatus.BadNoCommunication"/>,
|
||||
/// restored → <see cref="OpcUaStatus.Good"/>). Sets ONLY the condition's Quality — never
|
||||
/// Active/Acked/Severity/Retain (a comms-lost active alarm stays active) — and fires one Part 9 event
|
||||
/// only on a change to the status code itself — since #519 that includes a move between two Bad
|
||||
/// only on a change to the status code itself — that now includes a move between two Bad
|
||||
/// sub-codes, which genuinely changes what a subscriber reads. A no-op for an unmaterialised /
|
||||
/// non-condition node.</summary>
|
||||
/// <param name="alarmNodeId">The condition node id (RawPath for a native alarm).</param>
|
||||
@@ -172,9 +172,9 @@ public interface IOpcUaAddressSpaceSink
|
||||
/// <param name="realm">The namespace realm the variable (and its <paramref name="parentFolderNodeId"/>) live
|
||||
/// in. A historized UNS reference node registers the SAME <paramref name="historianTagname"/> as its backing
|
||||
/// raw node — both NodeIds map to one tagname — so this call carries the shared tagname per realm.</param>
|
||||
/// <param name="refuseArrayOnScalar">Gitea #610 review (R6): when true and the node is SCALAR, a client write
|
||||
/// <param name="refuseArrayOnScalar">When true and the node is SCALAR, a client write
|
||||
/// of an array value is refused inline (<c>BadTypeMismatch</c>, nothing dispatched) because the owning
|
||||
/// driver's scalar encoder cannot take one (Modbus / S7). False (default) keeps the pre-#610 behaviour — the
|
||||
/// driver's scalar encoder cannot take one (Modbus / S7). False (default) keeps the earlier behaviour — the
|
||||
/// write is dispatched and the driver decides (OpcUaClient forwards it to the upstream server, Galaxy encodes
|
||||
/// it). Fixed at creation; an existing node keeps the value it was created with.</param>
|
||||
void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false);
|
||||
@@ -186,7 +186,7 @@ public interface IOpcUaAddressSpaceSink
|
||||
void RebuildAddressSpace();
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #641 — mark the END of one address-space apply pass (after <see cref="RebuildAddressSpace"/>, if
|
||||
/// Mark the END of one address-space apply pass (after <see cref="RebuildAddressSpace"/>, if
|
||||
/// one ran, and every materialise / notifier-wiring call that followed it). An SDK-backed sink carries each
|
||||
/// native condition's live Part 9 state across a rebuild and restores it when the same condition is
|
||||
/// re-materialised; this is where it reports the ONE condition event per restored condition (now that
|
||||
@@ -229,7 +229,7 @@ public interface IOpcUaAddressSpaceSink
|
||||
}
|
||||
|
||||
/// <summary>One value write within a <see cref="IOpcUaAddressSpaceSink.WriteValues"/> batch.
|
||||
/// Same semantics per field as <see cref="IOpcUaAddressSpaceSink.WriteValue"/> (#519: status verbatim).</summary>
|
||||
/// Same semantics per field as <see cref="IOpcUaAddressSpaceSink.WriteValue"/> (status verbatim).</summary>
|
||||
public readonly record struct ValueWrite(
|
||||
string NodeId, object? Value, uint StatusCode, DateTime SourceTimestampUtc, AddressSpaceRealm Realm);
|
||||
|
||||
@@ -237,7 +237,7 @@ public readonly record struct ValueWrite(
|
||||
/// node's current VALUE is retained and only <paramref name="StatusCode"/> +
|
||||
/// <paramref name="SourceTimestampUtc"/> change.</summary>
|
||||
/// <param name="NodeId">The OPC UA node id of the variable, within <paramref name="Realm"/>.</param>
|
||||
/// <param name="StatusCode">The full status code, sub-code included, passed to the SDK verbatim (#519).</param>
|
||||
/// <param name="StatusCode">The full status code, sub-code included, passed to the SDK verbatim.</param>
|
||||
/// <param name="SourceTimestampUtc">The source timestamp in UTC.</param>
|
||||
/// <param name="Realm">Which of the two v3 namespaces <paramref name="NodeId"/> lives in.</param>
|
||||
public readonly record struct StatusWrite(
|
||||
@@ -279,6 +279,7 @@ public sealed class NullOpcUaAddressSpaceSink : IOpcUaAddressSpaceSink
|
||||
|
||||
/// <inheritdoc />
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||
/// <inheritdoc />
|
||||
public void WriteAlarmQuality(string alarmNodeId, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -2,7 +2,7 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
|
||||
/// <summary>
|
||||
/// The handful of OPC UA status codes the engine layer needs to spell, plus the Part 4 severity
|
||||
/// classification helpers that replaced the retired <c>OpcUaQuality</c> 3-state enum (Gitea #519).
|
||||
/// classification helpers that replaced the retired <c>OpcUaQuality</c> 3-state enum.
|
||||
///
|
||||
/// <para><b>Why these are bare <c>uint</c> literals.</b> Commons and Runtime deliberately do not
|
||||
/// reference the OPC UA SDK — <see cref="IOpcUaAddressSpaceSink"/> exists precisely so the Runtime
|
||||
@@ -10,11 +10,11 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
/// decision the driver assemblies make, and it carries the same single failure mode: nothing checks
|
||||
/// the literal against the name. <c>StatusCodeParityTests</c> closes it by reflection, and it can
|
||||
/// only see a <b>named</b> constant — so add codes here rather than writing a literal at a call
|
||||
/// site, exactly as Gitea #497 concluded for the drivers.</para>
|
||||
/// site, exactly as for the drivers.</para>
|
||||
///
|
||||
/// <para><b>Severity, not equality, is what callers should branch on.</b> A status code's top two
|
||||
/// bits carry its Part 4 severity (00 = Good, 01 = Uncertain, 10/11 = Bad); everything below is the
|
||||
/// sub-code. Before #519 the engine collapsed every value onto a 3-state enum here and the node
|
||||
/// sub-code. Previously the engine collapsed every value onto a 3-state enum here and the node
|
||||
/// manager re-expanded <c>Bad</c> to a bare <c>0x80000000</c>, so no driver's sub-code ever reached
|
||||
/// a client. The sub-code now rides through to the SDK verbatim, and any code that only needs the
|
||||
/// bucket uses <see cref="IsGood"/> / <see cref="IsUncertain"/> / <see cref="IsBad"/> rather than
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// One OPC UA data-plane API key. The OPC UA data plane is machine-to-machine (#520), so a
|
||||
/// One OPC UA data-plane API key. The OPC UA data plane is machine-to-machine, so a
|
||||
/// consuming system authenticates with a key rather than an LDAP user, and the key resolves to a
|
||||
/// <see cref="Principal"/> that <see cref="NodeAcl"/> rows grant permissions to.
|
||||
/// </summary>
|
||||
@@ -22,7 +22,7 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
/// ⚠️ <b>Revocation requires a deploy.</b> Phase 4 leaves driver-only nodes with no ConfigDb, so
|
||||
/// keys reach them inside the deployment artifact exactly as <see cref="NodeAcl"/> rows do.
|
||||
/// Setting <see cref="Enabled"/> to false takes effect on a node only once a new artifact reaches
|
||||
/// it. This is an operational sharp edge, not an oversight — see the #520 design doc.
|
||||
/// it. This is an operational sharp edge, not an oversight.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ApiKey
|
||||
|
||||
@@ -41,7 +41,7 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
|
||||
/// <summary>Gets the DbSet of node ACLs.</summary>
|
||||
public DbSet<NodeAcl> NodeAcls => Set<NodeAcl>();
|
||||
|
||||
/// <summary>OPC UA data-plane API keys (#520). One principal per key; secret stored as a peppered hash.</summary>
|
||||
/// <summary>OPC UA data-plane API keys. One principal per key; secret stored as a peppered hash.</summary>
|
||||
public DbSet<ApiKey> ApiKeys => Set<ApiKey>();
|
||||
/// <summary>Gets the DbSet of configuration audit logs.</summary>
|
||||
public DbSet<ConfigAuditLog> ConfigAuditLogs => Set<ConfigAuditLog>();
|
||||
@@ -563,14 +563,14 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
|
||||
});
|
||||
}
|
||||
|
||||
// ConfigureDriverHostStatus is GONE (Gitea #521). The DriverHostStatus table held per-host
|
||||
// ConfigureDriverHostStatus is GONE. The DriverHostStatus table held per-host
|
||||
// connectivity that a publisher hosted service was supposed to upsert from each driver node. That
|
||||
// publisher was never written, and per-cluster mesh Phase 4 made it unwritable as designed: ConfigDb
|
||||
// is registered only on the admin role, so a driver-only node has no connection to write rows with.
|
||||
// Per-host connectivity now rides DriverHealthChanged.HostStatuses to /hosts over the Phase 5
|
||||
// telemetry stream, which needs no DB and survives the mesh split.
|
||||
//
|
||||
// ConfigureDriverInstanceResilienceStatus is GONE for the same reason (Gitea #524). That table held
|
||||
// ConfigureDriverInstanceResilienceStatus is GONE for the same reason. That table held
|
||||
// per-(instance, host) breaker/in-flight counters and was equally never written: its doc described a
|
||||
// HostedService sampler and an AdminUI left-join on DriverHostStatus, neither of which was built. The
|
||||
// live data reaches the AdminUI's DriverStatusPanel as DriverResilienceStatusChanged over the
|
||||
|
||||
@@ -57,7 +57,7 @@ public sealed class DraftSnapshot
|
||||
public IReadOnlyList<ScriptedAlarm> ScriptedAlarms { get; init; } = [];
|
||||
|
||||
/// <summary>User-authored scripts (shared by VirtualTags, ScriptedAlarms, and Calculation tags). Drives
|
||||
/// the WP7 Calculation gates: <c>scriptId</c> existence + the calc→calc dependency-cycle check.</summary>
|
||||
/// the Calculation gates: <c>scriptId</c> existence + the calc→calc dependency-cycle check.</summary>
|
||||
public IReadOnlyList<Script> Scripts { get; init; } = [];
|
||||
/// <summary>Gets the list of poll groups.</summary>
|
||||
public IReadOnlyList<PollGroup> PollGroups { get; init; } = [];
|
||||
|
||||
@@ -33,7 +33,7 @@ public static class DraftValidator
|
||||
ValidateReservationPreflight(draft, errors);
|
||||
ValidateEquipmentIdDerivation(draft, errors);
|
||||
|
||||
// v3 rules (WP4). The retired rules ValidateSameClusterNamespaceBinding (Namespace entity gone)
|
||||
// v3 rules. The retired rules ValidateSameClusterNamespaceBinding (Namespace entity gone)
|
||||
// and ValidateGalaxyTagFullName (blob-as-identity gone) are replaced by:
|
||||
ValidateRawNameCharset(draft, errors);
|
||||
ValidateHistorizedTagnameLength(draft, errors);
|
||||
@@ -47,7 +47,7 @@ public static class DraftValidator
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #520 stage 3: every <see cref="NodeAcl"/> row's scope must name something the deployed address
|
||||
/// every <see cref="NodeAcl"/> row's scope must name something the deployed address
|
||||
/// space actually contains.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
@@ -74,7 +74,7 @@ public static class DraftValidator
|
||||
/// <c>AclScopePathResolver</c>'s dictionary lookups use, so a row this rule passes is a row that
|
||||
/// resolver resolves. The realm check mirrors <c>NodeAclRealms.TryCanonicalize</c> instead
|
||||
/// (trimmed, case-insensitive) for the same reason. Both this rule and the resolver were
|
||||
/// ordinal until Gitea #533; the cross-cluster comparison below was ordinal too, which was the
|
||||
/// ordinal until that was fixed; the cross-cluster comparison below was ordinal too, which was the
|
||||
/// worse half — it could reject a deploy as cross-cluster over a <c>ClusterId</c> the runtime
|
||||
/// would have matched, and the two spellings genuinely occur (see
|
||||
/// <c>RoleParser.ClusterIdFromRole</c>). The rule now is: identifiers compare
|
||||
@@ -279,7 +279,7 @@ public static class DraftValidator
|
||||
|
||||
/// <summary>
|
||||
/// The <c>Sql</c> driver's "a pasted literal connection string is never persisted" guarantee, enforced
|
||||
/// at the deploy gate (Gitea #498).
|
||||
/// at the deploy gate.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>A Sql driver names its credentials indirectly — <c>connectionStringRef</c> resolves from the
|
||||
@@ -294,7 +294,7 @@ public static class DraftValidator
|
||||
/// Discarding a secret on read is not the same as refusing to store it.</para>
|
||||
/// <para><b>Two of the three surfaces are checked here.</b> The third — a node's
|
||||
/// <see cref="ClusterNode.DriverConfigOverridesJson"/> — is not reachable from
|
||||
/// <see cref="DraftSnapshot"/> and is gated at its save instead (#499); see
|
||||
/// <see cref="DraftSnapshot"/> and is gated at its save instead; see
|
||||
/// <see cref="SqlCredentialGuard"/> for why a deploy gate is the wrong instrument for a value that
|
||||
/// never enters the artifact.</para>
|
||||
/// <para>Checked on <b>both</b> config surfaces a Sql driver reads: the instance's
|
||||
@@ -344,7 +344,7 @@ public static class DraftValidator
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Error code for an MTConnect driver instance that has more than one <c>/raw</c> Device (#628).</summary>
|
||||
/// <summary>Error code for an MTConnect driver instance that has more than one <c>/raw</c> Device.</summary>
|
||||
public const string MTConnectMultipleDevicesCode = "MTConnectMultipleDevices";
|
||||
|
||||
/// <summary>
|
||||
@@ -352,6 +352,7 @@ public static class DraftValidator
|
||||
/// use, so the two can never describe different rules.
|
||||
/// </summary>
|
||||
/// <param name="driverLabel">The driver instance id or name to cite.</param>
|
||||
/// <returns>The validation message text.</returns>
|
||||
public static string MTConnectMultipleDevicesMessage(string driverLabel) =>
|
||||
$"MTConnect driver '{driverLabel}' has more than one Device. MTConnect binds all tags through one agent " +
|
||||
"client per driver instance and does not route by /raw Device, and with several Devices the Device's " +
|
||||
@@ -359,7 +360,7 @@ public static class DraftValidator
|
||||
"supply the endpoint is ambiguous. Author one MTConnect driver per agent device, each with a single Device.";
|
||||
|
||||
/// <summary>
|
||||
/// #628 (decision (b)): an MTConnect driver instance owns exactly one agent client, so a second <c>/raw</c>
|
||||
/// An MTConnect driver instance owns exactly one agent client, so a second <c>/raw</c>
|
||||
/// Device under it is a genuine ambiguity — the driver ignores <c>RawTagEntry.DeviceName</c>, and
|
||||
/// <c>DriverDeviceConfigMerger</c> only merges a Device's <c>DeviceConfig</c> up to the driver config when
|
||||
/// there is exactly ONE Device. The normal single-Device layout (every driver gets a default
|
||||
@@ -380,7 +381,7 @@ public static class DraftValidator
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>WP7 Calculation-driver deploy gates. For every tag bound to a <c>Calculation</c> driver:
|
||||
/// <summary>Calculation-driver deploy gates. For every tag bound to a <c>Calculation</c> driver:
|
||||
/// <list type="number">
|
||||
/// <item><b>scriptId existence</b> — the tag's <c>TagConfig.scriptId</c> must be present and resolve to
|
||||
/// a <see cref="Script"/> row in the draft.</item>
|
||||
@@ -634,7 +635,7 @@ public static class DraftValidator
|
||||
}
|
||||
|
||||
// The rule itself lives on UnsSegment, which the AdminUI's authoring guards also call — a second
|
||||
// copy here is exactly how the two drifted apart (Gitea #535). Only the per-level wording is local.
|
||||
// copy here is exactly how the two drifted apart. Only the per-level wording is local.
|
||||
private static void ValidateUnsSegments(DraftSnapshot draft, List<ValidationError> errors)
|
||||
{
|
||||
foreach (var a in draft.UnsAreas)
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// The one place that decides whether a persisted config blob carries a literal Sql
|
||||
/// <c>connectionString</c> (Gitea #498 / #499).
|
||||
/// <c>connectionString</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
|
||||
@@ -12,7 +12,7 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||
/// rule is not cosmetic: it is what keeps a NodeId parseable and stable.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ⚠️ <b>This exists because the rule was duplicated and drifted (Gitea #535).</b> The deploy gate
|
||||
/// ⚠️ <b>This exists because the rule was duplicated and drifted.</b> The deploy gate
|
||||
/// held it privately in <c>DraftValidator</c> and the AdminUI restated it on the equipment form
|
||||
/// alone, so an area or line could be named <c>Packaging</c> or <c>Line 1</c>, saved, and then
|
||||
/// fail the deploy with <c>UnsSegmentInvalid</c>. Because a deployment snapshots the whole
|
||||
|
||||
@@ -2,7 +2,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Shared capped-exponential backoff for poll-based / lazy-reconnect drivers, extracted from
|
||||
/// the S7 driver's bespoke poll fork (05/STAB-8, CONV-1). Provides two surfaces:
|
||||
/// the S7 driver's bespoke poll fork. Provides two surfaces:
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="ComputeDelay"/> — the pure schedule (base, 2×, 4×, … saturating at a
|
||||
/// cap) used by <c>PollGroupEngine</c> to stretch its retry cadence under sustained
|
||||
|
||||
@@ -3,10 +3,10 @@ using System.Text.Json;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #610 — the single authority on whether a driver can WRITE a 1-D array tag. The address-space
|
||||
/// the single authority on whether a driver can WRITE a 1-D array tag. The address-space
|
||||
/// composer consults it at compose time (both compose seams run the same composer, so the live-edit and
|
||||
/// artifact-decode sides cannot disagree) to decide whether an authored-<c>ReadWrite</c> array tag
|
||||
/// materialises as a writable OPC UA node. Before #610 every array node was forced read-only regardless of
|
||||
/// materialises as a writable OPC UA node. Previously every array node was forced read-only regardless of
|
||||
/// driver; that remains the answer for every (driver, element type) pair NOT listed here.
|
||||
/// <para>
|
||||
/// <b>The rule is "never advertise a write the driver will refuse".</b> A pair is listed only when the
|
||||
@@ -125,16 +125,16 @@ public static class DriverArrayWriteSupport
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #610 review (R6 MINOR-1) — whether a driver of <paramref name="driverType"/> is known to REFUSE an
|
||||
/// Whether a driver of <paramref name="driverType"/> is known to REFUSE an
|
||||
/// array value written to one of its SCALAR tags, so the node manager may refuse such a write inline
|
||||
/// (<c>BadTypeMismatch</c>, nothing dispatched) instead of storing it optimistically, dispatching it, and
|
||||
/// reverting it when the scalar encoder throws.
|
||||
/// <para>
|
||||
/// Only Modbus and S7 qualify: their scalar encoders <c>Convert.ToXxx</c> the value, which throws on an
|
||||
/// array. Every other driver keeps the pre-#610 behaviour — the write is dispatched and the driver
|
||||
/// array. Every other driver keeps the previous behaviour — the write is dispatched and the driver
|
||||
/// decides. That matters for the pass-through drivers: a browse-committed array attribute is committed
|
||||
/// as a SCALAR tag (the <c>/raw</c> commit never sets <c>isArray</c>), the driver publishes an array into
|
||||
/// it, and writing that array back worked before #610 — <c>OpcUaClient</c> forwards the
|
||||
/// it, and writing that array back worked previously — <c>OpcUaClient</c> forwards the
|
||||
/// <c>Variant</c> unchanged (the upstream server arbitrates shape) and Galaxy's <c>MxValueEncoder</c>
|
||||
/// encodes the array. An unknown / null driver type is not listed ⇒ <see langword="false"/>.
|
||||
/// </para>
|
||||
|
||||
@@ -53,7 +53,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
/// Sparkplug births, MTConnect probe inference).
|
||||
/// <para>
|
||||
/// <b>It exists because <paramref name="DriverDataType"/> is lossy in exactly the direction a
|
||||
/// browse-commit needs</b> (Gitea #536): <c>AbCipDataType.SInt</c>, <c>.Int</c> and <c>.DInt</c>
|
||||
/// browse-commit needs</b>: <c>AbCipDataType.SInt</c>, <c>.Int</c> and <c>.DInt</c>
|
||||
/// all map to <see cref="DriverDataType.Int32"/>, so a commit that reverse-mapped the
|
||||
/// driver-agnostic type would bind a 16-bit INT as a 32-bit DINT. Nothing downstream of the
|
||||
/// address-space build reads this — it is authoring metadata carried alongside the type the
|
||||
|
||||
@@ -38,7 +38,7 @@ public enum DriverCapability
|
||||
AlarmAcknowledge,
|
||||
|
||||
/// <summary>
|
||||
/// <c>IDriver.InitializeAsync</c> — connection establishment (R2-10 T10 closure: without this,
|
||||
/// <c>IDriver.InitializeAsync</c> — connection establishment (without this,
|
||||
/// a driver that cannot connect contributes zero breaker input and the resilience panel stays
|
||||
/// quiet for the failure operators most care about). Does NOT retry — the
|
||||
/// <c>DriverInstanceActor</c> reconnect timer is the retry loop, and pipeline-level retries
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
// OPC UA Part 11 modified-value history (Gitea #668): every HistoryUpdate a backend applies leaves an audit row
|
||||
// OPC UA Part 11 modified-value history: every HistoryUpdate a backend applies leaves an audit row
|
||||
// carrying the PRIOR value, who made the change and when; HistoryRead with IsReadModified=true serves them.
|
||||
|
||||
/// <summary>Who is performing a HistoryUpdate. Threaded into every <see cref="IHistorianDataUpdater"/> call.</summary>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
// Shared history result contracts used by IHistorianDataSource and the historian gateway.
|
||||
// Drivers carry no history capability (Gitea #655): HistoryRead is served server-side.
|
||||
// Drivers carry no history capability: HistoryRead is served server-side.
|
||||
|
||||
|
||||
/// <summary>Result of a HistoryRead call.</summary>
|
||||
@@ -13,7 +13,7 @@ public sealed record HistoryReadResult(
|
||||
|
||||
/// <summary>Aggregate function for processed history reads. Mirrors OPC UA Part 13 standard aggregates.</summary>
|
||||
/// <remarks>
|
||||
/// The first five members are the original set every historian backend serves. The remainder (#285) are
|
||||
/// The first five members are the original set every historian backend serves. The remainder are
|
||||
/// the further standard Part 13 aggregates a server-side HistoryRead can forward to a backend that
|
||||
/// supports them (a gateway backend maps them to its own aggregate modes). A backend that cannot serve a
|
||||
/// member must throw <see cref="HistoryAggregateNotSupportedException"/> — the server maps that to
|
||||
@@ -48,14 +48,14 @@ public enum HistoryAggregateType
|
||||
StandardDeviationPopulation,
|
||||
VariancePopulation,
|
||||
|
||||
/// <summary>Part 13 TimeAverage2: time-weighted average over the interval's non-Bad data only (#671).</summary>
|
||||
/// <summary>Part 13 TimeAverage2: time-weighted average over the interval's non-Bad data only.</summary>
|
||||
TimeAverage2,
|
||||
|
||||
/// <summary>Part 13 Total2: time-weighted integral (value·seconds) over the interval's non-Bad data only (#671).</summary>
|
||||
/// <summary>Part 13 Total2: time-weighted integral (value·seconds) over the interval's non-Bad data only.</summary>
|
||||
Total2,
|
||||
}
|
||||
|
||||
/// <summary>Shared <see cref="HistoryAggregateType"/> sets for <see cref="IHistorianDataSource.SupportedAggregates"/> (#682).</summary>
|
||||
/// <summary>Shared <see cref="HistoryAggregateType"/> sets for <see cref="IHistorianDataSource.SupportedAggregates"/>.</summary>
|
||||
public static class HistoryAggregateTypes
|
||||
{
|
||||
/// <summary>Every defined <see cref="HistoryAggregateType"/> — the interface default.</summary>
|
||||
@@ -85,7 +85,7 @@ public sealed class HistoryAggregateNotSupportedException : NotSupportedExceptio
|
||||
|
||||
/// <summary>
|
||||
/// Thrown by a history backend when a read would exceed a configured size limit (e.g. the raw-row cap of the SQL
|
||||
/// backends' time-weighted aggregates, #671). The Server HistoryRead path translates it to
|
||||
/// backends' time-weighted aggregates). The Server HistoryRead path translates it to
|
||||
/// <c>BadTooManyOperations</c> for that node only; it is a client-request limit, not a backend fault.
|
||||
/// </summary>
|
||||
public sealed class HistoryReadLimitExceededException : InvalidOperationException
|
||||
@@ -94,7 +94,7 @@ public sealed class HistoryReadLimitExceededException : InvalidOperationExceptio
|
||||
public long Limit { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #694: a raw read refused because rows BEFORE its window (inside the backend's look-back margin — a tie
|
||||
/// a raw read refused because rows BEFORE its window (inside the backend's look-back margin — a tie
|
||||
/// cluster just before a page start, the HistorianGateway's boundary row) crowded a ceiling-bounded row budget: how
|
||||
/// many such rows the read saw (a lower bound when the read was itself truncated). <c>0</c> = not a crowding refusal.
|
||||
/// The server re-reads such a page once, smaller by this many values (<c>RawPageLimit</c>).
|
||||
@@ -127,7 +127,7 @@ public sealed record HistoricalEvent(
|
||||
ushort Severity);
|
||||
|
||||
/// <summary>Result of a <see cref="IHistorianDataSource.ReadEventsAsync(string?, DateTime, DateTime, int, CancellationToken)"/> call.</summary>
|
||||
/// <param name="Events">Events ordered by <c>EventTimeUtc</c> in the read's direction — ascending, or descending for a reverse read (#675).</param>
|
||||
/// <param name="Events">Events ordered by <c>EventTimeUtc</c> in the read's direction — ascending, or descending for a reverse read.</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,
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is a *server registration*, not a driver capability: drivers carry no history
|
||||
/// surface (Gitea #655). The server calls it directly, so a single historian can serve
|
||||
/// surface. The server calls it directly, so a single historian can serve
|
||||
/// many drivers' nodes, and drivers can restart without dropping history availability.
|
||||
/// All values returned use the shared <see cref="DataValueSnapshot"/> /
|
||||
/// <see cref="HistoricalEvent"/> shapes; backend-specific quality / type encodings
|
||||
@@ -20,7 +20,7 @@ public interface IHistorianDataSource : IDisposable
|
||||
/// <remarks>
|
||||
/// Forward (<c>startUtc < endUtc</c>) is <c>[start, end)</c> ascending; <c>startUtc == endUtc</c> is a point read.
|
||||
/// <c>startUtc > endUtc</c> is the Part 11 reverse read <c>(end, start]</c> newest-first — but only a backend that
|
||||
/// declares <see cref="SupportsReverseRaw"/> is ever sent one (Gitea #686); the server serves a reverse read on any
|
||||
/// declares <see cref="SupportsReverseRaw"/> is ever sent one; the server serves a reverse read on any
|
||||
/// other backend from bounded forward reads.
|
||||
/// </remarks>
|
||||
/// <param name="fullReference">The full reference of the tag to read.</param>
|
||||
@@ -38,7 +38,7 @@ public interface IHistorianDataSource : IDisposable
|
||||
|
||||
/// <summary>
|
||||
/// Whether this backend can serve a reverse (newest-first) raw read itself — <see cref="ReadRawAsync"/> with
|
||||
/// <c>startUtc > endUtc</c> (Gitea #686). The server checks it before every raw read. When <c>false</c> it never
|
||||
/// <c>startUtc > endUtc</c>. The server checks it before every raw read. When <c>false</c> it never
|
||||
/// sends <c>start > end</c>: it serves the reverse read — an explicit one, a resumed reverse page, and the
|
||||
/// end-only capped read Part 11 reads backward from EndTime — from a series of bounded FORWARD reads walking back
|
||||
/// from the later bound (<c>ReverseRawReader</c> in the OPC UA server). Default <c>false</c>, the safe direction: a
|
||||
@@ -48,11 +48,11 @@ public interface IHistorianDataSource : IDisposable
|
||||
|
||||
/// <summary>
|
||||
/// The most values one capped <see cref="ReadRawAsync"/> can currently return without failing
|
||||
/// <see cref="HistoryReadLimitExceededException"/> on a BACKEND row ceiling (Gitea #694); <c>0</c> = no known limit.
|
||||
/// <see cref="HistoryReadLimitExceededException"/> on a BACKEND row ceiling; <c>0</c> = no known limit.
|
||||
/// The server sizes its raw pages to it — a page is at most this many values and carries a continuation point when
|
||||
/// full (Part 11 allows fewer than <c>NumValuesPerNode</c> with a continuation point), instead of failing a page size
|
||||
/// the backend cannot read in one go. It may change at runtime (a strict HistorianGateway's ceiling is learned from
|
||||
/// its first refusal, #691.3), so it is read per read. A backend whose reads are never ceiling-bound returns <c>0</c>.
|
||||
/// its first refusal), so it is read per read. A backend whose reads are never ceiling-bound returns <c>0</c>.
|
||||
/// The source still never truncates: a read asking for more than this is answered in full or refused. Default
|
||||
/// <c>0</c>; a backend declares it itself (a guard test pins that).
|
||||
/// </summary>
|
||||
@@ -94,7 +94,7 @@ public interface IHistorianDataSource : IDisposable
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #693: <see cref="ReadAtTimeAsync(string, IReadOnlyList{DateTime}, CancellationToken)"/> with the value type of
|
||||
/// <see cref="ReadAtTimeAsync(string, IReadOnlyList{DateTime}, CancellationToken)"/> with the value type of
|
||||
/// the node being read, for a backend whose stored values do not carry their type (the Gateway: AVEVA stores every
|
||||
/// historized type as a numeric analog, so a Boolean comes back as a 0/1 Double). Such a backend uses the hint to apply
|
||||
/// the AtTime rules exactly as the typed backends do — a Boolean (or String / DateTime) node is stepped, an integer node
|
||||
@@ -156,17 +156,17 @@ public interface IHistorianDataSource : IDisposable
|
||||
|
||||
/// <summary>
|
||||
/// Whether this backend can serve a reverse (newest-first) events read — the reverse overload of
|
||||
/// <see cref="ReadEventsAsync(string?, DateTime, DateTime, int, bool, CancellationToken)"/> (Gitea #675). The server
|
||||
/// <see cref="ReadEventsAsync(string?, DateTime, DateTime, int, bool, CancellationToken)"/>. The server
|
||||
/// checks it BEFORE calling the source: a reverse read (explicit <c>StartTime > EndTime</c>, or an end-only capped
|
||||
/// read, which Part 11 reads backward) on a backend that cannot is never sent the reverse overload — since Gitea
|
||||
/// #691.4 the server walks it back from bounded FORWARD reads instead (#675 refused it). Default <c>false</c>; an
|
||||
/// the server walks it back from bounded FORWARD reads instead (it was once refused). Default <c>false</c>; an
|
||||
/// implementation (or a wrapper) declares it itself — a guard test pins that.
|
||||
/// </summary>
|
||||
bool SupportsReverseEvents => false;
|
||||
|
||||
/// <summary>
|
||||
/// Read historical alarm / event records in a chosen direction — the overload the server's HistoryReadEvents
|
||||
/// paging calls (Gitea #675).
|
||||
/// paging calls.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -182,7 +182,7 @@ public interface IHistorianDataSource : IDisposable
|
||||
/// The default forwards a forward read to the five-argument overload and throws
|
||||
/// <see cref="NotSupportedException"/> for a reverse one — a backend that cannot read newest-first (the
|
||||
/// Gateway). The server never relies on that throw: it consults <see cref="SupportsReverseEvents"/> first and walks
|
||||
/// such a read back from forward reads (#691.4), never returning the earliest events in ascending order as if
|
||||
/// such a read back from forward reads, never returning the earliest events in ascending order as if
|
||||
/// they were the answer. A backend that serves reverse natively must override BOTH members.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
@@ -228,7 +228,7 @@ public interface IHistorianDataSource : IDisposable
|
||||
long TimestampResolutionTicks => 1;
|
||||
|
||||
/// <summary>
|
||||
/// The Part 13 aggregates this backend serves for a processed HistoryRead (Gitea #682) — the ONE authority for
|
||||
/// The Part 13 aggregates this backend serves for a processed HistoryRead — the ONE authority for
|
||||
/// both what the server advertises under <c>HistoryServerCapabilities/AggregateFunctions</c> (i=11201) and what
|
||||
/// its HistoryRead-Processed answers <c>BadAggregateNotSupported</c> up front without calling the backend.
|
||||
/// (<c>ServerCapabilities/AggregateFunctions</c>, i=2997, is not affected: it lists every aggregate the server
|
||||
@@ -236,7 +236,7 @@ public interface IHistorianDataSource : IDisposable
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The default is <see cref="HistoryAggregateTypes.All"/> — "every aggregate the server can name; a member I cannot
|
||||
/// serve throws <see cref="HistoryAggregateNotSupportedException"/> at read time", which is exactly the pre-#682
|
||||
/// serve throws <see cref="HistoryAggregateNotSupportedException"/> at read time", which is exactly the earlier
|
||||
/// behaviour, so a test fake or an out-of-tree source keeps working unchanged. It is a concrete set rather than a
|
||||
/// <c>null</c> "all" sentinel so every consumer handles one shape. Every PRODUCTION backend declares its set itself
|
||||
/// (a reflection guard pins that): a wrapper that forgot to forward this would otherwise advertise every aggregate.
|
||||
@@ -247,7 +247,7 @@ public interface IHistorianDataSource : IDisposable
|
||||
IReadOnlySet<HistoryAggregateType> SupportedAggregates => HistoryAggregateTypes.All;
|
||||
|
||||
/// <summary>
|
||||
/// Read modified-value history — OPC UA HistoryReadRaw with <c>IsReadModified = true</c> (Part 11, Gitea #668):
|
||||
/// Read modified-value history — OPC UA HistoryReadRaw with <c>IsReadModified = true</c> (Part 11):
|
||||
/// every modification a HistoryUpdate applied to the tag inside the window, each carrying the PRIOR value.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
|
||||
@@ -18,13 +18,13 @@ public enum HistoryUpdateMode
|
||||
/// supplies no updater (the server then reports HistoryUpdate as unsupported).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Modified-value history (Part 11, Gitea #668).</b> Every sample an implementation inserts, replaces or deletes
|
||||
/// <b>Modified-value history (Part 11).</b> Every sample an implementation inserts, replaces or deletes
|
||||
/// MUST leave one modification record carrying the prior value (none for an insert), the
|
||||
/// <see cref="HistoryUpdateContext.Principal"/>, the modification time and the <see cref="HistoryModificationType"/>,
|
||||
/// readable through <see cref="IHistorianDataSource.ReadModifiedAsync"/>. A refused item (any non-Good code) leaves
|
||||
/// no record. Live recorder writes and retention purges never leave one.
|
||||
/// <para>
|
||||
/// <b>Cannot update at all</b> (Gitea #674): an implementation that is wired but cannot perform any update right
|
||||
/// <b>Cannot update at all</b>: an implementation that is wired but cannot perform any update right
|
||||
/// now — the SqlServer backend on a schema without the modification trail — throws
|
||||
/// <see cref="NotSupportedException"/> from every method without touching storage. The server reports it as a
|
||||
/// node-level <c>BadHistoryOperationUnsupported</c>, the same as <see cref="IHistorianDataSource.ReadModifiedAsync"/>.
|
||||
|
||||
@@ -56,21 +56,21 @@ public sealed class NullHistorianDataSource : IHistorianDataSource
|
||||
CancellationToken cancellationToken) => Task.FromResult(EmptyEvents);
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks><c>true</c> (Gitea #686): there is no history, so the empty result is the correct answer in either
|
||||
/// direction — no need for the server to walk back through forward reads to find nothing.</remarks>
|
||||
// true: there is no history, so the empty result is the correct answer in either
|
||||
// direction — no need for the server to walk back through forward reads to find nothing.
|
||||
public bool SupportsReverseRaw => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks><c>0</c>: no history, no ceiling (Gitea #694).</remarks>
|
||||
// 0: no history, no ceiling.
|
||||
public int MaxRawValuesPerRead => 0;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks><c>true</c>, like <see cref="SupportsReverseRaw"/> (Gitea #691.4 review; <c>false</c> under #675): there is no
|
||||
/// history to read in either direction — no need for the server to walk back through empty forward reads.</remarks>
|
||||
// true, like SupportsReverseRaw: there is no history to read in either direction —
|
||||
// no need for the server to walk back through empty forward reads.
|
||||
public bool SupportsReverseEvents => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>The empty result in either direction.</remarks>
|
||||
// The empty result in either direction.
|
||||
public Task<HistoricalEventsResult> ReadEventsAsync(
|
||||
string? sourceName,
|
||||
DateTime startUtc,
|
||||
@@ -81,12 +81,10 @@ public sealed class NullHistorianDataSource : IHistorianDataSource
|
||||
Task.FromResult(EmptyEvents);
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// EMPTY (Gitea #682): with no historian configured the server advertises no aggregate functions, and a processed
|
||||
/// HistoryRead answers <c>BadAggregateNotSupported</c> per node rather than <c>GoodNoData</c> — an aggregate is a
|
||||
/// statistic the server cannot compute without a history, and advertising the SDK's full list on a server with no
|
||||
/// historian misled capability discovery. Raw / AtTime / Events reads still return the empty <c>GoodNoData</c> result.
|
||||
/// </remarks>
|
||||
// EMPTY: with no historian configured the server advertises no aggregate functions, and a processed
|
||||
// HistoryRead answers BadAggregateNotSupported per node rather than GoodNoData — an aggregate is a
|
||||
// statistic the server cannot compute without a history, and advertising the SDK's full list on a server with no
|
||||
// historian misled capability discovery. Raw / AtTime / Events reads still return the empty GoodNoData result.
|
||||
public IReadOnlySet<HistoryAggregateType> SupportedAggregates => HistoryAggregateTypes.None;
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
/// Driver capability for alarm events. Optional — only drivers whose backends expose
|
||||
/// alarm conditions implement this. Currently: Galaxy (MxAccess alarms), FOCAS
|
||||
/// (CNC alarms), OPC UA Client (A&C events from upstream server), MTConnect
|
||||
/// (CONDITION data items, Gitea #626).
|
||||
/// (CONDITION data items).
|
||||
/// </summary>
|
||||
public interface IAlarmSource
|
||||
{
|
||||
@@ -88,22 +88,22 @@ public sealed record AlarmAcknowledgeRequest(
|
||||
/// </param>
|
||||
/// <param name="OpcUaSeverity">
|
||||
/// Optional exact OPC UA severity (1..1000) for a source whose own scale is finer than the four
|
||||
/// <see cref="AlarmSeverity"/> buckets (Gitea #626: MTConnect Warning = 500, Fault = 800). When set it
|
||||
/// <see cref="AlarmSeverity"/> buckets (MTConnect Warning = 500, Fault = 800). When set it
|
||||
/// supersedes the bucket on the projected Part 9 condition (clamped to 1..1000); <c>null</c> — every
|
||||
/// pre-#626 source — keeps the bucket mapping. <see cref="Severity"/> must still carry the nearest
|
||||
/// earlier source — keeps the bucket mapping. <see cref="Severity"/> must still carry the nearest
|
||||
/// bucket for consumers that only read the bucket.
|
||||
/// </param>
|
||||
/// <param name="QualityStatusCode">
|
||||
/// Optional status code of the condition's SOURCE data (Part 9 <c>ConditionType.Quality</c>), for a
|
||||
/// source that can report "no value for this condition" per alarm rather than per connection (Gitea
|
||||
/// #626: MTConnect <c>Unavailable</c>). <c>null</c> keeps the pre-#626 behaviour (projected Good;
|
||||
/// source that can report "no value for this condition" per alarm rather than per connection (for example
|
||||
/// MTConnect <c>Unavailable</c>). <c>null</c> keeps the earlier behaviour (projected Good;
|
||||
/// driver-wide comms loss is annotated separately by the host). An event whose <see cref="Kind"/> is
|
||||
/// <see cref="AlarmTransitionKind.Unspecified"/> and which carries a quality is a
|
||||
/// <b>quality-only annotation</b> (<see cref="IsQualityOnly"/>): the host updates (via its Quality-only node path) the condition's
|
||||
/// Quality without touching its Active/Acked state and publishes no <c>alerts</c> row for it.
|
||||
/// </param>
|
||||
/// <param name="IsRestatement">
|
||||
/// Gitea #656 — true when the source is RE-STATING upstream's current state rather than reporting a transition it
|
||||
/// True when the source is RE-STATING upstream's current state rather than reporting a transition it
|
||||
/// watched happen: a reconnect snapshot entry (Galaxy) or the first event for a condition after a driver start or
|
||||
/// deploy respawn (OpcUaClient). It governs <b>history only, never node state</b>: the host still projects the
|
||||
/// state, but writes no <c>alerts</c> row when the re-statement changes nothing the condition shows (a Retrigger on
|
||||
@@ -111,7 +111,7 @@ public sealed record AlarmAcknowledgeRequest(
|
||||
/// Acknowledge on an already-acknowledged condition) — or
|
||||
/// when a flagged <see cref="AlarmTransitionKind.Unspecified"/> update moves the message/severity of an
|
||||
/// already-active condition (node updated, one condition event, active/acked kept, no row). AbCip ALMA sets the
|
||||
/// flag for the last shape (the governing limit changed, no new occurrence). OpcUaClient (Gitea #657) re-states a
|
||||
/// flag for the last shape (the governing limit changed, no new occurrence). OpcUaClient re-states a
|
||||
/// first-seen inactive+acked condition as a flagged Clear then a flagged Acknowledge, so a host already showing
|
||||
/// that writes nothing and a host retaining it Active gets a real "Cleared". <c>false</c> (the default, every source that does not
|
||||
/// opt in) keeps every transition's row.
|
||||
@@ -147,9 +147,9 @@ public enum AlarmSeverity { Low, Medium, High, Critical }
|
||||
/// Kind of alarm state change carried by <see cref="AlarmEventArgs.Kind"/>, letting a
|
||||
/// consumer derive OPC UA Part 9 active/ack state. Mirrors the driver-side
|
||||
/// transition-kind enums (e.g. Galaxy's <c>GalaxyAlarmTransitionKind</c>).
|
||||
/// <see cref="ActiveAcknowledged"/> (Gitea #656) means "the condition is active AND acknowledged", applied by the
|
||||
/// <see cref="ActiveAcknowledged"/> means "the condition is active AND acknowledged", applied by the
|
||||
/// host as ONE projection — never Raise-then-Acknowledge, which would show clients an Active+unacked intermediate.
|
||||
/// <see cref="InactiveUnacknowledged"/> (Gitea #657) is its mirror: "the condition is inactive AND unacknowledged"
|
||||
/// <see cref="InactiveUnacknowledged"/> is its mirror: "the condition is inactive AND unacknowledged"
|
||||
/// (it went active and cleared unacknowledged), applied as ONE projection — never Raise-then-Clear, which showed
|
||||
/// clients the condition Active again for an instant. The host writes rows only for what actually changed:
|
||||
/// "Activated" + "Cleared" when the condition was acknowledged (a new occurrence must have happened), "Cleared"
|
||||
|
||||
@@ -24,8 +24,8 @@ public interface IDriver
|
||||
|
||||
/// <summary>
|
||||
/// Apply a config change in place without tearing down the driver process.
|
||||
/// <para>The runtime does not call this today: since #516 a DriverConfig change is a stop + respawn,
|
||||
/// and #665 deleted <c>DriverInstanceActor</c>'s in-place <c>ApplyDelta</c> path. Kept as the in-place
|
||||
/// <para>The runtime does not call this today: a DriverConfig change is a stop + respawn,
|
||||
/// and <c>DriverInstanceActor</c> no longer has an in-place <c>ApplyDelta</c> path. Kept as the in-place
|
||||
/// contract each driver implements and tests.</para>
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
@@ -40,12 +40,12 @@ public interface IDriver
|
||||
|
||||
/// <summary>Stop the driver, close connections, release resources. Called on shutdown or driver removal.</summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Land the terminal health value AFTER the teardown, never only before it</b> (Gitea #517).
|
||||
/// <para><b>Land the terminal health value AFTER the teardown, never only before it</b>.
|
||||
/// Teardown drains in-flight poll / probe loops, and those loops write health as they unwind — so a
|
||||
/// driver that wrote <see cref="DriverState.Unknown"/> first and then tore down could have a late
|
||||
/// loop overwrite it with <see cref="DriverState.Healthy"/> and leave THAT as the resting state,
|
||||
/// with every connection already closed. This is why the fleet writes last, and it is the reason
|
||||
/// #517's original suggestion — "write the transitional value before disposing" — is only safe as an
|
||||
/// The original suggestion — "write the transitional value before disposing" — is only safe as an
|
||||
/// ADDITIONAL write, bracketing the teardown, rather than as a move.</para>
|
||||
/// <para>Bracketing matters wherever a <see cref="GetHealth"/> can land mid-teardown. It cannot on
|
||||
/// this path today: <c>DriverInstanceActor</c> calls this from <c>PostStop</c>, on the actor thread,
|
||||
@@ -61,7 +61,7 @@ public interface IDriver
|
||||
/// <summary>Current health snapshot, polled by Core for the status dashboard and ServiceLevel.
|
||||
/// <para>Must reflect the driver's ACTUAL state rather than a constant — a hard-coded
|
||||
/// <see cref="DriverState.Healthy"/> reports a healthy driver after shutdown and before initialize
|
||||
/// alike, which is what Gitea #517 found in <c>CalculationDriver</c>. A driver with no transport still
|
||||
/// alike, which was found in <c>CalculationDriver</c>. A driver with no transport still
|
||||
/// has a running / not-running distinction worth reporting.</para></summary>
|
||||
/// <returns>The driver's current health snapshot.</returns>
|
||||
DriverHealth GetHealth();
|
||||
|
||||
@@ -49,7 +49,7 @@ public interface IDriverCapabilityInvoker
|
||||
/// <paramref name="isIdempotent"/> is <c>false</c>, retries are disabled regardless of the
|
||||
/// configured policy; when <c>true</c>, the call runs through the Write pipeline which may
|
||||
/// retry if the tier configuration permits. <b>The current production default is <c>false</c></b>
|
||||
/// (R2-02/S-8): <c>DriverInstanceActor.HandleWriteAsync</c> passes <c>isIdempotent: false</c> so a
|
||||
/// <c>DriverInstanceActor.HandleWriteAsync</c> passes <c>isIdempotent: false</c> so a
|
||||
/// command-shaped write is never replayed; per-tag opt-in via <see cref="WriteIdempotentAttribute"/>
|
||||
/// is the unshipped forward path.
|
||||
/// </summary>
|
||||
|
||||
@@ -31,7 +31,7 @@ public interface IDriverCapabilityInvokerFactory
|
||||
|
||||
/// <summary>
|
||||
/// Release every piece of shared resilience state held for <paramref name="driverInstanceId"/> — called
|
||||
/// by the host when the instance leaves the deployed plan (#609). A concrete factory drops the
|
||||
/// by the host when the instance leaves the deployed plan. A concrete factory drops the
|
||||
/// instance's cached pipelines and its status-tracker snapshots, so the periodic resilience publisher
|
||||
/// stops re-emitting (and re-priming new telemetry subscribers with) a decommissioned driver's last
|
||||
/// breaker/failure counters. Default: no-op (a factory that holds no state).
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Optional driver capability (Gitea #676) — a driver that can tell, from its protocol, that its connection is
|
||||
/// Optional driver capability — a driver that can tell, from its protocol, that its connection is
|
||||
/// DEFINITIVELY gone (it can never recover in place, only a re-initialise brings it back) raises
|
||||
/// <see cref="ConnectionLost"/> so the host re-initialises it at once instead of waiting out the #527 comms-loss
|
||||
/// <see cref="ConnectionLost"/> so the host re-initialises it at once instead of waiting out the comms-loss
|
||||
/// watchdog (~90 s of frozen <see cref="DriverHealth.LastSuccessfulRead"/>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
|
||||
@@ -3,7 +3,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
/// <summary>
|
||||
/// Optional driver capability: expose a small bag of operator-facing diagnostic key/value pairs
|
||||
/// (e.g. S7's negotiated PDU size) that ride the driver-health snapshot to the AdminUI <c>/hosts</c>
|
||||
/// page (Gitea #640).
|
||||
/// page.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
|
||||
@@ -32,11 +32,11 @@ public interface IDriverHealthPublisher
|
||||
/// Authored tags the driver could not build into its poll table, from
|
||||
/// <see cref="ISkippedTagReporter.GetSkippedTags"/>; null when the driver is not an
|
||||
/// <see cref="ISkippedTagReporter"/>. Contradicts the "Healthy, 0 errors, reads advancing" reading
|
||||
/// of a driver that is in fact ignoring part or all of its configuration (Gitea #541).
|
||||
/// of a driver that is in fact ignoring part or all of its configuration.
|
||||
/// </param>
|
||||
/// <param name="diagnostics">
|
||||
/// Bounded driver diagnostics from <see cref="IDriverDiagnostics.GetDiagnostics"/>; null when the
|
||||
/// driver is not an <see cref="IDriverDiagnostics"/> (Gitea #640).
|
||||
/// driver is not an <see cref="IDriverDiagnostics"/>.
|
||||
/// </param>
|
||||
void Publish(
|
||||
string clusterId,
|
||||
|
||||
@@ -12,7 +12,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
/// <i>inbound</i>: a write, an alarm ack, an alerts publish. A Sparkplug primary-host claim is
|
||||
/// not inbound. It is a standing assertion the driver makes to the broker, on its own
|
||||
/// connection, for as long as it holds one — there is no message for the host actor to
|
||||
/// intercept. So the decision has to reach the driver instead (Gitea #511).
|
||||
/// intercept. So the decision has to reach the driver instead.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The decision, not the role.</b> Same discipline as the alarm-drain view in
|
||||
@@ -61,7 +61,7 @@ public interface IDriverPrimaryRoleSource
|
||||
/// publishing a role there is by definition no peer to collide with, and a closed default
|
||||
/// would make an exclusive-identity claim silently never happen on the single-node deployment
|
||||
/// that is the common case — an inert feature with no error, which is precisely the failure
|
||||
/// mode Gitea #511 was filed about.
|
||||
/// mode this contract exists to prevent.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The risk this shifts onto the wiring is real and is guarded there instead: a redundant
|
||||
@@ -82,8 +82,7 @@ public sealed class UnmanagedDriverPrimaryRoleSource : IDriverPrimaryRoleSource
|
||||
/// <inheritdoc/>
|
||||
public bool ShouldActAsPrimary => true;
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>No-op: there is no redundancy manager to publish anything here.</remarks>
|
||||
/// <inheritdoc />
|
||||
public void PublishPrimaryRole(bool shouldActAsPrimary)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -7,18 +7,18 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why this exists (Gitea #541).</b> Every driver builds its <c>RawPath → definition</c>
|
||||
/// <b>Why this exists.</b> Every driver builds its <c>RawPath → definition</c>
|
||||
/// table at initialise and <i>tolerates</i> a per-tag miss — an unmappable <c>TagConfig</c>, an
|
||||
/// unparseable address, a capability-matrix rejection. Tolerance is the right call: one bad tag
|
||||
/// must not take down the other few thousand. But a skip is not an error and happens before any
|
||||
/// read, so the driver stays <c>Healthy</c>, <c>errors/5 min</c> stays 0, <c>Last error</c> stays
|
||||
/// empty, and <c>Last read</c> advances live off the tags that <i>did</i> map. On the Makino in
|
||||
/// Gitea #540 that combination held while the driver ignored 20 of its 22 tags, and no surface
|
||||
/// a past incident that combination held while the driver ignored 20 of its 22 tags, and no surface
|
||||
/// anywhere said otherwise.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Deliberately additive to driver state, never a change of it.</b> Latching a driver
|
||||
/// non-Healthy over a config problem would collide with the Gitea #527 comms-loss detector,
|
||||
/// non-Healthy over a config problem would collide with the comms-loss detector,
|
||||
/// which trips on <i>90 s without a successful read AND non-Healthy</i>. A driver whose tags
|
||||
/// were <i>all</i> skipped has no successful reads by construction, so it would satisfy both
|
||||
/// halves and reconnect-loop forever against a device that is perfectly fine. The signal
|
||||
@@ -32,7 +32,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
/// <para>
|
||||
/// Implementations should accumulate into a <see cref="SkippedTagLedger"/> rather than
|
||||
/// hand-rolling a counter: the ledger is one implementation of the reset/commit/cap rules
|
||||
/// instead of one per driver, which is precisely the duplication #541 was filed about.
|
||||
/// instead of one per driver, which is precisely the duplication this type removes.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public interface ISkippedTagReporter
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
/// The one worst-of-input rule every derived-value seam uses — scripted alarms, virtual tags and
|
||||
/// the Calculation driver.
|
||||
///
|
||||
/// <para><b>The contract it implements</b> (archreview R3-02, "the stale-Good family"): a value
|
||||
/// <para><b>The contract it implements</b> ("the stale-Good family"): a value
|
||||
/// consumer never reports a quality better than its source's. A node derived from driver values
|
||||
/// carries, at worst, the worst status among its inputs — a Bad input makes the derived node Bad
|
||||
/// with that input's sub-code, an Uncertain input makes it Uncertain, and a source that has
|
||||
|
||||
@@ -87,8 +87,8 @@ public sealed class PollGroupEngine : IAsyncDisposable
|
||||
/// internal contract-violation throw) so the owning driver can route the failure to its
|
||||
/// health surface. Defensive: an <c>onError</c> handler that
|
||||
/// itself throws is silently absorbed so a buggy forwarder cannot crash the poll loop.</param>
|
||||
/// <param name="backoffCap">Optional cap enabling capped-exponential failure backoff
|
||||
/// (05/STAB-8). When supplied, sustained reader failures stretch the retry cadence
|
||||
/// <param name="backoffCap">Optional cap enabling capped-exponential failure backoff.
|
||||
/// When supplied, sustained reader failures stretch the retry cadence
|
||||
/// (interval, 2×, 4×, … saturating at this cap) instead of hammering a dead device every
|
||||
/// tick; a successful poll resets to the base interval. <c>null</c> (default) keeps the
|
||||
/// byte-identical fixed-interval cadence for consumers that have not opted in.
|
||||
@@ -260,7 +260,7 @@ public sealed class PollGroupEngine : IAsyncDisposable
|
||||
consecutiveFailures = 0;
|
||||
}
|
||||
// Only a teardown OCE (the loop's OWN token) exits the loop. A reader that throws OCE with
|
||||
// the loop token un-cancelled — a driver-internal timeout CTS (the STAB-14/STAB-15 class) —
|
||||
// the loop token un-cancelled — a driver-internal timeout CTS (the class of failure) —
|
||||
// must NOT tear the loop down: it falls through to the generic handler, is reported, and the
|
||||
// loop retries with backoff. This is the guard that makes the S7 fork migration safe.
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; }
|
||||
@@ -319,7 +319,7 @@ public sealed class PollGroupEngine : IAsyncDisposable
|
||||
consecutiveFailures = 0;
|
||||
}
|
||||
// Only teardown (the loop token) exits; a reader OCE with ct un-cancelled backs off
|
||||
// like any other failure instead of killing the loop (STAB-14 engine guard).
|
||||
// like any other failure instead of killing the loop.
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; }
|
||||
catch (Exception ex) when (!IsFatal(ex))
|
||||
{
|
||||
|
||||
@@ -19,7 +19,7 @@ public enum JsonEnumRead
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shared TagConfig JSON field readers for the six equipment-tag parsers (05/CONV-2). Replaces the
|
||||
/// Shared TagConfig JSON field readers for the six equipment-tag parsers. Replaces the
|
||||
/// six byte-identical private <c>ReadEnum</c> copies with one strict-capable home beside
|
||||
/// <see cref="EquipmentTagRefResolver{TDef}"/>.
|
||||
/// <para><b>Unknown-key handling is a deliberate non-goal.</b> The TagConfig blob is a SHARED
|
||||
@@ -62,7 +62,7 @@ public static class TagConfigJson
|
||||
/// AdminUI's typed tag-config editors hold a mutable <c>JsonObject</c> key bag, not a
|
||||
/// <see cref="JsonElement"/> — and an authoring gate that RESTATED the accept/reject boundary
|
||||
/// would drift from the runtime the moment either side moved, in the one direction that hurts:
|
||||
/// a value the modal accepts and the driver then rejects (Gitea #549). Both doors reach this
|
||||
/// a value the modal accepts and the driver then rejects. Both doors reach this
|
||||
/// method.
|
||||
/// </para>
|
||||
/// <para>
|
||||
@@ -131,7 +131,7 @@ public static class TagConfigJson
|
||||
=> TryReadEnum<TEnum>(o, name, out var v) == JsonEnumRead.Valid ? v : fallback;
|
||||
|
||||
/// <summary>
|
||||
/// Strict enum read (R2-11 Phase C — the runtime flip). Absent ⇒ <paramref name="fallback"/>;
|
||||
/// Strict enum read. Absent ⇒ <paramref name="fallback"/>;
|
||||
/// a present, case-insensitively-parseable string ⇒ the parsed value; a present-but-invalid
|
||||
/// string (a typo) ⇒ the read FAILS (returns <see langword="false"/>) so the runtime parser can
|
||||
/// reject the whole tag and the driver surfaces <c>BadNodeIdUnknown</c> — instead of silently
|
||||
|
||||
@@ -9,7 +9,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
/// <remarks>
|
||||
/// Applied to tag-definition POCOs
|
||||
/// (e.g. <c>ModbusTagDefinition</c>, <c>S7TagDefinition</c>, OPC UA client tag rows) at the
|
||||
/// property or record level. <b>Reserved for the per-tag opt-in; NOT yet consulted</b> (R2-02/S-8) —
|
||||
/// property or record level. <b>Reserved for the per-tag opt-in; NOT yet consulted</b> —
|
||||
/// the dispatch site (<c>DriverInstanceActor.HandleWriteAsync</c>) currently treats every write as
|
||||
/// non-idempotent, so no tag retries a write today. Threading tag-definition metadata to the dispatch
|
||||
/// seam is the documented forward path; when it ships, the <c>CapabilityInvoker</c> non-idempotent arm
|
||||
|
||||
@@ -45,16 +45,13 @@ public sealed record AlarmHistorianEvent(
|
||||
[JsonIgnore]
|
||||
public Guid DeliveryId { get; init; } = Guid.NewGuid();
|
||||
|
||||
/// <summary>Compares the ten business fields, excluding transport delivery identity.</summary>
|
||||
/// <param name="other">The event to compare.</param>
|
||||
/// <returns>Whether the business fields are equal.</returns>
|
||||
/// <inheritdoc />
|
||||
public bool Equals(AlarmHistorianEvent? other) => ReferenceEquals(this, other) || other is not null
|
||||
&& AlarmId == other.AlarmId && EquipmentPath == other.EquipmentPath && AlarmName == other.AlarmName
|
||||
&& AlarmTypeName == other.AlarmTypeName && Severity == other.Severity && EventKind == other.EventKind
|
||||
&& Message == other.Message && User == other.User && Comment == other.Comment && TimestampUtc == other.TimestampUtc;
|
||||
|
||||
/// <summary>Hashes the same business fields used by equality, excluding delivery identity.</summary>
|
||||
/// <returns>The business-field hash code.</returns>
|
||||
/// <inheritdoc />
|
||||
public override int GetHashCode()
|
||||
{
|
||||
var hash = new HashCode();
|
||||
|
||||
@@ -106,8 +106,8 @@ public static class Part9StateMachine
|
||||
|
||||
if (current.Acked == AlarmAckedState.Acknowledged)
|
||||
{
|
||||
// A COMMENTED re-ack carries a real comment even though nothing is acknowledged (Gitea #597 review
|
||||
// m1): the OPC UA node reports it — the SDK writes the comment on a re-ack — so the system of
|
||||
// A COMMENTED re-ack carries a real comment even though nothing is acknowledged (review
|
||||
// finding m1): the OPC UA node reports it — the SDK writes the comment on a re-ack — so the system of
|
||||
// record must hold it too, or the event advertises a comment that exists only on the node that
|
||||
// served the call (never persisted, never replicated to the pair peer). Recorded exactly like
|
||||
// AddComment; the ack fields (LastAck*) stay those of the real acknowledge. An uncommented re-ack
|
||||
@@ -387,7 +387,7 @@ public enum EmissionKind
|
||||
Enabled,
|
||||
Disabled,
|
||||
CommentAdded,
|
||||
/// <summary>#478 — the worst-of-input source quality changed bucket (Good/Uncertain/Bad) with no
|
||||
/// <summary>The worst-of-input source quality changed bucket (Good/Uncertain/Bad) with no
|
||||
/// accompanying Part 9 state transition. Delivered out of band via the dedicated
|
||||
/// <c>WriteAlarmQuality</c> node path, never through the <c>IAlarmSource</c> fan-out (it is not a
|
||||
/// state change and must not materialize or historize a condition).</summary>
|
||||
|
||||
@@ -97,7 +97,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// #478 — last emitted worst-of-input quality bucket per alarm (0 = Good, 1 = Uncertain,
|
||||
/// Last emitted worst-of-input quality bucket per alarm (0 = Good, 1 = Uncertain,
|
||||
/// 2 = Bad), computed each evaluation over the refilled read cache. A change in this bucket
|
||||
/// with no accompanying Part 9 state transition drives a standalone
|
||||
/// <see cref="EmissionKind.QualityChanged"/> emission (a Bad input freezes the condition — no
|
||||
@@ -384,7 +384,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
=> _alarms.Values.Select(a => a.Condition).ToArray();
|
||||
|
||||
/// <summary>
|
||||
/// #487 — the currently-held condition of every loaded alarm, packaged with the definition's
|
||||
/// The currently-held condition of every loaded alarm, packaged with the definition's
|
||||
/// severity, the rendered message template and the last-observed input quality, i.e. everything
|
||||
/// a caller needs to <b>project</b> the alarm onto an OPC UA condition node without waiting for
|
||||
/// the next transition.
|
||||
@@ -740,7 +740,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
_ => new AlarmScratch(state.Inputs, state.Logger, _clock));
|
||||
RefillReadCache(scratch.ReadCache, state.Inputs);
|
||||
|
||||
// #478 — worst OPC UA quality across the alarm's inputs, computed BEFORE the readiness
|
||||
// Worst OPC UA quality across the alarm's inputs, computed BEFORE the readiness
|
||||
// short-circuit so an outright-Bad input is still observed. A bucket change with no state
|
||||
// transition is delivered out of band as a QualityChanged emission (see below).
|
||||
var worstStatus = WorstInputStatus(scratch.ReadCache);
|
||||
@@ -881,13 +881,13 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
// adapter honors it to suppress ONLY the durable sink write; the live alerts fan-out is
|
||||
// unaffected (it is not gated on this flag).
|
||||
HistorizeToAveva: state.Definition.HistorizeToAveva,
|
||||
// #478 — the worst input quality at evaluation time rides the transition so the projected
|
||||
// The worst input quality at evaluation time rides the transition so the projected
|
||||
// full snapshot keeps quality consistent (no clobber-to-Good).
|
||||
WorstInputStatusCode: worstInputStatus);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #478 — build a standalone <see cref="EmissionKind.QualityChanged"/> event carrying the new
|
||||
/// Build a standalone <see cref="EmissionKind.QualityChanged"/> event carrying the new
|
||||
/// worst-of-input quality. Emitted when the quality bucket moved but no Part 9 transition fired
|
||||
/// (a Bad input freezes the condition; a Suppressed/None transition also leaves state unchanged).
|
||||
/// The host routes it to the dedicated <c>WriteAlarmQuality</c> node path (annotate quality only,
|
||||
@@ -913,14 +913,14 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
/// the <see cref="AreInputsReady"/> guard already handles by holding the condition. Counting it would
|
||||
/// flash every scripted condition Bad at deploy until the first push and flood the quality path with
|
||||
/// load-time annotations, so it is skipped (contributes Good). Empty / all-unpushed cache ⇒ Good (0).
|
||||
/// <para><b>"Never pushed" is a specific status code, not "the value is null" (#481, closing #478
|
||||
/// Finding 3).</b> This used to skip every entry whose value was null, which made a genuine runtime Bad
|
||||
/// <para><b>"Never pushed" is a specific status code, not "the value is null"
|
||||
/// (Finding 3).</b> This used to skip every entry whose value was null, which made a genuine runtime Bad
|
||||
/// indistinguishable from cold start — and that is exactly the shape a comms-lost driver produces, so
|
||||
/// the case the skip was hiding was the case that matters most. The upstream source now stamps a
|
||||
/// never-pushed path <see cref="StatusNeverPushed"/> and nothing else does, so the two are tellable
|
||||
/// apart. A null snapshot is still skipped: that is a cache the refill did not populate, not an
|
||||
/// observation.</para>
|
||||
/// <para><b>The comparison itself lives in <see cref="InputQuality.WorstOf"/></b> (archreview R3-02)
|
||||
/// <para><b>The comparison itself lives in <see cref="InputQuality.WorstOf"/></b>
|
||||
/// — the same rule the virtual-tag and Calculation seams now use, so "worst input wins, carrying its
|
||||
/// sub-code, first-seen on a severity tie" is stated once. What stays here is the alarm-specific
|
||||
/// <em>filter</em>: this engine's two skips are deliberately NOT part of the shared rule, because the
|
||||
@@ -952,7 +952,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
return severity >= 2 ? 2 : (int)severity;
|
||||
}
|
||||
|
||||
/// <summary>#478 — a canonical worst StatusCode for an alarm's last-observed quality bucket, used by
|
||||
/// <summary>A canonical worst StatusCode for an alarm's last-observed quality bucket, used by
|
||||
/// the operator-command + shelving-timer emission paths (which don't re-read inputs) so an ack /
|
||||
/// shelve while an input is Bad still carries Bad rather than resetting the condition to Good.</summary>
|
||||
private uint LastWorstStatus(string alarmId)
|
||||
@@ -1264,14 +1264,14 @@ public sealed record ScriptedAlarmEvent(
|
||||
DateTime TimestampUtc,
|
||||
string? Comment = null,
|
||||
bool HistorizeToAveva = true,
|
||||
// #478 — the worst OPC UA StatusCode across the alarm's input tags at evaluation time. A raw uint,
|
||||
// since Core.ScriptedAlarms does not reference Commons. Since #519 the host carries it to the condition
|
||||
// The worst OPC UA StatusCode across the alarm's input tags at evaluation time. A raw uint,
|
||||
// since Core.ScriptedAlarms does not reference Commons. The host carries it to the condition
|
||||
// node UNCHANGED — it used to be collapsed to a 3-state bucket and re-expanded, losing the sub-code.
|
||||
// Default 0u == Good keeps every existing constructor call unchanged.
|
||||
uint WorstInputStatusCode = 0u);
|
||||
|
||||
/// <summary>
|
||||
/// #487 — a loaded alarm's current condition, ready to be projected onto its OPC UA node.
|
||||
/// A loaded alarm's current condition, ready to be projected onto its OPC UA node.
|
||||
/// Produced by <see cref="ScriptedAlarmEngine.GetProjections"/> on demand; it is <b>not</b> an
|
||||
/// emission and never travels the <see cref="ScriptedAlarmEngine.OnEvent"/> path, so it can never
|
||||
/// become an <c>alerts</c> row or a historian record. Deliberately a separate type from
|
||||
|
||||
@@ -81,7 +81,7 @@ public sealed class ScriptedAlarmSource : IAlarmSource, IDisposable
|
||||
{
|
||||
if (_disposed) return;
|
||||
|
||||
// #478 — QualityChanged is a source-quality annotation, not a Part 9 state change. It is delivered
|
||||
// QualityChanged is a source-quality annotation, not a Part 9 state change. It is delivered
|
||||
// out of band via the dedicated WriteAlarmQuality node path; surfacing it here would fabricate a
|
||||
// phantom AlarmEventArgs that materializes / historizes a condition. Swallow it.
|
||||
if (evt.Emission == EmissionKind.QualityChanged) return;
|
||||
|
||||
@@ -81,7 +81,7 @@ public static class ScriptSandbox
|
||||
// surface (operator-authored hosting helpers, third-party packages, app code)
|
||||
// is not on the list and stays unreachable.
|
||||
var references = new List<MetadataReference>();
|
||||
// #603: both halves are cached. CreateFromFile reads + maps the DLL, and the BCL half is ~170
|
||||
// both halves are cached. CreateFromFile reads + maps the DLL, and the BCL half is ~170
|
||||
// assemblies, so doing it per compile was the dominant fixed cost of every ScriptEvaluator build.
|
||||
// MetadataReference is immutable and thread-safe, so sharing instances across compilations is safe.
|
||||
foreach (var asm in pinnedAssemblies)
|
||||
|
||||
@@ -2,7 +2,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// <c>Security:Acl</c> — the switch that turns per-node ACL evaluation into the authorization
|
||||
/// decision on the OPC UA data plane (#520, stage 5).
|
||||
/// decision on the OPC UA data plane.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -39,7 +39,7 @@ public sealed class AclOptions
|
||||
|
||||
/// <summary>
|
||||
/// When true, node ACL grants decide write, alarm-command and HistoryRead authorization. Default
|
||||
/// false, which preserves the pre-stage-5 role gates byte-for-byte.
|
||||
/// false, which preserves the previous role gates byte-for-byte.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
@@ -56,7 +56,7 @@ public sealed class AclOptions
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <c>0</c> or negative disables the timer, leaving revalidation deploy-triggered only (the
|
||||
/// pre-R3-05 behaviour). The timer is <b>inert while <see cref="Enabled"/> is false</b>: the
|
||||
/// previous behaviour). The timer is <b>inert while <see cref="Enabled"/> is false</b>: the
|
||||
/// pass early-returns when ACL enforcement is off.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
|
||||
@@ -16,7 +16,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
/// wrong-node authorization rather than a visible error.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The string-identity rule for the whole authorization path (Gitea #533), stated here because
|
||||
/// <b>The string-identity rule for the whole authorization path, stated here because
|
||||
/// this type is where it was first violated:</b>
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
@@ -26,7 +26,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
/// mean the same entity, and the sources genuinely differ in case (a <c>ClusterId</c> arrives
|
||||
/// lower-cased through <c>OTOPCUA_ROLES</c> and verbatim through <c>Cluster:Roles</c> — see
|
||||
/// <c>RoleParser.ClusterIdFromRole</c> — and a <c>ScopeId</c> is operator free-text in the ACL
|
||||
/// editor until the stage-7 picker lands).
|
||||
/// editor until the scope picker lands).
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <b>Path segments compare <c>Ordinal</c></b> — every level of <see cref="NodeAclPath"/>,
|
||||
@@ -36,7 +36,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
/// </item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// Before #533 this record struct used the compiler's default equality, i.e. ordinal, while the
|
||||
/// Previously this record struct used the compiler's default equality, i.e. ordinal, while the
|
||||
/// trie builder filtered <c>ClusterId</c> and hashed principals <c>OrdinalIgnoreCase</c> — three
|
||||
/// rules in one path. It worked only because the resolver and the builder were handed the same
|
||||
/// <c>NodeAcl</c> instances, so the <c>ScopeId</c> strings were literally the same object. Feed
|
||||
@@ -49,9 +49,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
/// <param name="ScopeId">The authored scope id.</param>
|
||||
public readonly record struct AclScopeRef(NodeAclScopeKind Kind, string ScopeId)
|
||||
{
|
||||
/// <summary>Equality over the kind plus a case-insensitive id — see the rule on the type.</summary>
|
||||
/// <param name="other">The other reference.</param>
|
||||
/// <returns><see langword="true"/> when both name the same scope.</returns>
|
||||
/// <inheritdoc />
|
||||
public bool Equals(AclScopeRef other) =>
|
||||
Kind == other.Kind && string.Equals(ScopeId, other.ScopeId, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
@@ -62,7 +60,7 @@ public readonly record struct AclScopeRef(NodeAclScopeKind Kind, string ScopeId)
|
||||
|
||||
/// <summary>
|
||||
/// Produces the <c>scopePaths</c> lookup <see cref="PermissionTrieBuilder.Build"/> consumes: for each
|
||||
/// ACL scope, the trie path(s) at which a grant on it must be planted (#520 stage 3).
|
||||
/// ACL scope, the trie path(s) at which a grant on it must be planted.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -131,7 +129,7 @@ public sealed class AclScopePathResolver
|
||||
ArgumentNullException.ThrowIfNull(references);
|
||||
|
||||
// Every map is re-keyed OrdinalIgnoreCase rather than used as handed in. Ids are identifiers, so
|
||||
// they follow the identifier half of the rule on AclScopeRef (Gitea #533) — and doing it HERE,
|
||||
// they follow the identifier half of the rule on AclScopeRef — and doing it HERE,
|
||||
// rather than documenting a comparer contract for callers, is what makes the rule impossible to
|
||||
// get wrong from the outside: the two producers (EF entities at the deploy gate, artifact JSON on
|
||||
// the node) build these dictionaries independently and neither can now diverge from the other.
|
||||
@@ -224,7 +222,7 @@ public sealed class AclScopePathResolver
|
||||
// Cluster never reaches here (the caller short-circuits it), and FolderSegment is the retired
|
||||
// v2 kind with no v3 entity behind it — deliberately unresolvable. The trie builder now
|
||||
// short-circuits FolderSegment too, so it reports RetiredScopeKind rather than the
|
||||
// indistinguishable-from-a-typo UnresolvedScope (Gitea #533); this arm stays as the answer
|
||||
// indistinguishable-from-a-typo UnresolvedScope; this arm stays as the answer
|
||||
// for a direct ResolveOne caller.
|
||||
case NodeAclScopeKind.Cluster:
|
||||
case NodeAclScopeKind.FolderSegment:
|
||||
|
||||
@@ -4,7 +4,7 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
|
||||
/// <summary>Create / list / revoke OPC UA data-plane API keys (#520).</summary>
|
||||
/// <summary>Create / list / revoke OPC UA data-plane API keys.</summary>
|
||||
/// <remarks>
|
||||
/// A service rather than logic in the Razor page, because this repo has no bUnit — anything only a
|
||||
/// page can reach is untestable, and minting credentials is not where that belongs.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// <c>Security:ApiKeys</c> — OPC UA data-plane API key settings (#520). The data plane is
|
||||
/// <c>Security:ApiKeys</c> — OPC UA data-plane API key settings. The data plane is
|
||||
/// machine-to-machine, so a consuming system presents a key rather than an LDAP user.
|
||||
/// </summary>
|
||||
public sealed class ApiKeyOptions
|
||||
@@ -34,7 +34,7 @@ public sealed class ApiKeyOptions
|
||||
/// distinct Warning plus <c>otopcua.opcua.apikey_store.bounded{outcome=timeout}</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <c>0</c> or negative disables the deadline, restoring the pre-R3-05 unbounded behaviour. Do not
|
||||
/// <c>0</c> or negative disables the deadline, restoring the previous unbounded behaviour. Do not
|
||||
/// do that on a node with a ConfigDb: the SDK raises impersonation synchronously under a
|
||||
/// <c>SessionManager</c>-wide lock, so an unanswered lookup stalls <b>every</b> session activation
|
||||
/// in the server, not just its own.
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// Pure helpers for the OPC UA data-plane API key format <c>otua_<keyId>_<secret></c>
|
||||
/// (#520): generation, parsing, and constant-time verification. No I/O and no DI, so the
|
||||
/// — generation, parsing, and constant-time verification. No I/O and no DI, so the
|
||||
/// security-critical half is unit-testable on its own.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
/// <summary>
|
||||
/// The data-plane authorization inputs a node is currently serving: the API keys that may
|
||||
/// authenticate, the ACL grants naming their principals, the cluster the node belongs to, and the
|
||||
/// generation those were resolved at (#520, stage 4).
|
||||
/// generation those were resolved at.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -215,7 +215,6 @@ public sealed class EmptyDataPlaneAuthoritySource : IDataPlaneAuthoritySource
|
||||
public DataPlaneAuthoritySnapshot Current => DataPlaneAuthoritySnapshot.Empty;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>Never raised — this source is permanently empty, so nothing derived from it can go stale.</remarks>
|
||||
public event Action<DataPlaneAuthoritySnapshot>? Changed
|
||||
{
|
||||
add { }
|
||||
@@ -252,7 +251,7 @@ public interface IDataPlaneAuthoritySink
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>It owns the <see cref="PermissionTrieCache"/> rather than being handed one</b> (#520 stage 5).
|
||||
/// <b>It owns the <see cref="PermissionTrieCache"/> rather than being handed one</b>.
|
||||
/// The trie is built from the same grants and the same generation as the snapshot, inside the same
|
||||
/// <see cref="Apply"/> call, so the two cannot describe different deployments. Injecting the cache
|
||||
/// would have made "install the snapshot" and "install the trie" two wirings, and this repo has
|
||||
@@ -282,7 +281,7 @@ public sealed class DataPlaneAuthorityState : IDataPlaneAuthoritySource, IDataPl
|
||||
/// <inheritdoc />
|
||||
public DataPlaneAuthoritySnapshot Current => Volatile.Read(ref _current);
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Raised after <see cref="Apply"/> publishes a new snapshot.</summary>
|
||||
public event Action<DataPlaneAuthoritySnapshot>? Changed;
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The hierarchy is <c>Cluster → Realm → …node path…</c>. The node path is <b>variable length</b>,
|
||||
/// which is the whole reason this type is no longer a fixed set of level properties (#520 stage 3):
|
||||
/// which is the whole reason this type is no longer a fixed set of level properties:
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item>
|
||||
@@ -57,7 +57,7 @@ public enum NodeHierarchyKind
|
||||
/// <summary><c>Area / Line / Equipment [/ EffectiveName]</c> — the UNS realm.</summary>
|
||||
Uns,
|
||||
|
||||
/// <summary><c>folder… / Driver / Device / group… / Tag</c> — the raw device-tree realm. (Appended, #520 stage 3.)</summary>
|
||||
/// <summary><c>folder… / Driver / Device / group… / Tag</c> — the raw device-tree realm. (Appended.)</summary>
|
||||
Raw,
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ public enum NodeHierarchyKind
|
||||
/// <para>
|
||||
/// This exists because v3 <b>has no <c>Namespace</c> entity</b>. The snapshot was retired when the
|
||||
/// address space went dual-namespace ("the two OPC UA namespaces are implicit"), which left the
|
||||
/// trie's level-1 gate with no configured key source at all — the pre-stage-3 walker read a
|
||||
/// trie's level-1 gate with no configured key source at all — the earlier walker read a
|
||||
/// <c>NamespaceId</c> that nothing in the system ever produced, so the level could only ever fail
|
||||
/// to match. Deriving the segment from the realm restores the level as something real without
|
||||
/// reintroducing an entity.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
|
||||
/// <summary>Reserved principal names for the OPC UA data plane (#520).</summary>
|
||||
/// <summary>Reserved principal names for the OPC UA data plane.</summary>
|
||||
/// <remarks>
|
||||
/// Lives in Core rather than beside the authenticator because BOTH ends need it: the OPC UA server
|
||||
/// resolves an anonymous session to it, and the AdminUI authoring path must refuse to mint a key
|
||||
|
||||
@@ -151,7 +151,7 @@ public sealed class PermissionTrieNode
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ The comparer is <b>case-sensitive ordinal</b>, matching <c>RawPaths.Comparer</c> and OPC UA
|
||||
/// NodeId identity. It was <c>OrdinalIgnoreCase</c> before #520 stage 3, which was a silent
|
||||
/// NodeId identity. It was <c>OrdinalIgnoreCase</c> in an earlier version, which was a silent
|
||||
/// widening: raw sibling names are unique under an ordinal comparer, so <c>Speed</c> and
|
||||
/// <c>speed</c> can both exist under one device, and a grant on either would have matched both.
|
||||
/// Case-sensitive matching is also the fail-closed direction — a case-drifted ACL row now grants
|
||||
@@ -167,7 +167,7 @@ public sealed class PermissionTrieNode
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Compared <c>OrdinalIgnoreCase</c>: this holds principal names, which are identifiers, and the
|
||||
/// repo's rule (#533) is that identifiers compare case-insensitively while <em>path segments</em>
|
||||
/// repo's rule is that identifiers compare case-insensitively while <em>path segments</em>
|
||||
/// — the <see cref="Children"/> keys — compare ordinally.
|
||||
/// </remarks>
|
||||
public HashSet<string> BrowseTransitPrincipals { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
@@ -14,7 +14,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
/// life of the cache entry. Idempotent — two builds from the same rows produce equal tries.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ⚠️ <b>There is no longer a "deterministic test mode".</b> Before #520 stage 3 a null
|
||||
/// ⚠️ <b>There is no longer a "deterministic test mode".</b> Previously a null
|
||||
/// <c>scopePaths</c> made sub-cluster rows fall back to a direct child of the trie
|
||||
/// root keyed on <c>ScopeId</c>. That position is unreachable by construction — level 1 of the walk
|
||||
/// is the realm — so the fallback produced grants that looked placed and could never match, and
|
||||
@@ -183,7 +183,7 @@ public enum PermissionTrieBuildDiagnosticReason
|
||||
/// <summary>
|
||||
/// The row's <see cref="NodeAclScopeKind"/> is retired and has no v3 entity behind it
|
||||
/// (<see cref="NodeAclScopeKind.FolderSegment"/>), so it can never resolve whatever its
|
||||
/// <c>ScopeId</c>. Split out of <see cref="UnresolvedScope"/> by Gitea #533: the two have
|
||||
/// <c>ScopeId</c>. Split out of <see cref="UnresolvedScope"/> deliberately: the two have
|
||||
/// different remedies — re-scope the grant to <see cref="NodeAclScopeKind.RawFolder"/> versus
|
||||
/// fix an id — and conflating them told an operator with a legacy row to go looking for a typo.
|
||||
/// </summary>
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ <b>The refresh machinery below is LDAP-shaped and is largely vestigial under the
|
||||
/// API-key model (#520).</b> It exists because an LDAP group membership can change
|
||||
/// API-key model.</b> It exists because an LDAP group membership can change
|
||||
/// out-of-band in the directory, so a long-lived session had to re-resolve periodically.
|
||||
/// An API-key principal set cannot change out-of-band: keys and ACL rows both ride the
|
||||
/// deployment artifact (driver-only nodes have no ConfigDb since Phase 4), so the only
|
||||
|
||||
@@ -141,7 +141,7 @@ public static class EquipmentNodeWalker
|
||||
/// <summary>
|
||||
/// Pre-loaded + pre-filtered snapshot of one cluster's UNS content (Area / Line / Equipment +
|
||||
/// per-equipment VirtualTags / ScriptedAlarms). v3: raw-tag references (<c>UnsTagReference</c>)
|
||||
/// are materialized by the Batch-4 dual-namespace fan-out, not by this walker, so they are not
|
||||
/// are materialized by the dual-namespace fan-out, not by this walker, so they are not
|
||||
/// part of this snapshot.
|
||||
/// </summary>
|
||||
public sealed record EquipmentNamespaceContent(
|
||||
|
||||
@@ -30,7 +30,7 @@ public static class IdentificationFolderBuilder
|
||||
public const string FolderName = "Identification";
|
||||
|
||||
/// <summary>
|
||||
/// Canonical decision #139 field set exposed in the Identification sub-folder. Order
|
||||
/// Canonical field set exposed in the Identification sub-folder. Order
|
||||
/// matches the decision-log entry so any browse-order reader can cross-reference
|
||||
/// without re-sorting.
|
||||
/// </summary>
|
||||
|
||||
@@ -132,7 +132,7 @@ public sealed class CapabilityInvoker : IDriverCapabilityInvoker
|
||||
{
|
||||
// Build the no-retry options snapshot ONCE per invoker lifetime (01/P-4). The options are
|
||||
// immutable for the invoker's lifetime — a ResilienceConfig change respawns the driver child,
|
||||
// which constructs a fresh invoker (#13 respawn-on-change) — so lazily caching the snapshot is
|
||||
// which constructs a fresh invoker (respawn-on-change) — so lazily caching the snapshot is
|
||||
// safe and removes the per-write allocation the previous once-per-call snapshot incurred. The
|
||||
// null-check-assign tolerates a benign first-call race (both threads build an equivalent record).
|
||||
var noRetryOptions = _noRetryWriteOptions ??= BuildNoRetryWriteOptions();
|
||||
|
||||
@@ -81,7 +81,7 @@ public sealed class DriverCapabilityInvokerFactory : IDriverCapabilityInvokerFac
|
||||
// too — a retained "Breaker OPEN" from a pre-respawn outage could never self-correct (only the
|
||||
// discarded pipeline's OnClosed stamps the close) and would show a false OPEN chip forever.
|
||||
// Reinstate first: an instance id removed by an earlier deployment (Release ⇒ Retire) and now re-added
|
||||
// must record again (#609).
|
||||
// must record again.
|
||||
_statusTracker.Reinstate(driverInstanceId);
|
||||
_statusTracker.Invalidate(driverInstanceId);
|
||||
|
||||
@@ -94,17 +94,15 @@ public sealed class DriverCapabilityInvokerFactory : IDriverCapabilityInvokerFac
|
||||
optionsGeneration: generation);
|
||||
}
|
||||
|
||||
// Without this the tracker kept a removed instance's snapshots forever (only Create
|
||||
// invalidated, and a removed instance is never re-created), and the resilience publisher re-emitted
|
||||
// them every tick — undoing the host's telemetry-hub eviction within one interval.
|
||||
// The tracker retires the instance rather than merely invalidating it: the host stops the
|
||||
// child asynchronously, so a capability call it still had in flight completes through the released
|
||||
// invoker afterwards, and its call-start/success/complete writes re-created the entry (observed: the
|
||||
// child's in-flight SubscribeBulk). A retired id ignores those writes until a later Create
|
||||
// for the same id reinstates it.
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// #609: without this the tracker kept a removed instance's snapshots forever (only <see cref="Create"/>
|
||||
/// invalidated, and a removed instance is never re-created), and the resilience publisher re-emitted
|
||||
/// them every tick — undoing the host's telemetry-hub eviction within one interval.
|
||||
/// <para>The tracker <b>retires</b> the instance rather than merely invalidating it: the host stops the
|
||||
/// child asynchronously, so a capability call it still had in flight completes through the released
|
||||
/// invoker afterwards, and its call-start/success/complete writes re-created the entry (observed: the
|
||||
/// child's in-flight SubscribeBulk). A retired id ignores those writes until a later <see cref="Create"/>
|
||||
/// for the same id reinstates it.</para>
|
||||
/// </remarks>
|
||||
public void Release(string driverInstanceId)
|
||||
{
|
||||
_builder.Invalidate(driverInstanceId);
|
||||
|
||||
@@ -19,7 +19,7 @@ public sealed record DriverResilienceOptions
|
||||
public IReadOnlyDictionary<DriverCapability, CapabilityPolicy> CapabilityPolicies { get; init; }
|
||||
= new Dictionary<DriverCapability, CapabilityPolicy>();
|
||||
|
||||
// RecycleIntervalSeconds is GONE (Gitea #522). It configured a scheduled recycle of a Tier C
|
||||
// RecycleIntervalSeconds is GONE. It configured a scheduled recycle of a Tier C
|
||||
// driver's out-of-process Host — a process that no longer exists anywhere: Galaxy reaches MXAccess
|
||||
// over gRPC to the external mxaccessgw sidecar (PR 7.2 retired the in-process Host/Proxy/Shared
|
||||
// projects) and FOCAS has run in-process since its managed wire client landed 2026-04-24. The
|
||||
@@ -64,7 +64,7 @@ public sealed record DriverResilienceOptions
|
||||
/// Per-tier per-capability default policy table, per the Phase 6.1
|
||||
/// Stream A.2 specification. The parser enforces no-retry on
|
||||
/// <see cref="DriverCapability.Write"/> and <see cref="DriverCapability.AlarmAcknowledge"/>
|
||||
/// as an invariant (R2-02/S-8) — a JSON retryCount override on those capabilities is forced back
|
||||
/// as an invariant — a JSON retryCount override on those capabilities is forced back
|
||||
/// to 0; per-tag opt-in via <see cref="WriteIdempotentAttribute"/> is the future relaxation.
|
||||
/// </summary>
|
||||
/// <param name="tier">The driver tier to get defaults for.</param>
|
||||
@@ -84,7 +84,7 @@ public sealed record DriverResilienceOptions
|
||||
// Initialize: no retry (the DriverInstanceActor reconnect timer is the retry loop) and a
|
||||
// threshold of 2, NOT the Read-sized 5 — the breaker samples over 30 s and the reconnect
|
||||
// timer fires every 10 s, so at most three init attempts fit one window; a threshold of 5
|
||||
// could never open on connect failures (R2-10 T10).
|
||||
// could never open on connect failures.
|
||||
[DriverCapability.Initialize] = new(TimeoutSeconds: 5, RetryCount: 0, BreakerFailureThreshold: 2),
|
||||
},
|
||||
DriverTier.B => new Dictionary<DriverCapability, CapabilityPolicy>
|
||||
|
||||
@@ -94,7 +94,7 @@ public static class DriverResilienceOptionsParser
|
||||
}
|
||||
}
|
||||
|
||||
// No recycle-interval handling: the scheduled-recycle machinery was deleted in Gitea #522
|
||||
// No recycle-interval handling: the scheduled-recycle machinery was deleted
|
||||
// (nothing constructed it, and IDriverSupervisor — which would have performed the recycle —
|
||||
// had no implementations). A "recycleIntervalSeconds" key surviving in an existing
|
||||
// ResilienceConfig blob is harmless: the shape no longer declares it, and unknown JSON
|
||||
|
||||
@@ -16,13 +16,13 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Resilience;
|
||||
/// Pure in-memory (no EF Core / SQL Server dependency) so tests can exercise it directly.
|
||||
/// The operator-facing reader is the DPS publisher named above, mirroring the driver-health
|
||||
/// flow — not the DB-table/HostedService shape originally sketched for Phase 6.1 Stream E
|
||||
/// (R2-10 built the DPS reader instead).
|
||||
/// (the DPS reader was built instead).
|
||||
/// </remarks>
|
||||
public sealed class DriverResilienceStatusTracker
|
||||
{
|
||||
private readonly ConcurrentDictionary<StatusKey, ResilienceStatusSnapshot> _status = new();
|
||||
|
||||
// Instances retired by Retire (removed from the deployed plan) and not since Reinstated (#609). Every
|
||||
// Instances retired by Retire (removed from the deployed plan) and not since Reinstated. Every
|
||||
// Record* for one of these is ignored: the removed instance's child stops ASYNCHRONOUSLY, so a capability
|
||||
// call it still had in flight completes through its invoker after the host's release and would otherwise
|
||||
// re-create the entry — which the resilience publisher then re-emits every tick, forever. One entry per
|
||||
@@ -127,7 +127,7 @@ public sealed class DriverResilienceStatusTracker
|
||||
|
||||
/// <summary>
|
||||
/// Drop every snapshot for a driver instance that has been REMOVED from the deployed plan, and ignore
|
||||
/// every later write for it until <see cref="Reinstate"/> (#609). Called by
|
||||
/// every later write for it until <see cref="Reinstate"/>. Called by
|
||||
/// <c>DriverCapabilityInvokerFactory.Release</c>. Unlike <see cref="Invalidate"/> (a respawn, whose new
|
||||
/// invoker records straight away), the removed instance's child is still stopping — a call it had in
|
||||
/// flight finishes after this returns, and without the hold its RecordCallStart / RecordSuccess /
|
||||
@@ -261,7 +261,7 @@ public sealed record ResilienceStatusSnapshot
|
||||
/// <c>driver-resilience-status</c> telemetry channel.
|
||||
/// <para>It used to say this fed <c>DriverInstanceResilienceStatus.CurrentBulkheadDepth</c> "until
|
||||
/// the full telemetry observer lands". The telemetry observer landed (Phase 5); that table never
|
||||
/// had a writer and was dropped in Gitea #524.</para>
|
||||
/// had a writer and was since dropped.</para>
|
||||
/// </summary>
|
||||
public int CurrentInFlight { get; init; }
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ public sealed class ProbeCommand : S7CommandBase
|
||||
|
||||
// Derive the headline verdict from BOTH the driver state AND the probe-read
|
||||
// StatusCode so the operator never sees `Health: Healthy` over a Bad snapshot
|
||||
// line (Driver.S7.Cli-012; mirrors Modbus.Cli's ComputeVerdict).
|
||||
// line (mirrors Modbus.Cli's ComputeVerdict).
|
||||
var verdict = ComputeVerdict(health.State, snapshot[0].StatusCode);
|
||||
|
||||
await console.Output.WriteLineAsync($"Host: {Host}:{Port}");
|
||||
|
||||
@@ -25,7 +25,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.LibplctagShared;
|
||||
/// release cannot run; the tag is then rooted for the life of the process instead, which keeps its delegate
|
||||
/// alive. That is no crash but an UNBOUNDED leak — one native tag (holding its libplctag session open) per failed
|
||||
/// create, and the data path re-creates a NotFound tag on every read — so <c>LibplctagOrphanReleaseTests</c> / <c>LibplctagLegacyOrphanReleaseTests</c> fail, so the bump is noticed.</para>
|
||||
/// <para>SHARED SOURCE (#580): this directory has no project. <c>Driver.AbCip</c> and <c>Driver.AbLegacy</c> each
|
||||
/// <para>SHARED SOURCE: this directory has no project. <c>Driver.AbCip</c> and <c>Driver.AbLegacy</c> each
|
||||
/// compile this file in (<c><Compile Include></c>), so each assembly has its own internal copy and its own
|
||||
/// counters. It must keep a <c>Libplctag*.cs</c> name — <c>LibplctagConfinementGuardTests</c> allows libplctag
|
||||
/// references only in such files.</para>
|
||||
@@ -35,7 +35,7 @@ internal static class LibplctagOrphanRelease
|
||||
private const BindingFlags Private = BindingFlags.NonPublic | BindingFlags.Instance;
|
||||
|
||||
private static readonly FieldInfo? WrapperField = typeof(Tag).GetField("_tag", Private);
|
||||
// Final review M2: the field must also still be an int. A wrapper that retyped it (IntPtr, long) would make
|
||||
// The field must also still be an int. A wrapper that retyped it (IntPtr, long) would make
|
||||
// the cast in HandleOf throw; treating it as unreadable instead degrades to the rooting fallback ("keep
|
||||
// alive"), which the pin test reports.
|
||||
private static readonly FieldInfo? HandleField =
|
||||
@@ -57,6 +57,8 @@ internal static class LibplctagOrphanRelease
|
||||
|
||||
/// <summary>The native handle the wrapper holds (0 when none was ever created, or unreadable). Never throws:
|
||||
/// callers run it inside a catch block, where an exception would replace the failure being handled.</summary>
|
||||
/// <param name="tag">The libplctag wrapper whose handle is read.</param>
|
||||
/// <returns>The native handle, or 0 when none exists or it cannot be read.</returns>
|
||||
internal static int HandleOf(Tag tag)
|
||||
{
|
||||
try
|
||||
@@ -70,7 +72,7 @@ internal static class LibplctagOrphanRelease
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Clears the wrapper's handle after a destroy, so a repeat release is a no-op (review r1 M2).</summary>
|
||||
/// <summary>Clears the wrapper's handle after a destroy, so a repeat release is a no-op.</summary>
|
||||
private static void ClearHandle(Tag tag)
|
||||
{
|
||||
if (HandleField is not null && WrapperField!.GetValue(tag) is { } wrapper) HandleField.SetValue(wrapper, 0);
|
||||
@@ -81,6 +83,9 @@ internal static class LibplctagOrphanRelease
|
||||
/// Read/Write, which initialise on demand). If it fails and leaves the tag uninitialised, the native tag it
|
||||
/// created is unregistered and destroyed before the failure propagates.
|
||||
/// </summary>
|
||||
/// <param name="tag">The tag the operation may initialise.</param>
|
||||
/// <param name="op">The libplctag operation to run.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
internal static async Task RunAsync(Tag tag, Func<Task> op)
|
||||
{
|
||||
try
|
||||
@@ -112,15 +117,19 @@ internal static class LibplctagOrphanRelease
|
||||
/// Destroys the native tag behind an UNINITIALISED wrapper, unregistering its callback first. No-op for an
|
||||
/// initialised tag (its own <c>Dispose</c> handles it) or one that never got a handle. Never throws.
|
||||
/// </summary>
|
||||
/// <param name="tag">The libplctag wrapper to release when orphaned.</param>
|
||||
/// <returns>The status the native tag held just before it was destroyed (what <c>GetStatus()</c> would have
|
||||
/// reported), or null when nothing was destroyed.</returns>
|
||||
internal static Status? ReleaseIfOrphaned(Tag tag) => ReleaseIfOrphaned(tag, CanReadHandles);
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="ReleaseIfOrphaned(Tag)"/> with the handle-readability decision passed in, so a test can take the
|
||||
/// rooting fallback (review r1 M1) without a process-wide switch (Gitea #581). Production always passes
|
||||
/// rooting fallback without a process-wide switch. Production always passes
|
||||
/// <see cref="CanReadHandles"/>.
|
||||
/// </summary>
|
||||
/// <param name="tag">The libplctag wrapper to release when orphaned.</param>
|
||||
/// <param name="canReadHandles">Whether the wrapper's native handle can be read; when false the tag is rooted instead.</param>
|
||||
/// <returns>The status the native tag held just before it was destroyed, or null when nothing was destroyed.</returns>
|
||||
internal static Status? ReleaseIfOrphaned(Tag tag, bool canReadHandles)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -2,7 +2,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
|
||||
/// <summary>
|
||||
/// The single <see cref="AbStatus"/> to OPC UA StatusCode table shared by <c>AbCipStatusMapper</c> and
|
||||
/// <c>AbLegacyStatusMapper</c> (#580) — the two drivers used to carry identical copies.
|
||||
/// <c>AbLegacyStatusMapper</c> — the two drivers used to carry identical copies.
|
||||
/// </summary>
|
||||
public static class AbOpcUaStatusMap
|
||||
{
|
||||
|
||||
@@ -2,6 +2,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
|
||||
public static class AbStatusCodes
|
||||
{
|
||||
/// <summary>Maps a CIP general status (and optional extended status words) to an <see cref="AbStatus"/>.</summary>
|
||||
/// <param name="general">The CIP general status byte.</param>
|
||||
/// <param name="extended">The extended status words, if any.</param>
|
||||
/// <returns>The mapped status.</returns>
|
||||
public static AbStatus FromCip(byte general, ReadOnlySpan<ushort> extended) => general switch
|
||||
{
|
||||
0x00 or 0x0B => AbStatus.Ok,
|
||||
@@ -18,6 +22,10 @@ public static class AbStatusCodes
|
||||
_ => AbStatus.BadReply,
|
||||
};
|
||||
|
||||
/// <summary>Maps a PCCC status byte (and optional extended status) to an <see cref="AbStatus"/>.</summary>
|
||||
/// <param name="sts">The PCCC STS byte.</param>
|
||||
/// <param name="extSts">The extended status byte, when present.</param>
|
||||
/// <returns>The mapped status.</returns>
|
||||
public static AbStatus FromPccc(byte sts, byte? extSts) => sts switch
|
||||
{
|
||||
0x00 => AbStatus.Ok,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
|
||||
/// <summary>Thrown by both AB transports. <see cref="Status"/> drives OPC UA status mapping and the
|
||||
/// driver's "controller answered, don't throttle the device" classification (05/STAB-8).</summary>
|
||||
/// driver's "controller answered, don't throttle the device" classification.</summary>
|
||||
public class AbTransportException(AbStatus status, string message, Exception? inner = null)
|
||||
: Exception(message, inner)
|
||||
{
|
||||
/// <summary>The AB status that classifies this failure.</summary>
|
||||
public AbStatus Status { get; } = status;
|
||||
}
|
||||
|
||||
@@ -12,9 +12,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
||||
/// only by the <i>next</i> send; the failing call throws its own exception. Caller cancellation never
|
||||
/// faults the session (B2), so it never causes a reconnect. A connected reply saying the target no longer
|
||||
/// holds the class-3 connection (general 0x01, extended 0x0107 / 0x0203 — the session itself still up) retires
|
||||
/// the pair at once, whether a request or a keep-alive got it, so the next send reopens it (Gitea #566).</para>
|
||||
/// the pair at once, whether a request or a keep-alive got it, so the next send reopens it.</para>
|
||||
/// <para><b>Bounded.</b> Every <see cref="SendAsync"/> is bounded even if the caller passes no token
|
||||
/// (ruling R9-3): opening (including the throttle wait and any wait behind another opener) is bounded by
|
||||
/// — opening (including the throttle wait and any wait behind another opener) is bounded by
|
||||
/// <c>MinForwardOpenInterval + 4 × Timeout</c> (connect, RegisterSession, Forward Open, fallback Forward
|
||||
/// Open), and the exchange by <c>2 × Timeout</c> (waiting behind one in-flight exchange, then its own).
|
||||
/// Exceeding either surfaces as <see cref="AbTransportException"/>(<see cref="AbStatus.Timeout"/>).</para>
|
||||
@@ -52,6 +52,7 @@ public sealed class CipConnection : ICipChannel, IAsyncDisposable
|
||||
private int _keepAlivesSent;
|
||||
private int _connectionSerial = Random.Shared.Next(1, ushort.MaxValue);
|
||||
|
||||
/// <summary>Creates a connection for one device; nothing is opened until the first send.</summary>
|
||||
/// <param name="key">The device; <see cref="DeviceKey.CipPath"/> is the route (ignored for Direct).</param>
|
||||
/// <param name="options">Routing, sizes, timeouts and keep-alive settings.</param>
|
||||
/// <param name="throttle">The device's process-lifetime Forward Open throttle.</param>
|
||||
@@ -91,6 +92,7 @@ public sealed class CipConnection : ICipChannel, IAsyncDisposable
|
||||
private TimeSpan OpenBudget => _o.MinForwardOpenInterval + 4 * _o.Timeout;
|
||||
private TimeSpan ExchangeBudget => 2 * _o.Timeout;
|
||||
|
||||
/// <inheritdoc />
|
||||
public int MaxRequestBytes => _o.Routing switch
|
||||
{
|
||||
CipRouting.Connected => (Volatile.Read(ref _open)?.NegotiatedSize ?? Math.Min(_o.ConnectionSize, UnconnectedMessageLimit)) - 2,
|
||||
@@ -98,6 +100,7 @@ public sealed class CipConnection : ICipChannel, IAsyncDisposable
|
||||
_ => UnconnectedMessageLimit,
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ReadOnlyMemory<byte>> SendAsync(ReadOnlyMemory<byte> cipRequest, CancellationToken cancellationToken)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
@@ -140,7 +143,7 @@ public sealed class CipConnection : ICipChannel, IAsyncDisposable
|
||||
return pending.WaitAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>The shared open. Bounded by <see cref="OpenBudget"/> (ruling R9-3); its outcome, success or
|
||||
/// <summary>The shared open. Bounded by <see cref="OpenBudget"/>; its outcome, success or
|
||||
/// failure, goes to every caller waiting on it.</summary>
|
||||
private async Task RunOpenAsync(TaskCompletionSource<Open> result)
|
||||
{
|
||||
@@ -222,7 +225,7 @@ public sealed class CipConnection : ICipChannel, IAsyncDisposable
|
||||
var frame = ConnectionManagerServices.ForwardOpen(p);
|
||||
_diagnostics.CountForwardOpen();
|
||||
var raw = await client.SendRRDataAsync(frame, _o.Timeout, ct).ConfigureAwait(false);
|
||||
_diagnostics.NotifyExchange(frame, raw); // evidence tap: the granted RPIs / connection ids (#566 gate)
|
||||
_diagnostics.NotifyExchange(frame, raw); // evidence tap: the granted RPIs / connection ids
|
||||
return CipReply.Parse(raw);
|
||||
}
|
||||
|
||||
@@ -270,7 +273,7 @@ public sealed class CipConnection : ICipChannel, IAsyncDisposable
|
||||
/// The target answered over a session that is still up, but says the class-3 connection itself is gone (it
|
||||
/// timed the connection out, or was restarted / re-downloaded and forgot it). Every later connected request
|
||||
/// would get the same answer while <see cref="EncapsulationClient.IsConnected"/> stays true, so nothing else
|
||||
/// would ever reopen it (Gitea #566): retire the pair now, so the next send reopens through the throttle.
|
||||
/// would ever reopen it: retire the pair now, so the next send reopens through the throttle.
|
||||
/// No Forward Close — the target has just said there is nothing to close.
|
||||
/// </summary>
|
||||
private async Task RetireClosedByTargetAsync(Open open)
|
||||
@@ -333,6 +336,7 @@ public sealed class CipConnection : ICipChannel, IAsyncDisposable
|
||||
|
||||
/// <summary>Stops the keep-alive, sends Forward Close (Connected only) and closes the session, whose
|
||||
/// own dispose sends UnRegisterSession. Later sends throw <see cref="AbStatus.Abort"/>.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposeStarted, 1) != 0) return;
|
||||
|
||||
@@ -7,6 +7,8 @@ public static class CipPath
|
||||
{
|
||||
/// <summary>Route path from "port,link[,port,link…]". Links that are not a byte value are IP
|
||||
/// addresses, encoded as extended link-address port segments.</summary>
|
||||
/// <param name="cipPath">The route as comma-separated port,link pairs; blank yields an empty route.</param>
|
||||
/// <returns>The encoded route path segments.</returns>
|
||||
public static byte[] EncodeRoute(string cipPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(cipPath)) return [];
|
||||
@@ -47,6 +49,8 @@ public static class CipPath
|
||||
}
|
||||
|
||||
/// <summary>Splits a trailing purely numeric member ("Tag.5") into the tag name and bit index.</summary>
|
||||
/// <param name="tagName">The tag name, optionally ending in a numeric bit selector.</param>
|
||||
/// <returns>The tag name without the selector and the bit index, or a null bit when there is no selector.</returns>
|
||||
public static (string Name, int? Bit) SplitBitSelector(string tagName)
|
||||
{
|
||||
var dot = tagName.LastIndexOf('.');
|
||||
@@ -59,6 +63,9 @@ public static class CipPath
|
||||
return (tagName, null);
|
||||
}
|
||||
|
||||
/// <summary>Encodes a symbolic tag name (members and subscripts) as ANSI extended symbol segments.</summary>
|
||||
/// <param name="tagName">The tag name, without any bit selector.</param>
|
||||
/// <returns>The encoded symbolic path segments.</returns>
|
||||
/// <exception cref="ArgumentException">The name is empty, has an empty member, an over-long (> 255) or
|
||||
/// non-ASCII member, an unterminated / non-numeric subscript, or ends in a bit selector.</exception>
|
||||
public static byte[] EncodeSymbolic(string tagName)
|
||||
@@ -118,6 +125,10 @@ public static class CipPath
|
||||
yield return name[start..];
|
||||
}
|
||||
|
||||
/// <summary>Encodes a logical class and instance path (8- or 16-bit segments as the values require).</summary>
|
||||
/// <param name="cls">The CIP class ID.</param>
|
||||
/// <param name="instance">The CIP instance ID.</param>
|
||||
/// <returns>The encoded class/instance path segments.</returns>
|
||||
public static byte[] EncodeClassInstance(ushort cls, uint instance)
|
||||
{
|
||||
var w = new CipWriter();
|
||||
@@ -129,6 +140,8 @@ public static class CipPath
|
||||
}
|
||||
|
||||
/// <summary>Prefixes the path with its size in 16-bit words.</summary>
|
||||
/// <param name="segments">The encoded path segments.</param>
|
||||
/// <returns>The size byte followed by the segments.</returns>
|
||||
/// <exception cref="ArgumentException">The segments are not a whole number of 16-bit words, or exceed
|
||||
/// the 255-word maximum the one-byte size field can carry.</exception>
|
||||
public static byte[] WithPathSize(byte[] segments)
|
||||
|
||||
@@ -7,6 +7,7 @@ public sealed record CipReply(byte Service, byte GeneralStatus, ushort[] Extende
|
||||
{
|
||||
// Record equality over an array / ReadOnlyMemory is reference equality; two parses of the same wire
|
||||
// bytes must compare equal, so compare the extended-status words and the data by content.
|
||||
/// <inheritdoc />
|
||||
public bool Equals(CipReply? other) =>
|
||||
other is not null
|
||||
&& Service == other.Service
|
||||
@@ -14,6 +15,7 @@ public sealed record CipReply(byte Service, byte GeneralStatus, ushort[] Extende
|
||||
&& ExtendedStatus.AsSpan().SequenceEqual(other.ExtendedStatus)
|
||||
&& Data.Span.SequenceEqual(other.Data.Span);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override int GetHashCode()
|
||||
{
|
||||
var h = new HashCode();
|
||||
@@ -24,8 +26,12 @@ public sealed record CipReply(byte Service, byte GeneralStatus, ushort[] Extende
|
||||
return h.ToHashCode();
|
||||
}
|
||||
|
||||
/// <summary>The reply status mapped from the general and extended status words.</summary>
|
||||
public AbStatus Status => AbStatusCodes.FromCip(GeneralStatus, ExtendedStatus);
|
||||
|
||||
/// <summary>Parses a raw message-router reply.</summary>
|
||||
/// <param name="raw">The raw reply bytes.</param>
|
||||
/// <returns>The parsed reply.</returns>
|
||||
public static CipReply Parse(ReadOnlyMemory<byte> raw)
|
||||
{
|
||||
var r = new CipReader(raw.Span);
|
||||
|
||||
@@ -7,9 +7,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
||||
/// read-only differential harness.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Instance-scoped (Gitea #581): every pool — and every connection a test builds on its own — has its own, so
|
||||
/// Instance-scoped: every pool — and every connection a test builds on its own — has its own, so
|
||||
/// no process-wide mutable state ships in the transport, and two tests can never move each other's counts. The
|
||||
/// open-attempt limit is scoped to one <see cref="DeviceKey"/> (Gitea #566), so limiting the press under test
|
||||
/// open-attempt limit is scoped to one <see cref="DeviceKey"/>, so limiting the press under test
|
||||
/// can never refuse an open to any other device in the process.
|
||||
/// </remarks>
|
||||
internal sealed class ConnectionDiagnostics
|
||||
@@ -62,11 +62,17 @@ internal sealed class ConnectionDiagnostics
|
||||
Interlocked.Exchange(ref _forwardClosesAcked, 0);
|
||||
}
|
||||
|
||||
/// <summary>Counts one Forward Open frame sent.</summary>
|
||||
internal void CountForwardOpen() => Interlocked.Increment(ref _forwardOpens);
|
||||
/// <summary>Counts one Forward Close request put on the wire.</summary>
|
||||
internal void CountForwardCloseSent() => Interlocked.Increment(ref _forwardClosesSent);
|
||||
/// <summary>Counts one Forward Close that was acknowledged within its budget.</summary>
|
||||
internal void CountForwardCloseAcked() => Interlocked.Increment(ref _forwardClosesAcked);
|
||||
/// <summary>Counts one channel send.</summary>
|
||||
internal void CountRequest() => Interlocked.Increment(ref _requestsSent);
|
||||
/// <summary>Counts one open parked behind a still-closing predecessor.</summary>
|
||||
internal void CountPredecessorWait() => Interlocked.Increment(ref _predecessorWaits);
|
||||
/// <summary>Counts one connection retired because the target closed the CIP connection.</summary>
|
||||
internal void CountClosedByTarget() => Interlocked.Increment(ref _connectionsClosedByTarget);
|
||||
|
||||
/// <summary>Round-trip statistics of the exchanges completed since the last <see cref="ResetExchangeStats"/>.</summary>
|
||||
@@ -82,6 +88,7 @@ internal sealed class ConnectionDiagnostics
|
||||
}
|
||||
|
||||
/// <summary>Snapshot of the round-trip statistics.</summary>
|
||||
/// <returns>The count and min/max/mean round-trip times in milliseconds.</returns>
|
||||
public ExchangeStats ReadExchangeStats()
|
||||
{
|
||||
var count = Interlocked.Read(ref _exchangeCount);
|
||||
@@ -92,12 +99,15 @@ internal sealed class ConnectionDiagnostics
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence tap for the gate harness (#582 / #572 / #566): sees the CIP request and the raw CIP reply of every
|
||||
/// Evidence tap for the gate harness: sees the CIP request and the raw CIP reply of every
|
||||
/// completed exchange, Forward Opens included (their replies carry the granted RPIs). Null in production; set only by the read-only differential harness. A throwing
|
||||
/// observer is swallowed so it can never affect a request.
|
||||
/// </summary>
|
||||
internal Action<ReadOnlyMemory<byte>, ReadOnlyMemory<byte>>? ExchangeObserver { get; set; }
|
||||
|
||||
/// <summary>Passes a completed exchange to <see cref="ExchangeObserver"/>, swallowing any observer fault.</summary>
|
||||
/// <param name="request">The CIP request bytes.</param>
|
||||
/// <param name="reply">The raw CIP reply bytes.</param>
|
||||
internal void NotifyExchange(ReadOnlyMemory<byte> request, ReadOnlyMemory<byte> reply)
|
||||
{
|
||||
var observer = ExchangeObserver;
|
||||
@@ -106,6 +116,8 @@ internal sealed class ConnectionDiagnostics
|
||||
catch { /* evidence only */ }
|
||||
}
|
||||
|
||||
/// <summary>Records the round-trip time of one completed exchange.</summary>
|
||||
/// <param name="ticks">The elapsed Stopwatch ticks.</param>
|
||||
internal void RecordExchange(long ticks)
|
||||
{
|
||||
Interlocked.Increment(ref _exchangeCount);
|
||||
@@ -121,9 +133,12 @@ internal sealed class ConnectionDiagnostics
|
||||
/// Caps the open attempts to <paramref name="key"/> at <paramref name="attempts"/> until the returned scope is
|
||||
/// disposed. An attempt beyond the cap fails with <see cref="AbStatus.Abort"/> before any I/O — no TCP connect,
|
||||
/// no Forward Open — so a press that refuses or drops the connection sees exactly the budgeted attempts instead
|
||||
/// of one per remaining operation. Only the read-only differential harness (Task 18/21) sets one; one limit per
|
||||
/// of one per remaining operation. Only the read-only differential harness sets one; one limit per
|
||||
/// key at a time.
|
||||
/// </summary>
|
||||
/// <param name="key">The device whose open attempts are capped.</param>
|
||||
/// <param name="attempts">The number of open attempts allowed.</param>
|
||||
/// <returns>A scope that lifts the cap when disposed.</returns>
|
||||
public OpenAttemptLimit LimitOpenAttempts(DeviceKey key, int attempts)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
@@ -138,9 +153,13 @@ internal sealed class ConnectionDiagnostics
|
||||
}
|
||||
|
||||
/// <summary>True while a limit is in force for <paramref name="key"/>.</summary>
|
||||
/// <param name="key">The device to check.</param>
|
||||
/// <returns><see langword="true"/> when a limit is in force; otherwise <see langword="false"/>.</returns>
|
||||
public bool HasOpenAttemptLimit(DeviceKey key) { lock (_lock) return _limits.ContainsKey(key); }
|
||||
|
||||
/// <summary>Counts one open attempt to <paramref name="key"/>; null when it may proceed, else why it may not.</summary>
|
||||
/// <param name="key">The device being opened.</param>
|
||||
/// <returns>Null when the attempt may proceed; otherwise the refusal reason.</returns>
|
||||
internal string? AdmitOpenAttempt(DeviceKey key)
|
||||
{
|
||||
var attempt = Interlocked.Increment(ref _openAttempts);
|
||||
@@ -164,15 +183,20 @@ internal sealed class ConnectionDiagnostics
|
||||
{
|
||||
private int _attempts;
|
||||
|
||||
/// <summary>The device this limit applies to.</summary>
|
||||
public DeviceKey Key { get; } = key;
|
||||
|
||||
/// <summary>The number of open attempts allowed.</summary>
|
||||
public int Budget { get; } = budget;
|
||||
|
||||
/// <summary>Attempts counted against this limit, the refused ones included.</summary>
|
||||
public int Attempts => Volatile.Read(ref _attempts);
|
||||
|
||||
/// <summary>Counts one attempt against the budget.</summary>
|
||||
/// <returns><see langword="true"/> while the attempt is within the budget.</returns>
|
||||
internal bool TryTake() => Interlocked.Increment(ref _attempts) <= Budget;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose() => owner.Remove(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,10 @@ public sealed record ForwardOpenReply(uint OTConnectionId, uint TOConnectionId,
|
||||
/// diagnostics) read <see cref="GeneralStatus"/> / <see cref="ExtendedStatus"/> directly.</summary>
|
||||
public sealed class ForwardOpenRefusedException : AbTransportException
|
||||
{
|
||||
/// <summary>Creates the exception for a refused Forward Open.</summary>
|
||||
/// <param name="status">The mapped transport status.</param>
|
||||
/// <param name="generalStatus">The CIP general status of the refusal.</param>
|
||||
/// <param name="extendedStatus">The CIP extended-status words of the refusal.</param>
|
||||
public ForwardOpenRefusedException(AbStatus status, byte generalStatus, IReadOnlyList<ushort> extendedStatus)
|
||||
: base(status, $"Forward Open refused: general 0x{generalStatus:X2}, extended {string.Join(",", extendedStatus.Select(e => $"0x{e:X4}"))}.")
|
||||
{
|
||||
@@ -37,6 +41,10 @@ public static class ConnectionManagerServices
|
||||
/// Close, Unconnected Send) carries 0x05, and the capture wins.</summary>
|
||||
public const byte TimeoutTicks = 0x05;
|
||||
|
||||
/// <summary>Wraps an embedded request in an Unconnected Send to the Connection Manager.</summary>
|
||||
/// <param name="embedded">The embedded CIP request.</param>
|
||||
/// <param name="route">The encoded route path to the target.</param>
|
||||
/// <returns>The encoded Unconnected Send request.</returns>
|
||||
public static byte[] UnconnectedSend(ReadOnlySpan<byte> embedded, ReadOnlySpan<byte> route)
|
||||
{
|
||||
var w = new CipWriter().U8(0x52).U8(2).Bytes(CipPath.ConnectionManager)
|
||||
@@ -45,6 +53,9 @@ public static class ConnectionManagerServices
|
||||
return w.U8((byte)(route.Length / 2)).U8(0).Bytes(route).ToArray();
|
||||
}
|
||||
|
||||
/// <summary>Builds a (Large) Forward Open request.</summary>
|
||||
/// <param name="p">The Forward Open parameters.</param>
|
||||
/// <returns>The encoded Forward Open request.</returns>
|
||||
public static byte[] ForwardOpen(ForwardOpenParams p)
|
||||
{
|
||||
if (p.ConnectionSize < 1 || p.ConnectionSize > (p.Large ? 0xFFFF : 0x1FF))
|
||||
@@ -67,6 +78,10 @@ public static class ConnectionManagerServices
|
||||
else w.U16((ushort)(0x4200 | (p.ConnectionSize & 0x1FF)));
|
||||
}
|
||||
|
||||
/// <summary>Parses a Forward Open reply.</summary>
|
||||
/// <param name="reply">The CIP reply to parse.</param>
|
||||
/// <returns>The granted connection ids and API values.</returns>
|
||||
/// <exception cref="ForwardOpenRefusedException">The target refused the Forward Open.</exception>
|
||||
public static ForwardOpenReply ParseForwardOpenReply(CipReply reply)
|
||||
{
|
||||
if (reply.GeneralStatus != 0)
|
||||
@@ -79,6 +94,8 @@ public static class ConnectionManagerServices
|
||||
|
||||
/// <summary>True when a refused Large Forward Open should be retried as a regular one: general 0x08
|
||||
/// (Service Not Supported — pre-v20 Logix) or general 0x01 / extended 0x0109 (invalid connection size).</summary>
|
||||
/// <param name="r">The refused Forward Open reply.</param>
|
||||
/// <returns><see langword="true"/> when a regular Forward Open should be tried.</returns>
|
||||
public static bool ShouldFallBackToRegularOpen(CipReply r) =>
|
||||
r.GeneralStatus == 0x08
|
||||
|| (r.GeneralStatus == 0x01 && r.ExtendedStatus.Length > 0 && r.ExtendedStatus[0] == 0x0109);
|
||||
@@ -95,6 +112,8 @@ public static class ConnectionManagerServices
|
||||
/// found) or 0x0203 (connection timed out) — else null. Any other status, a tag-level one included, describes
|
||||
/// the request and leaves the connection alone.
|
||||
/// </summary>
|
||||
/// <param name="reply">The raw CIP reply bytes.</param>
|
||||
/// <returns>The extended status when the connection is gone; otherwise <see langword="null"/>.</returns>
|
||||
public static ushort? IsConnectionGone(ReadOnlySpan<byte> reply)
|
||||
{
|
||||
// Reply header: service | reserved | general status | extended-status word count | words...
|
||||
@@ -103,6 +122,12 @@ public static class ConnectionManagerServices
|
||||
return extended is TargetConnectionNotFound or ConnectionTimedOut ? extended : null;
|
||||
}
|
||||
|
||||
/// <summary>Builds a Forward Close request.</summary>
|
||||
/// <param name="connectionSerial">The connection serial number used at open.</param>
|
||||
/// <param name="vendorId">The originator vendor id used at open.</param>
|
||||
/// <param name="originatorSerial">The originator serial number used at open.</param>
|
||||
/// <param name="route">The encoded route path to the target.</param>
|
||||
/// <returns>The encoded Forward Close request.</returns>
|
||||
public static byte[] ForwardClose(ushort connectionSerial, ushort vendorId, uint originatorSerial, ReadOnlySpan<byte> route)
|
||||
{
|
||||
var connPath = new CipWriter().Bytes(route).Bytes(CipPath.MessageRouter).ToArray();
|
||||
|
||||
@@ -31,13 +31,14 @@ public sealed record ConnectionOptions
|
||||
private readonly byte _timeoutMultiplier = 1;
|
||||
private readonly TimeSpan? _keepAliveInterval;
|
||||
|
||||
/// <summary>How requests reach the target's message router.</summary>
|
||||
public CipRouting Routing { get; init; } = CipRouting.Connected;
|
||||
|
||||
/// <summary>Pack queued requests into Multiple Service Packets (the scheduler's switch).</summary>
|
||||
public bool AllowPacking { get; init; }
|
||||
|
||||
/// <summary>Bound on each single exchange (TCP connect, RegisterSession, Forward Open, request).
|
||||
/// Must be finite and positive (ruling R9-2): an infinite timeout would let an abandoned exchange
|
||||
/// Must be finite and positive: an infinite timeout would let an abandoned exchange
|
||||
/// hold the session's single-flight gate forever.</summary>
|
||||
public TimeSpan Timeout
|
||||
{
|
||||
@@ -93,6 +94,7 @@ public sealed record ConnectionOptions
|
||||
: throw new ArgumentOutOfRangeException(nameof(KeepAliveInterval), value, "Keep-alive interval must be finite and positive.");
|
||||
}
|
||||
|
||||
/// <summary>The vendor id sent in the Forward Open originator identity.</summary>
|
||||
public ushort VendorId { get; init; } = 0x1337;
|
||||
|
||||
/// <summary>Random per process; together with the vendor id and connection serial it names a connection.</summary>
|
||||
|
||||
@@ -22,6 +22,7 @@ public sealed record DeviceKey(string Host, int Port, string CipPath, string Plc
|
||||
private static string Canonicalise(string? path) =>
|
||||
string.IsNullOrWhiteSpace(path) ? "" : string.Join(',', path.Split(',').Select(p => p.Trim()));
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool Equals(DeviceKey? other) =>
|
||||
other is not null
|
||||
&& string.Equals(Host, other.Host, StringComparison.OrdinalIgnoreCase)
|
||||
@@ -29,6 +30,8 @@ public sealed record DeviceKey(string Host, int Port, string CipPath, string Plc
|
||||
&& string.Equals(CipPath, other.CipPath, StringComparison.Ordinal)
|
||||
&& string.Equals(PlcAttribute, other.PlcAttribute, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// <summary>Hashes the same fields used by <see cref="Equals(DeviceKey?)"/>.</summary>
|
||||
/// <inheritdoc />
|
||||
public override int GetHashCode() => HashCode.Combine(
|
||||
StringComparer.OrdinalIgnoreCase.GetHashCode(Host),
|
||||
Port,
|
||||
|
||||
@@ -16,6 +16,9 @@ public sealed class ForwardOpenThrottle
|
||||
private int _queued;
|
||||
private Task? _intervalWait; // the pending interval wait (test hook only)
|
||||
|
||||
/// <summary>Creates a throttle that spaces Forward Open attempts at least <paramref name="minInterval"/> apart.</summary>
|
||||
/// <param name="minInterval">The minimum gap between the starts of successive attempts; must be finite and non-negative.</param>
|
||||
/// <param name="time">The clock used to measure the interval.</param>
|
||||
/// <remarks>Internal: the only legitimate owner is <see cref="DeviceSessionPool"/>, which keeps exactly
|
||||
/// one per device. A second throttle for the same device would halve the guaranteed interval.</remarks>
|
||||
internal ForwardOpenThrottle(TimeSpan minInterval, TimeProvider time)
|
||||
@@ -39,6 +42,10 @@ public sealed class ForwardOpenThrottle
|
||||
/// throttled"). <paramref name="ct"/> is also passed to <paramref name="open"/>, which may ignore it:
|
||||
/// <see cref="CipConnection"/> does, running the attempt under its own open deadline so that an accepted
|
||||
/// Forward Open is never abandoned without a Forward Close.</summary>
|
||||
/// <typeparam name="T">The result type of the open attempt.</typeparam>
|
||||
/// <param name="open">The open attempt to run once the throttle allows it.</param>
|
||||
/// <param name="ct">Cancels the wait before the attempt starts.</param>
|
||||
/// <returns>The result of <paramref name="open"/>.</returns>
|
||||
public async Task<T> RunAsync<T>(Func<CancellationToken, Task<T>> open, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(open);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
||||
|
||||
/// <summary>A path to a CIP message router: connected (Forward Open) or unconnected (UCMM). Task 9
|
||||
/// implements it; Task 10's scheduler and the native adapters consume it.</summary>
|
||||
/// <summary>A path to a CIP message router: connected (Forward Open) or unconnected (UCMM). The
|
||||
/// native CIP transport implements it; the scheduler and the native adapters consume it.</summary>
|
||||
public interface ICipChannel
|
||||
{
|
||||
/// <summary>Largest CIP request (service byte through end of data) one call may carry.</summary>
|
||||
@@ -9,5 +9,8 @@ public interface ICipChannel
|
||||
|
||||
/// <summary>Send one CIP request and return the raw CIP reply bytes (reply-service byte first).
|
||||
/// Throws <see cref="AbTransportException"/> on transport failure or timeout.</summary>
|
||||
/// <param name="cipRequest">The CIP request bytes, service byte through end of data.</param>
|
||||
/// <param name="cancellationToken">Token that cancels the in-flight request.</param>
|
||||
/// <returns>The raw CIP reply bytes, reply-service byte first.</returns>
|
||||
Task<ReadOnlyMemory<byte>> SendAsync(ReadOnlyMemory<byte> cipRequest, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ public static class MultipleServicePacket
|
||||
/// <summary>Service + path size + Message Router path.</summary>
|
||||
private const int HeaderSize = 6;
|
||||
|
||||
/// <summary>Builds a Multiple Service Packet carrying the given embedded requests.</summary>
|
||||
/// <param name="requests">The encoded embedded requests.</param>
|
||||
/// <returns>The encoded Multiple Service Packet.</returns>
|
||||
public static byte[] Build(IReadOnlyList<byte[]> requests)
|
||||
{
|
||||
var w = new CipWriter().U8(0x0A).U8(2).Bytes(CipPath.MessageRouter).U16((ushort)requests.Count);
|
||||
@@ -17,6 +20,8 @@ public static class MultipleServicePacket
|
||||
}
|
||||
|
||||
/// <summary>Exact byte size of an MSP carrying requests of the given sizes.</summary>
|
||||
/// <param name="requestSizes">The byte size of each embedded request.</param>
|
||||
/// <returns>The total packet size in bytes.</returns>
|
||||
public static int BuildSize(IReadOnlyList<int> requestSizes)
|
||||
{
|
||||
var total = HeaderSize + 2 + 2 * requestSizes.Count;
|
||||
@@ -25,6 +30,9 @@ public static class MultipleServicePacket
|
||||
}
|
||||
|
||||
/// <summary>Splits an MSP reply (the outer reply's data) into the embedded replies.</summary>
|
||||
/// <param name="reply">The outer Multiple Service Packet reply.</param>
|
||||
/// <param name="count">The number of embedded replies expected.</param>
|
||||
/// <returns>The embedded replies in request order.</returns>
|
||||
public static IReadOnlyList<CipReply> Parse(CipReply reply, int count)
|
||||
{
|
||||
var data = reply.Data;
|
||||
|
||||
@@ -38,6 +38,7 @@ public sealed class RequestScheduler : IAsyncDisposable
|
||||
private CancellationTokenRegistration _registration;
|
||||
private bool _hasRegistration;
|
||||
|
||||
/// <summary>The encoded CIP request bytes.</summary>
|
||||
public byte[] Request { get; } = request;
|
||||
|
||||
/// <summary>True when this request must never share a Multiple Service Packet with another.</summary>
|
||||
@@ -45,10 +46,14 @@ public sealed class RequestScheduler : IAsyncDisposable
|
||||
|
||||
/// <summary>Expected whole-reply size (header included), or <see cref="UnknownReply"/>.</summary>
|
||||
public int ExpectedReplyBytes { get; } = expectedReplyBytes;
|
||||
/// <summary>Completes with the reply, a fault, or cancellation.</summary>
|
||||
public TaskCompletionSource<CipReply> Tcs { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
/// <summary>This request's node in the scheduler queue, or null once dequeued.</summary>
|
||||
public LinkedListNode<Pending>? Node { get; set; }
|
||||
|
||||
/// <summary>Stores the waiter's registration; disposes it at once if the waiter already completed.</summary>
|
||||
/// <param name="registration">The cancellation registration to track.</param>
|
||||
/// <param name="owner">The scheduler whose live-registration count is maintained.</param>
|
||||
public void SetRegistration(CancellationTokenRegistration registration, RequestScheduler owner)
|
||||
{
|
||||
lock (_gate)
|
||||
@@ -89,16 +94,25 @@ public sealed class RequestScheduler : IAsyncDisposable
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Completes the waiter with a reply, unless it already completed.</summary>
|
||||
/// <param name="reply">The reply.</param>
|
||||
/// <param name="owner">The owning scheduler.</param>
|
||||
public void Succeed(CipReply reply, RequestScheduler owner)
|
||||
{
|
||||
if (Finish(owner)) Tcs.TrySetResult(reply);
|
||||
}
|
||||
|
||||
/// <summary>Faults the waiter, unless it already completed.</summary>
|
||||
/// <param name="ex">The fault.</param>
|
||||
/// <param name="owner">The owning scheduler.</param>
|
||||
public void Fail(Exception ex, RequestScheduler owner)
|
||||
{
|
||||
if (Finish(owner)) Tcs.TrySetException(ex);
|
||||
}
|
||||
|
||||
/// <summary>Cancels the waiter, unless it already completed.</summary>
|
||||
/// <param name="ct">The token reported as the cancellation cause.</param>
|
||||
/// <param name="owner">The owning scheduler.</param>
|
||||
public void Cancel(CancellationToken ct, RequestScheduler owner)
|
||||
{
|
||||
if (Finish(owner)) Tcs.TrySetCanceled(ct);
|
||||
@@ -116,6 +130,9 @@ public sealed class RequestScheduler : IAsyncDisposable
|
||||
private int _liveRegistrations;
|
||||
private int _cancellationCallbacksRun;
|
||||
|
||||
/// <summary>Creates a scheduler and starts its pump over the channel.</summary>
|
||||
/// <param name="channel">The channel requests are sent on.</param>
|
||||
/// <param name="allowPacking">Whether requests may be combined into Multiple Service Packets.</param>
|
||||
public RequestScheduler(ICipChannel channel, bool allowPacking)
|
||||
{
|
||||
_channel = channel ?? throw new ArgumentNullException(nameof(channel));
|
||||
@@ -144,9 +161,10 @@ public sealed class RequestScheduler : IAsyncDisposable
|
||||
/// Packet: for requests whose reply can fill the whole connection.</param>
|
||||
/// <param name="expectedReplyBytes">Upper bound on this request's whole reply (header included) as
|
||||
/// it would sit inside a Multiple Service Packet reply. The packer budgets this against the
|
||||
/// connection size as well as the request bytes (#582), so a batch of small requests with large
|
||||
/// connection size as well as the request bytes, so a batch of small requests with large
|
||||
/// replies (STRINGs, arrays) is split instead of overflowing. Defaults to <see cref="StatusOnlyReply"/>;
|
||||
/// <see cref="UnknownReply"/> sends the request alone.</param>
|
||||
/// <returns>A task that completes with the CIP reply.</returns>
|
||||
public Task<CipReply> SendAsync(byte[] request, CancellationToken ct, bool exclusive = false,
|
||||
int expectedReplyBytes = StatusOnlyReply)
|
||||
{
|
||||
@@ -221,6 +239,8 @@ public sealed class RequestScheduler : IAsyncDisposable
|
||||
|
||||
/// <summary>Expected size of the MSP reply for the given embedded reply sizes: reply header, count,
|
||||
/// one offset per reply, then the replies themselves.</summary>
|
||||
/// <param name="replySizes">The sizes of the embedded replies.</param>
|
||||
/// <returns>The total size of the MSP reply in bytes.</returns>
|
||||
internal static int ReplyEnvelopeSize(IReadOnlyList<int> replySizes)
|
||||
{
|
||||
var total = ReplyHeaderBytes + 2 + 2 * replySizes.Count;
|
||||
@@ -324,6 +344,7 @@ public sealed class RequestScheduler : IAsyncDisposable
|
||||
|
||||
/// <summary>Stops the pump. In-flight and queued waiters fault with
|
||||
/// <see cref="AbTransportException"/> (<see cref="AbStatus.BadConnection"/>).</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
lock (_lock)
|
||||
|
||||
@@ -19,10 +19,13 @@ public sealed class DeviceSessionPool
|
||||
internal sealed class Entry(CipConnection connection, RequestScheduler scheduler, ConnectionOptions options,
|
||||
Task predecessorClosed)
|
||||
{
|
||||
/// <summary>The shared CIP connection for the device.</summary>
|
||||
public CipConnection Connection { get; } = connection;
|
||||
/// <summary>Completes when every earlier connection for this key has finished closing.</summary>
|
||||
public Task PredecessorClosed { get; } = predecessorClosed;
|
||||
/// <summary>The request scheduler serializing and packing traffic on <see cref="Connection"/>.</summary>
|
||||
public RequestScheduler Scheduler { get; } = scheduler;
|
||||
/// <summary>The options the connection was created with.</summary>
|
||||
public ConnectionOptions Options { get; } = options;
|
||||
public int RefCount; // under the pool lock
|
||||
public bool MismatchLogged; // under the pool lock
|
||||
@@ -35,6 +38,9 @@ public sealed class DeviceSessionPool
|
||||
private readonly TimeProvider _time;
|
||||
private volatile ILogger _logger;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="DeviceSessionPool"/> class.</summary>
|
||||
/// <param name="time">Time source; defaults to <see cref="TimeProvider.System"/>.</param>
|
||||
/// <param name="logger">Logger for mismatch and close-failure warnings; defaults to a no-op logger.</param>
|
||||
public DeviceSessionPool(TimeProvider? time = null, ILogger? logger = null)
|
||||
{
|
||||
_time = time ?? TimeProvider.System;
|
||||
@@ -43,6 +49,7 @@ public sealed class DeviceSessionPool
|
||||
|
||||
/// <summary>Give <see cref="Shared"/> a logger (it is built before any host exists), so its
|
||||
/// options-mismatch and close-failure warnings reach the service log. Call once at startup.</summary>
|
||||
/// <param name="loggerFactory">The factory used to create the shared pool's logger.</param>
|
||||
public static void ConfigureSharedLogger(ILoggerFactory loggerFactory)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(loggerFactory);
|
||||
@@ -61,6 +68,9 @@ public sealed class DeviceSessionPool
|
||||
/// <summary>Lease the shared connection for <paramref name="key"/>, creating it on first use. Performs
|
||||
/// no I/O: the connection opens on its first request. When the key already has an entry, that entry's
|
||||
/// options win; a differing <paramref name="options"/> is logged once per entry.</summary>
|
||||
/// <param name="key">The device to lease a connection for.</param>
|
||||
/// <param name="options">The options to open the connection with if the key has no entry yet.</param>
|
||||
/// <returns>A lease on the device's shared connection.</returns>
|
||||
public ConnectionLease Acquire(DeviceKey key, ConnectionOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
@@ -89,6 +99,10 @@ public sealed class DeviceSessionPool
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Drops one reference on <paramref name="entry"/>; the last release removes it and closes the connection.</summary>
|
||||
/// <param name="key">The device key the entry belongs to.</param>
|
||||
/// <param name="entry">The entry whose reference is released.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
internal async ValueTask ReleaseAsync(DeviceKey key, Entry entry)
|
||||
{
|
||||
TaskCompletionSource closed;
|
||||
@@ -128,6 +142,11 @@ public sealed class DeviceSessionPool
|
||||
/// connection. The connection is disposed even if the scheduler's dispose throws: otherwise its keep-alive
|
||||
/// would hold the press connection open forever. Never throws; each failure is logged.
|
||||
/// </summary>
|
||||
/// <param name="key">The device being closed (for logging).</param>
|
||||
/// <param name="scheduler">The request scheduler to dispose first.</param>
|
||||
/// <param name="connection">The connection to dispose after the scheduler.</param>
|
||||
/// <param name="logger">The logger that receives close failures.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
internal static async Task CloseAsync(DeviceKey key, IAsyncDisposable scheduler, IAsyncDisposable connection, ILogger logger)
|
||||
{
|
||||
try { await scheduler.DisposeAsync().ConfigureAwait(false); }
|
||||
@@ -152,6 +171,10 @@ public sealed class ConnectionLease : IAsyncDisposable
|
||||
private readonly DeviceSessionPool.Entry _entry;
|
||||
private int _disposed;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="ConnectionLease"/> class.</summary>
|
||||
/// <param name="pool">The owning pool.</param>
|
||||
/// <param name="key">The device key leased.</param>
|
||||
/// <param name="entry">The pool entry this lease references.</param>
|
||||
internal ConnectionLease(DeviceSessionPool pool, DeviceKey key, DeviceSessionPool.Entry entry)
|
||||
{
|
||||
_pool = pool;
|
||||
@@ -159,6 +182,7 @@ public sealed class ConnectionLease : IAsyncDisposable
|
||||
_entry = entry;
|
||||
}
|
||||
|
||||
/// <summary>The device this lease is on.</summary>
|
||||
public DeviceKey Key => _key;
|
||||
|
||||
/// <summary>The device's channel. Prefer <see cref="Scheduler"/>: it serializes and packs requests.</summary>
|
||||
@@ -167,13 +191,15 @@ public sealed class ConnectionLease : IAsyncDisposable
|
||||
/// <summary>The device's single request scheduler, shared by every lease on the key.</summary>
|
||||
public RequestScheduler Scheduler => _entry.Scheduler;
|
||||
|
||||
/// <summary>Releases the lease; further calls are no-ops.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public ValueTask DisposeAsync() =>
|
||||
Interlocked.Exchange(ref _disposed, 1) == 0 ? _pool.ReleaseAsync(_key, _entry) : ValueTask.CompletedTask;
|
||||
|
||||
/// <summary>
|
||||
/// Releases this lease without waiting for the teardown — the one release shape both native AB tag
|
||||
/// runtimes use from their synchronous <c>Dispose</c>, which the drivers call under a per-tag lock
|
||||
/// (Gitea #579). Idempotent, like <see cref="DisposeAsync"/>.
|
||||
/// runtimes use from their synchronous <c>Dispose</c>, which the drivers call under a per-tag lock.
|
||||
/// Idempotent, like <see cref="DisposeAsync"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Nothing is lost by not waiting. The release's bookkeeping — the ref-count drop and, for the
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Eip;
|
||||
/// <see cref="SemaphoreSlim"/>(1,1). The connected sequence count is advanced inside that gate, so
|
||||
/// concurrent callers can never put sequences on the wire out of order.</para>
|
||||
/// <para><b>Caller cancellation never faults the connection, and never misattributes a reply</b>
|
||||
/// (rulings R5-1/R5-2). The caller gets <see cref="OperationCanceledException"/> at once, but the
|
||||
/// The caller gets <see cref="OperationCanceledException"/> at once, but the
|
||||
/// abandoned exchange keeps reading <i>its own</i> reply in the background while still holding the
|
||||
/// gate, bounded by the original timeout. The reply is discarded and the connection stays healthy;
|
||||
/// if the timeout elapses first the connection faults exactly as a normal timeout does. Because the
|
||||
@@ -65,10 +65,18 @@ public sealed class EncapsulationClient : IAsyncDisposable
|
||||
/// <summary>Test seam: wraps an already-connected <paramref name="tcp"/> but routes all frame I/O through
|
||||
/// <paramref name="stream"/> (e.g. one whose write never completes), skipping RegisterSession. The TCP
|
||||
/// client is still what <see cref="Fault"/> closes and <see cref="IsConnected"/> consults.</summary>
|
||||
/// <param name="tcp">The already-connected TCP client.</param>
|
||||
/// <param name="stream">The stream through which all frame I/O is routed.</param>
|
||||
/// <returns>A client wrapping <paramref name="tcp"/> that performs I/O over <paramref name="stream"/>.</returns>
|
||||
internal static EncapsulationClient ForStream(TcpClient tcp, Stream stream) => new(tcp, stream);
|
||||
|
||||
/// <summary>Open the TCP connection and register an encapsulation session. <paramref name="timeout"/>
|
||||
/// bounds the TCP connect and the RegisterSession exchange separately.</summary>
|
||||
/// <param name="host">The host name or IP address of the controller.</param>
|
||||
/// <param name="port">The TCP port to connect to.</param>
|
||||
/// <param name="timeout">The bound applied to the TCP connect and the RegisterSession exchange separately.</param>
|
||||
/// <param name="ct">Token that cancels the operation.</param>
|
||||
/// <returns>A task that yields the connected client with a registered encapsulation session.</returns>
|
||||
public static async Task<EncapsulationClient> ConnectAsync(string host, int port, TimeSpan timeout, CancellationToken ct)
|
||||
{
|
||||
ValidateTimeout(timeout);
|
||||
@@ -111,6 +119,10 @@ public sealed class EncapsulationClient : IAsyncDisposable
|
||||
}
|
||||
|
||||
/// <summary>Send one unconnected (UCMM) CIP request and return the CIP reply bytes.</summary>
|
||||
/// <param name="cip">The CIP request bytes.</param>
|
||||
/// <param name="timeout">The bound on the whole exchange.</param>
|
||||
/// <param name="ct">Token that cancels the wait for the reply.</param>
|
||||
/// <returns>A task that yields the CIP reply bytes.</returns>
|
||||
public async Task<ReadOnlyMemory<byte>> SendRRDataAsync(ReadOnlyMemory<byte> cip, TimeSpan timeout, CancellationToken ct)
|
||||
{
|
||||
ValidateTimeout(timeout);
|
||||
@@ -122,6 +134,11 @@ public sealed class EncapsulationClient : IAsyncDisposable
|
||||
|
||||
/// <summary>Send one connected (class 3) CIP request over <paramref name="otConnectionId"/> and return
|
||||
/// the CIP reply bytes. The client owns the connected sequence count (review B1).</summary>
|
||||
/// <param name="otConnectionId">The originator-to-target connection id of the open class-3 connection.</param>
|
||||
/// <param name="cip">The CIP request bytes.</param>
|
||||
/// <param name="timeout">The bound on the whole exchange.</param>
|
||||
/// <param name="ct">Token that cancels the wait for the reply.</param>
|
||||
/// <returns>A task that yields the CIP reply bytes.</returns>
|
||||
public async Task<ReadOnlyMemory<byte>> SendUnitDataAsync(uint otConnectionId, ReadOnlyMemory<byte> cip,
|
||||
TimeSpan timeout, CancellationToken ct)
|
||||
{
|
||||
@@ -140,6 +157,8 @@ public sealed class EncapsulationClient : IAsyncDisposable
|
||||
|
||||
/// <summary>The connected sequence count after <paramref name="current"/>: increments, wrapping
|
||||
/// 65535 → 1 (0 is never put on the wire).</summary>
|
||||
/// <param name="current">The current sequence count.</param>
|
||||
/// <returns>The next sequence count.</returns>
|
||||
internal static ushort NextSequence(ushort current) => current == ushort.MaxValue ? (ushort)1 : (ushort)(current + 1);
|
||||
|
||||
/// <summary>Acquire the gate, build the request (sequence assigned under the gate), then hand the
|
||||
@@ -175,7 +194,7 @@ public sealed class EncapsulationClient : IAsyncDisposable
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
// Caller abandoned the wait (R5-1). The exchange keeps draining its own reply under the
|
||||
// Caller abandoned the wait. The exchange keeps draining its own reply under the
|
||||
// gate and faults on its own timeout; observe its outcome so a fault is never unobserved.
|
||||
_ = exchange.ContinueWith(static t => _ = t.Exception, CancellationToken.None,
|
||||
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);
|
||||
@@ -233,7 +252,7 @@ public sealed class EncapsulationClient : IAsyncDisposable
|
||||
}
|
||||
|
||||
/// <summary>Does <paramref name="frame"/> answer the request in flight? Unconnected replies match on
|
||||
/// command and sender context, tolerating a zeroed context (ab_server, R5-2); connected replies match
|
||||
/// command and sender context, tolerating a zeroed context (ab_server); connected replies match
|
||||
/// on the echoed sequence count. Anything else is skipped. A matching frame is fully validated, so an
|
||||
/// encapsulation error throws BadReply.</summary>
|
||||
private static bool TryMatch(byte[] frame, ushort cmd, bool connected, ulong ctx, ushort seq,
|
||||
@@ -296,6 +315,7 @@ public sealed class EncapsulationClient : IAsyncDisposable
|
||||
/// <summary>Best-effort UnRegisterSession, then close. Waits briefly for an in-flight exchange so the
|
||||
/// UnRegisterSession frame is never interleaved with another frame; if the gate is not free in time
|
||||
/// the socket is closed without it.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
|
||||
|
||||
@@ -35,14 +35,23 @@ public static class EncapsulationCodec
|
||||
/// ab_server's RegisterSession sanity check rejects any non-zero context with encapsulation status 1
|
||||
/// and a header-only reply, so a per-request context here failed every native session against the
|
||||
/// simulator. The reply is still matched — unconnected matching tolerates a zero context.</remarks>
|
||||
/// <returns>The encoded RegisterSession frame.</returns>
|
||||
public static byte[] RegisterSession() =>
|
||||
Finish(Header(EncapCommand.RegisterSession, 0, 0).U16(1).U16(0));
|
||||
|
||||
/// <summary>UnRegisterSession: header only, no data.</summary>
|
||||
/// <param name="session">The session handle returned by RegisterSession.</param>
|
||||
/// <param name="ctx">The sender context echoed in the reply.</param>
|
||||
/// <returns>The encoded UnRegisterSession frame.</returns>
|
||||
public static byte[] UnRegisterSession(uint session, ulong ctx) =>
|
||||
Finish(Header(EncapCommand.UnRegisterSession, session, ctx));
|
||||
|
||||
/// <summary>SendRRData (unconnected): Null Address item + Unconnected Data item carrying <paramref name="cip"/>.</summary>
|
||||
/// <param name="session">The session handle returned by RegisterSession.</param>
|
||||
/// <param name="ctx">The sender context echoed in the reply.</param>
|
||||
/// <param name="cip">The CIP request carried in the Unconnected Data item.</param>
|
||||
/// <param name="timeoutSeconds">The encapsulation timeout, in seconds.</param>
|
||||
/// <returns>The encoded SendRRData frame.</returns>
|
||||
public static byte[] SendRRData(uint session, ulong ctx, ReadOnlySpan<byte> cip, ushort timeoutSeconds) =>
|
||||
Finish(Header(EncapCommand.SendRRData, session, ctx)
|
||||
.U32(0).U16(timeoutSeconds).U16(2)
|
||||
@@ -51,6 +60,12 @@ public static class EncapsulationCodec
|
||||
|
||||
/// <summary>SendUnitData (connected): Connected Address item + Connected Data item carrying the
|
||||
/// sequence count followed by <paramref name="cip"/>.</summary>
|
||||
/// <param name="session">The session handle returned by RegisterSession.</param>
|
||||
/// <param name="ctx">The sender context echoed in the reply.</param>
|
||||
/// <param name="connectionId">The connection id of the Forward Open connection.</param>
|
||||
/// <param name="sequence">The connected-data sequence count.</param>
|
||||
/// <param name="cip">The CIP request carried in the Connected Data item.</param>
|
||||
/// <returns>The encoded SendUnitData frame.</returns>
|
||||
public static byte[] SendUnitData(uint session, ulong ctx, uint connectionId, ushort sequence, ReadOnlySpan<byte> cip) =>
|
||||
Finish(Header(EncapCommand.SendUnitData, session, ctx)
|
||||
.U32(0).U16(0).U16(2)
|
||||
@@ -65,6 +80,8 @@ public static class EncapsulationCodec
|
||||
}
|
||||
|
||||
/// <summary>Read the 24-byte header at the start of <paramref name="b"/>.</summary>
|
||||
/// <param name="b">The bytes beginning with the encapsulation header.</param>
|
||||
/// <returns>The decoded header.</returns>
|
||||
public static EncapsulationHeader ReadHeader(ReadOnlySpan<byte> b)
|
||||
{
|
||||
var r = new CipReader(b);
|
||||
@@ -76,6 +93,9 @@ public static class EncapsulationCodec
|
||||
/// the echoed sequence count. RegisterSession / UnRegisterSession replies return an empty payload.
|
||||
/// Throws <see cref="AbTransportException"/>(<see cref="AbStatus.BadReply"/>) on a non-zero
|
||||
/// encapsulation status, a length mismatch, a truncated item list or a missing data item.</summary>
|
||||
/// <param name="frame">The complete reply frame, header included.</param>
|
||||
/// <param name="expectConnected">True to expect a connected data item; false for an unconnected one.</param>
|
||||
/// <returns>The header, the CIP payload, and the echoed sequence count (0 for unconnected replies).</returns>
|
||||
public static (EncapsulationHeader Header, ReadOnlyMemory<byte> Cip, ushort Sequence) ParseReply(
|
||||
ReadOnlyMemory<byte> frame, bool expectConnected)
|
||||
{
|
||||
|
||||
@@ -8,11 +8,28 @@ public static class LogixServices
|
||||
private static CipWriter Start(byte service, ReadOnlySpan<byte> path) =>
|
||||
new CipWriter().U8(service).U8((byte)(path.Length / 2)).Bytes(path);
|
||||
|
||||
/// <summary>Builds a Read Tag request.</summary>
|
||||
/// <param name="path">The encoded tag path.</param>
|
||||
/// <param name="elements">The number of elements to read.</param>
|
||||
/// <returns>The encoded CIP request bytes.</returns>
|
||||
public static byte[] ReadTag(ReadOnlySpan<byte> path, ushort elements) => Start(0x4C, path).U16(elements).ToArray();
|
||||
|
||||
/// <summary>Builds a Read Tag Fragmented request.</summary>
|
||||
/// <param name="path">The encoded tag path.</param>
|
||||
/// <param name="elements">The number of elements to read.</param>
|
||||
/// <param name="offset">The byte offset into the tag data at which to resume.</param>
|
||||
/// <returns>The encoded CIP request bytes.</returns>
|
||||
public static byte[] ReadTagFragmented(ReadOnlySpan<byte> path, ushort elements, uint offset) =>
|
||||
Start(0x52, path).U16(elements).U32(offset).ToArray();
|
||||
|
||||
/// <summary>Builds a Write Tag request.</summary>
|
||||
/// <param name="path">The encoded tag path.</param>
|
||||
/// <param name="type">The Logix data type code.</param>
|
||||
/// <param name="handle">The structure handle; required when <paramref name="type"/> is a structure.</param>
|
||||
/// <param name="elements">The number of elements to write.</param>
|
||||
/// <param name="data">The encoded value bytes.</param>
|
||||
/// <returns>The encoded CIP request bytes.</returns>
|
||||
/// <exception cref="ArgumentException">A structure write was requested without a handle.</exception>
|
||||
public static byte[] WriteTag(ReadOnlySpan<byte> path, ushort type, ushort? handle, ushort elements, ReadOnlySpan<byte> data)
|
||||
{
|
||||
var w = Start(0x4D, path).U16(type);
|
||||
@@ -20,6 +37,15 @@ public static class LogixServices
|
||||
return w.U16(elements).Bytes(data).ToArray();
|
||||
}
|
||||
|
||||
/// <summary>Builds a Write Tag Fragmented request.</summary>
|
||||
/// <param name="path">The encoded tag path.</param>
|
||||
/// <param name="type">The Logix data type code.</param>
|
||||
/// <param name="handle">The structure handle; required when <paramref name="type"/> is a structure.</param>
|
||||
/// <param name="elements">The number of elements to write.</param>
|
||||
/// <param name="offset">The byte offset into the tag data at which this fragment starts.</param>
|
||||
/// <param name="data">The encoded value bytes of this fragment.</param>
|
||||
/// <returns>The encoded CIP request bytes.</returns>
|
||||
/// <exception cref="ArgumentException">A structure write was requested without a handle.</exception>
|
||||
public static byte[] WriteTagFragmented(ReadOnlySpan<byte> path, ushort type, ushort? handle, ushort elements, uint offset, ReadOnlySpan<byte> data)
|
||||
{
|
||||
var w = Start(0x53, path).U16(type);
|
||||
@@ -27,6 +53,10 @@ public static class LogixServices
|
||||
return w.U16(elements).U32(offset).Bytes(data).ToArray();
|
||||
}
|
||||
|
||||
/// <summary>Parses a Read Tag reply.</summary>
|
||||
/// <param name="reply">The CIP reply.</param>
|
||||
/// <returns>The type code, the structure handle (for structure types, otherwise null) and the value data.</returns>
|
||||
/// <exception cref="AbTransportException">The reply carries a failure status.</exception>
|
||||
public static (ushort TypeCode, ushort? StructHandle, ReadOnlyMemory<byte> Data) ParseReadReply(CipReply reply)
|
||||
{
|
||||
if (reply.GeneralStatus is not (0x00 or 0x06))
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Logix;
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Reads are issued as Read Tag Fragmented (0x52) starting at offset 0 — exactly what libplctag puts
|
||||
/// on the wire (golden captures, Task 4) — so the native and libplctag frames stay byte-comparable,
|
||||
/// on the wire (golden captures) — so the native and libplctag frames stay byte-comparable,
|
||||
/// and the continuation requests are the same service at a non-zero offset.
|
||||
/// </remarks>
|
||||
public sealed class LogixTagClient(RequestScheduler scheduler, ICipChannel channel)
|
||||
@@ -43,7 +43,8 @@ public sealed class LogixTagClient(RequestScheduler scheduler, ICipChannel chann
|
||||
/// <param name="ct">Cancels this read.</param>
|
||||
/// <param name="elementBytes">Upper bound on one element's data bytes (<see cref="LogixTypeCode.StringElementBytes"/>
|
||||
/// for a STRING), or 0 when unknown. The scheduler budgets the expected reply against the connection
|
||||
/// size when packing (#582); unknown sends the read alone.</param>
|
||||
/// size when packing; unknown sends the read alone.</param>
|
||||
/// <returns>The tag's type code, optional structure handle, and the reassembled data bytes.</returns>
|
||||
public async Task<(ushort TypeCode, ushort? StructHandle, byte[] Data)> ReadAsync(
|
||||
byte[] path, ushort elements, CancellationToken ct, int elementBytes = 0)
|
||||
{
|
||||
@@ -86,6 +87,10 @@ public sealed class LogixTagClient(RequestScheduler scheduler, ICipChannel chann
|
||||
/// <summary>Whole-reply bound for a Read Tag Fragmented at <paramref name="alreadyRead"/> bytes in:
|
||||
/// reply header, type (+ struct handle), then the remaining data. Unknown element size, or an
|
||||
/// unrepresentable total, is <see cref="RequestScheduler.UnknownReply"/>.</summary>
|
||||
/// <param name="elements">Element count being read.</param>
|
||||
/// <param name="elementBytes">Upper bound on one element's data bytes, or 0 when unknown.</param>
|
||||
/// <param name="alreadyRead">Data bytes already received in earlier fragments.</param>
|
||||
/// <returns>The expected reply size in bytes, or <see cref="RequestScheduler.UnknownReply"/>.</returns>
|
||||
internal static int ExpectedReadReplyBytes(ushort elements, int elementBytes, int alreadyRead)
|
||||
{
|
||||
if (elementBytes <= 0) return RequestScheduler.UnknownReply;
|
||||
@@ -105,6 +110,13 @@ public sealed class LogixTagClient(RequestScheduler scheduler, ICipChannel chann
|
||||
/// <remarks>A fragmented write is NOT atomic across fragments — the controller applies each
|
||||
/// fragment as it arrives, so a reader can observe a partly-written value. libplctag behaves the
|
||||
/// same way.</remarks>
|
||||
/// <param name="path">The encoded symbolic path.</param>
|
||||
/// <param name="type">The Logix type code.</param>
|
||||
/// <param name="handle">The structure handle for a structure type; otherwise <see langword="null"/>.</param>
|
||||
/// <param name="elements">Element count to write.</param>
|
||||
/// <param name="data">The encoded value bytes.</param>
|
||||
/// <param name="ct">Cancels this write.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
/// <exception cref="ArgumentException">For an atomic type, <paramref name="data"/> is not exactly
|
||||
/// <paramref name="elements"/> × the type's width.</exception>
|
||||
public async Task WriteAsync(byte[] path, ushort type, ushort? handle, ushort elements,
|
||||
|
||||
@@ -9,6 +9,8 @@ public static class LogixTypeCode
|
||||
public const int StringElementBytes = 88;
|
||||
|
||||
/// <summary>Byte width of an atomic type, or -1 when the code is not atomic (e.g. <see cref="Struct"/>).</summary>
|
||||
/// <param name="code">The Logix type code.</param>
|
||||
/// <returns>The byte width, or -1 for a non-atomic code.</returns>
|
||||
public static int ByteWidth(ushort code) => code switch
|
||||
{
|
||||
Bool or SInt or USInt => 1,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user