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:
@@ -4,7 +4,7 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Acls;
|
||||
|
||||
/// <summary>
|
||||
/// One row in the ACL scope picker (#520 stage 7) — a place a <c>NodeAcl</c> grant can be scoped at,
|
||||
/// One row in the ACL scope picker — a place a <c>NodeAcl</c> grant can be scoped at,
|
||||
/// in either the raw device tree or the UNS tree.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
|
||||
@@ -3,7 +3,7 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Acls;
|
||||
|
||||
/// <summary>
|
||||
/// Backs the ACL authoring surface (#520 stage 7): the scope picker's two trees, and the
|
||||
/// Backs the ACL authoring surface: the scope picker's two trees, and the
|
||||
/// authoring-time checks that stop a grant being saved against a principal or a scope that names
|
||||
/// nothing.
|
||||
/// </summary>
|
||||
|
||||
@@ -3,7 +3,7 @@ using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Browsing;
|
||||
|
||||
/// <summary>
|
||||
/// Payload for a leaf toggle in <c>DriverBrowseTree</c>'s multi-select mode (WP6 "Browse device…"
|
||||
/// Payload for a leaf toggle in <c>DriverBrowseTree</c>'s multi-select mode ("Browse device…"
|
||||
/// re-target). Carries the toggled <see cref="Leaf"/> plus the captured browse-folder nesting above it
|
||||
/// (<see cref="FolderPath"/>, root→parent display names) so the browse modal can optionally mirror the
|
||||
/// nesting onto TagGroups on commit. Fired only for <see cref="BrowseNodeKind.Leaf"/> nodes.
|
||||
|
||||
@@ -87,21 +87,15 @@ public sealed class BrowserSessionService(
|
||||
InvokeAsync<IReadOnlyList<AttributeInfo>>(token, ct, (s, c) => s.AttributesAsync(nodeId, c));
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The authorization check is the point of routing this through the service at all.</b>
|
||||
/// Every other member here is read-only; this one causes the browse session to publish onto
|
||||
/// a live plant broker, so it is gated on the same <c>DriverOperator</c> policy the picker
|
||||
/// bodies evaluate before rendering their Browse affordance. A render-time check alone is
|
||||
/// not a control — it decides what a page draws, not what a circuit may invoke — so the
|
||||
/// check is made here, where the action actually happens, and it fails closed.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Deliberately <b>not</b> wrapped in <see cref="PerCallTimeout"/>-style swallowing: unlike
|
||||
/// a failed expand, a failed or refused rebirth is something the operator must see, so the
|
||||
/// exception propagates to the caller for display.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
// The authorization check is the point of routing this through the service at all.
|
||||
// Every other member here is read-only; this one causes the browse session to publish onto
|
||||
// a live plant broker, so it is gated on the same DriverOperator policy the picker
|
||||
// bodies evaluate before rendering their Browse affordance. A render-time check alone is
|
||||
// not a control — it decides what a page draws, not what a circuit may invoke — so the
|
||||
// check is made here, where the action actually happens, and it fails closed.
|
||||
// Deliberately not wrapped in -style swallowing: unlike
|
||||
// a failed expand, a failed or refused rebirth is something the operator must see, so the
|
||||
// exception propagates to the caller for display.
|
||||
public async Task<int> RequestRebirthAsync(Guid token, string scope, CancellationToken ct)
|
||||
{
|
||||
if (!registry.TryGet(token, out var session))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Browsing;
|
||||
|
||||
/// <summary>
|
||||
/// Validates an operator-typed Sparkplug rebirth scope for the raw browse modal (Gitea #514).
|
||||
/// Validates an operator-typed Sparkplug rebirth scope for the raw browse modal.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
|
||||
@@ -29,5 +29,6 @@ public sealed class ContextMenuItem
|
||||
public bool IsSeparator { get; init; }
|
||||
|
||||
/// <summary>A horizontal divider between groups of items.</summary>
|
||||
/// <returns>A separator menu item.</returns>
|
||||
public static ContextMenuItem Separator() => new() { IsSeparator = true };
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared;
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why this exists (#504).</b> Several pages abbreviated a hash / thumbprint with the range
|
||||
/// <b>Why this exists.</b> Several pages abbreviated a hash / thumbprint with the range
|
||||
/// operator — <c>@s.SourceHash[..12]…</c> — which throws
|
||||
/// <see cref="ArgumentOutOfRangeException"/> the moment the value is shorter than the slice.
|
||||
/// In Blazor that escapes <c>BuildRenderTree</c> unhandled and takes the <b>whole page</b> to an
|
||||
|
||||
+3
-2
@@ -4,7 +4,7 @@ using System.Text.Json.Nodes;
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers;
|
||||
|
||||
/// <summary>
|
||||
/// Preserve-unknown save for the typed driver forms (#576). A form deserialises the stored DriverConfig
|
||||
/// Preserve-unknown save for the typed driver forms. A form deserialises the stored DriverConfig
|
||||
/// into a typed options object and re-serialises it on save, which erases every key the type does not
|
||||
/// model (a newer option, a hand-edited key, <c>rawTags</c>, a device's extra keys…). This overlays only
|
||||
/// the paths the form OWNS onto the ORIGINAL JSON object: an owned path is replaced by the form's value,
|
||||
@@ -16,9 +16,10 @@ internal static class DriverConfigJsonOverlay
|
||||
/// <param name="originalJson">The stored config as loaded; blank or non-object input yields <paramref name="typedJson"/> unchanged.</param>
|
||||
/// <param name="typedJson">The form's own serialised options.</param>
|
||||
/// <param name="ownedPaths">Dot-separated, case-insensitive property paths the form edits (e.g. <c>transport</c>, <c>probe.enabled</c>).</param>
|
||||
/// <returns>The merged JSON document.</returns>
|
||||
/// <param name="timeSpanToMs">
|
||||
/// (TimeSpan-string path, integer-millisecond path) pairs. The typed options serialise durations as
|
||||
/// <c>"00:00:05"</c> but the driver factories bind <c>*Ms</c> integers (#576); each pair is rewritten
|
||||
/// <c>"00:00:05"</c> but the driver factories bind <c>*Ms</c> integers; each pair is rewritten
|
||||
/// in the typed output (and the legacy TimeSpan path becomes owned, so a stale one is removed).
|
||||
/// </param>
|
||||
public static string Apply(string? originalJson, string typedJson, IEnumerable<string> ownedPaths,
|
||||
|
||||
@@ -4,13 +4,16 @@ using System.Text.Json.Nodes;
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers;
|
||||
|
||||
/// <summary>
|
||||
/// Shared millisecond helpers for the AB forms (#576): the driver factories bind <c>*Ms</c> integers
|
||||
/// Shared millisecond helpers for the AB forms: the driver factories bind <c>*Ms</c> integers
|
||||
/// while the forms edit whole seconds, so a sub-second stored value must survive an edit of an
|
||||
/// unrelated field.
|
||||
/// </summary>
|
||||
internal static class DriverConfigMs
|
||||
{
|
||||
/// <summary>Reads an integer-millisecond key at <paramref name="path"/> from <paramref name="json"/>, or null.</summary>
|
||||
/// <param name="json">The driver config JSON, which may be null or blank.</param>
|
||||
/// <param name="path">The dot-separated key path to read.</param>
|
||||
/// <returns>The integer value, or null when the key is absent or not an integer.</returns>
|
||||
public static int? Read(string? json, string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return null;
|
||||
@@ -27,13 +30,18 @@ internal static class DriverConfigMs
|
||||
catch (JsonException) { return null; }
|
||||
}
|
||||
|
||||
/// <summary>Whole seconds for display (truncating, like the pre-#576 forms).</summary>
|
||||
/// <summary>Whole seconds for display (truncating, like the earlier forms).</summary>
|
||||
/// <param name="t">The duration to convert.</param>
|
||||
/// <returns>The whole seconds in <paramref name="t"/>.</returns>
|
||||
public static int ToSeconds(TimeSpan t) => (int)t.TotalSeconds;
|
||||
|
||||
/// <summary>
|
||||
/// The duration to save: the loaded value when the operator left the seconds field unchanged
|
||||
/// (keeps 500 ms from collapsing to 0 s), else the edited whole seconds.
|
||||
/// </summary>
|
||||
/// <param name="seconds">The whole seconds currently shown in the form.</param>
|
||||
/// <param name="loaded">The duration originally loaded from config, if any.</param>
|
||||
/// <returns>The duration to persist.</returns>
|
||||
public static TimeSpan Save(int seconds, TimeSpan? loaded)
|
||||
=> loaded is { } l && ToSeconds(l) == seconds ? l : TimeSpan.FromSeconds(seconds);
|
||||
}
|
||||
|
||||
+2
-2
@@ -31,7 +31,7 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms;
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Preserving what this form does not author.</b> The inbound blob's keys are retained in a bag
|
||||
/// and the typed fields are written over it, so (a) the <c>Sparkplug</c> sub-object — P2, Task 21+ —
|
||||
/// and the typed fields are written over it, so (a) the <c>Sparkplug</c> sub-object (a later phase)
|
||||
/// survives a load→save untouched, and (b) any key a newer driver adds is not silently dropped by an
|
||||
/// older AdminUI. <c>RawTags</c> is the one key deliberately REMOVED: the deploy artifact injects it
|
||||
/// via <c>DriverDeviceConfigMerger</c>, and a form-authored copy would be dead weight.
|
||||
@@ -149,7 +149,7 @@ public sealed class MqttDriverFormModel
|
||||
public string SparkplugHostId { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Sparkplug mode: claim the Primary Host Application role (Gitea #511) — a retained
|
||||
/// Sparkplug mode: claim the Primary Host Application role — a retained
|
||||
/// <c>ONLINE</c>, a matching <c>OFFLINE</c> armed as the MQTT Last Will, and an explicit
|
||||
/// <c>OFFLINE</c> on clean shutdown.
|
||||
/// </summary>
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms;
|
||||
|
||||
/// <summary>
|
||||
/// The rule deciding which primary-host advisory <c>MqttDriverForm</c> shows when
|
||||
/// <c>Act as Primary Host</c> is on (Gitea #511).
|
||||
/// <c>Act as Primary Host</c> is on.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
|
||||
+2
-2
@@ -20,7 +20,7 @@ public enum FocasManualAddressKind
|
||||
Macro,
|
||||
|
||||
/// <summary>
|
||||
/// A selector over the CNC's active alarm list (<c>ALARM:</c>, Gitea #647). Only useful on a tag that also
|
||||
/// A selector over the CNC's active alarm list (<c>ALARM:</c>). Only useful on a tag that also
|
||||
/// carries the native-alarm object — the pair is what becomes a Part 9 condition.
|
||||
/// </summary>
|
||||
Alarm,
|
||||
@@ -40,7 +40,7 @@ public enum FocasManualAddressKind
|
||||
/// <c>axis/spindle/program/status</c> — none of which is a FOCAS addressing space — so the
|
||||
/// picker's own default value, <c>axis:5</c>, was an address the driver rejects. Together with
|
||||
/// the browse-commit defect this meant every UI-assisted authoring path produced unusable tags,
|
||||
/// and only hand-typed addresses worked at all (Gitea #540).
|
||||
/// and only hand-typed addresses worked at all.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class FocasAddressBuilder
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ public sealed class ResilienceFormModel
|
||||
public static readonly string[] Capabilities =
|
||||
["Read", "Write", "Discover", "Subscribe", "Probe", "AlarmSubscribe", "AlarmAcknowledge", "Initialize"];
|
||||
|
||||
// RecycleIntervalSeconds is GONE (Gitea #522) — the scheduled-recycle machinery it authored was
|
||||
// RecycleIntervalSeconds is GONE — the scheduled-recycle machinery it authored was
|
||||
// deleted, so the field configured nothing. It is deliberately NOT explicitly stripped from stored
|
||||
// JSON: the model's preserve-unknown-keys contract now covers it, so an existing blob's
|
||||
// "recycleIntervalSeconds" survives an unrelated edit in _bag rather than being silently dropped,
|
||||
|
||||
@@ -71,15 +71,15 @@ public static class EndpointRouteBuilderExtensions
|
||||
services.AddScoped<Browsing.IBrowserSessionService, Browsing.BrowserSessionService>();
|
||||
services.AddScoped<IUnsTreeService, UnsTreeService>();
|
||||
services.AddScoped<IRawTreeService, RawTreeService>();
|
||||
// v3 Batch 3 (WP2): authoring-time UNS effective-name uniqueness guard. Consumed by
|
||||
// UnsTreeService's reference/VirtualTag/ScriptedAlarm mutations (WP1); mirrors the
|
||||
// v3 Batch 3: authoring-time UNS effective-name uniqueness guard. Consumed by
|
||||
// UnsTreeService's reference/VirtualTag/ScriptedAlarm mutations; mirrors the
|
||||
// deploy-time DraftValidator UnsEffectiveNameCollision rule.
|
||||
services.AddScoped<IEffectiveNameGuard, EffectiveNameGuard>();
|
||||
// #520 stage 7: the ACL scope picker's two trees + the authoring-time scope/principal checks that
|
||||
// the ACL scope picker's two trees + the authoring-time scope/principal checks that
|
||||
// mirror the deploy gate's NodeAclScope* rules. Scoped, because it composes the two scoped tree
|
||||
// services above.
|
||||
services.AddScoped<Acls.IAclScopeService, Acls.AclScopeService>();
|
||||
// WP5 CSV export enumeration (read-only; does not extend the closed IRawTreeService prelude).
|
||||
// CSV export enumeration (read-only; does not extend the closed IRawTreeService prelude).
|
||||
services.AddScoped<RawTagCsvExportReader>();
|
||||
services.AddSingleton<IDriverBrowser, OpcUaClientDriverBrowser>();
|
||||
services.AddSingleton<IDriverBrowser, GalaxyDriverBrowser>();
|
||||
|
||||
@@ -30,7 +30,7 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Grpc;
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>NotFound is indistinguishable across two cases</b> — unknown id and an empty blob. The
|
||||
/// first is existence-hiding; the second is the #485 rule (a zero-length artifact is "no
|
||||
/// first is existence-hiding; the second is the empty-artifact rule (a zero-length artifact is "no
|
||||
/// answer," never "a configuration with no drivers"). Streaming empty bytes as if valid is
|
||||
/// exactly what that defect class forbids.
|
||||
/// </para>
|
||||
@@ -74,7 +74,7 @@ public sealed class DeploymentArtifactGrpcService : GeneratedServiceBase
|
||||
.Select(d => new { d.ArtifactBlob })
|
||||
.FirstOrDefaultAsync(context.CancellationToken);
|
||||
|
||||
// Existence-hiding + #485: unknown id / empty blob collapse to one NotFound. Status is NOT
|
||||
// Existence-hiding + the empty-artifact rule: unknown id / empty blob collapse to one NotFound. Status is NOT
|
||||
// gated — a node fetches during apply, before the deployment is sealed (see the class remarks).
|
||||
if (row is null || row.ArtifactBlob.Length == 0)
|
||||
{
|
||||
|
||||
@@ -51,11 +51,11 @@ public sealed record HostsDriverInstanceInfo(
|
||||
/// <param name="SkippedTags">Authored tags the driver could not build into its poll table; null when there
|
||||
/// is no answer — the driver cannot report skips, or it faulted before building a tag table. Lets
|
||||
/// "ignoring 20 of 22 tags" show even while every other column on the row — state, last read, error
|
||||
/// count, last error — reads perfectly healthy (Gitea #541).</param>
|
||||
/// count, last error — reads perfectly healthy.</param>
|
||||
/// <param name="Diagnostics">Driver-reported diagnostics (e.g. S7 negotiated PDU size); null or empty when
|
||||
/// the driver has none to show (Gitea #640).</param>
|
||||
/// the driver has none to show.</param>
|
||||
/// <param name="Enabled">Whether the matching configured instance is authored enabled; <see langword="true"/>
|
||||
/// when no instance matched. Fed to the row's expandable <c>DriverStatusPanel</c> (Gitea #546). An
|
||||
/// when no instance matched. Fed to the row's expandable <c>DriverStatusPanel</c>. An
|
||||
/// unmatched row defaults to enabled on purpose: the row only exists because a live snapshot arrived,
|
||||
/// so telling the operator it is "not deployed" would contradict the evidence in front of them.</param>
|
||||
public sealed record HostsDriverRow(
|
||||
|
||||
@@ -60,10 +60,10 @@ public sealed class InProcessBroadcaster<T> : IInProcessBroadcaster<T>
|
||||
private readonly object _connectionLock = new();
|
||||
private bool _isConnected;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Raised once per <see cref="Publish"/> with the published item, on the caller's thread.</summary>
|
||||
public event Action<T>? Received;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Raised whenever <see cref="IsConnected"/> changes, with the new value, on the caller's thread.</summary>
|
||||
public event Action<bool>? ConnectionStateChanged;
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -11,7 +11,7 @@ public sealed class InMemoryDriverResilienceStatusStore : IDriverResilienceStatu
|
||||
{
|
||||
private readonly ConcurrentDictionary<(string Instance, string Host), DriverResilienceStatusChanged> _byPair = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Raised after every <see cref="Upsert"/> with the just-stored snapshot.</summary>
|
||||
public event Action<DriverResilienceStatusChanged>? SnapshotChanged;
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -11,7 +11,7 @@ public sealed class InMemoryDriverStatusSnapshotStore : IDriverStatusSnapshotSto
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, DriverHealthChanged> _byInstance = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Raised whenever a driver status snapshot is stored or updated.</summary>
|
||||
public event Action<DriverHealthChanged>? SnapshotChanged;
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -100,6 +100,7 @@ public sealed class ManualFailoverPageModel
|
||||
/// Performs the failover the dialog is confirming.
|
||||
/// </summary>
|
||||
/// <param name="actor">The authenticated user name, recorded in the audit event.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async Task ConfirmFailoverAsync(string actor)
|
||||
{
|
||||
if (!ConfirmOpen) return;
|
||||
|
||||
@@ -55,7 +55,7 @@ public sealed record ScriptTagInfo(string Path, string Kind, string DataType, st
|
||||
/// (<c>DriverHostActor._nodeIdByDriverRef</c> is keyed <c>(DriverInstanceId, RawPath)</c>).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>This replaced a stale v2 projection (Gitea #490).</b> The catalog used to emit
|
||||
/// <b>This replaced a stale v2 projection.</b> The catalog used to emit
|
||||
/// <c>Tag.TagConfig.FullName</c> — the pre-v3 wire address — which since v3 is <i>not</i> the mux
|
||||
/// key. The consequence was worse than the missing feature the issue reported: completion offered
|
||||
/// paths that could never resolve at runtime, while a <b>correct</b> absolute RawPath got no
|
||||
|
||||
@@ -35,13 +35,11 @@ public sealed class EffectiveNameGuard(IDbContextFactory<OtOpcUaConfigDbContext>
|
||||
return FirstCollision(nameSpace, proposedEffectiveName, kind, excludeSourceId, equipmentId);
|
||||
}
|
||||
|
||||
// The whole point of the batch entry point: ONE context and ONE load of the equipment's name
|
||||
// space, then every proposed name tested in memory against it - the per-name rules and message
|
||||
// text come from the same FirstCollision helper CheckAsync uses, so the two can never drift. A
|
||||
// batch is a create of N new rows, so there is no self-row to exclude.
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// The whole point of the batch entry point: ONE context and ONE load of the equipment's name
|
||||
/// space, then every proposed name tested in memory against it — the per-name rules and message
|
||||
/// text come from the same <see cref="FirstCollision"/> helper <see cref="CheckAsync"/> uses, so
|
||||
/// the two can never drift. A batch is a create of N new rows, so there is no self-row to exclude.
|
||||
/// </remarks>
|
||||
public async Task<string?> CheckManyAsync(
|
||||
string equipmentId,
|
||||
IReadOnlyList<string> proposedEffectiveNames,
|
||||
|
||||
@@ -3,8 +3,8 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// Detached, editable projection of a raw <c>Tag</c> — the read side of tag editing. WP1 exposes this
|
||||
/// loader; WP4 owns the create/update authoring surface and should align its input model with these
|
||||
/// Detached, editable projection of a raw <c>Tag</c> — the read side of tag editing. The create/update
|
||||
/// authoring surface should align its input model with these
|
||||
/// fields (identity is the RawPath, formed from the tag's device + optional group + name).
|
||||
/// </summary>
|
||||
/// <param name="TagId">The tag's stable logical id.</param>
|
||||
@@ -30,7 +30,7 @@ public readonly record struct RawTagEditDto(
|
||||
byte[] RowVersion);
|
||||
|
||||
/// <summary>
|
||||
/// Detached, editable projection of a <c>DriverInstance</c> — the read side of the Wave-B (WP3)
|
||||
/// Detached, editable projection of a <c>DriverInstance</c> — the read side of the
|
||||
/// "Configure driver" modal. <c>DriverType</c> is included so the modal can dispatch to the correct
|
||||
/// typed driver-config editor; it is immutable, so <see cref="IRawTreeService.UpdateDriverAsync"/> does
|
||||
/// not change it.
|
||||
@@ -50,7 +50,7 @@ public sealed record RawDriverEditDto(
|
||||
byte[] RowVersion);
|
||||
|
||||
/// <summary>
|
||||
/// Detached, editable projection of a <c>Device</c> — the read side of the Wave-B (WP3) "Configure
|
||||
/// Detached, editable projection of a <c>Device</c> — the read side of the "Configure
|
||||
/// device" modal. <c>DriverType</c> is joined in from the parent <c>DriverInstance</c> so the modal can
|
||||
/// dispatch to the correct typed device-config editor (endpoint/connection settings now live in
|
||||
/// <c>DeviceConfig</c>).
|
||||
@@ -76,14 +76,14 @@ public sealed record RawDeviceEditDto(
|
||||
/// Enterprise → Cluster → Folder → Driver → Device → TagGroup → Tag hierarchy — from the config
|
||||
/// database. The peer of <see cref="IUnsTreeService"/> for the Raw subtree.
|
||||
/// <para>
|
||||
/// <b>Contract split (Batch 2 Wave A):</b> this file's committed surface is the <b>read</b> contract
|
||||
/// <b>Contract split:</b> this file's committed surface is the <b>read</b> contract
|
||||
/// the <c>RawTree</c> component consumes — <see cref="LoadRootsAsync"/> (eager Enterprise→Cluster roots)
|
||||
/// and <see cref="LoadChildrenAsync"/> (one lazy level per expand). B2-WP1 (<c>RawTreeService</c>)
|
||||
/// and <see cref="LoadChildrenAsync"/> (one lazy level per expand). <c>RawTreeService</c>
|
||||
/// <b>extends this interface</b> with the mutation surface — create/rename/delete per node type,
|
||||
/// move-into-folder, and the per-node edit projections — enforcing the integrity rules with
|
||||
/// user-readable errors (a delete blocked by a <c>UnsTagReference</c> names the referencing equipment).
|
||||
/// Rename methods return <see cref="RawRenameResult"/> so the UI can show non-blocking warnings.
|
||||
/// Mutations reuse <see cref="UnsMutationResult"/>. WP2 must not add to this interface.
|
||||
/// Mutations reuse <see cref="UnsMutationResult"/>. Do not add to this interface from other services.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public interface IRawTreeService
|
||||
@@ -105,7 +105,7 @@ public interface IRawTreeService
|
||||
/// yields its Devices; a Device yields its root TagGroups + Tags; a TagGroup yields sub-TagGroups
|
||||
/// + Tags. Tags are leaves and yield nothing. Ordering is deterministic and ordinal. Reads
|
||||
/// untracked; returns detached nodes. Per-device tag counts can be large — the implementation may
|
||||
/// page, but the committed shape returns the level's nodes (paging knobs, if any, are WP1's to add
|
||||
/// page, but the committed shape returns the level's nodes (paging knobs, if any, belong
|
||||
/// on the concrete type without changing this signature's meaning for the tree).
|
||||
/// </summary>
|
||||
/// <param name="parent">The container node whose direct children to load.</param>
|
||||
@@ -167,7 +167,7 @@ public interface IRawTreeService
|
||||
|
||||
/// <summary>
|
||||
/// Creates a minimal driver instance and its auto-created default <c>Device</c> so the tree has a
|
||||
/// child to show. Full typed driver/device config authoring is Wave B (WP3) — this create takes the
|
||||
/// child to show. Full typed driver/device config authoring is not done here — this create takes the
|
||||
/// driver config JSON verbatim and seeds an empty (<c>{}</c>) default device. The driver name is a
|
||||
/// RawPath segment, unique among its cluster/folder siblings.
|
||||
/// </summary>
|
||||
@@ -175,7 +175,7 @@ public interface IRawTreeService
|
||||
/// <param name="folderId">The containing folder's logical id, or null for a cluster-root driver.</param>
|
||||
/// <param name="name">The driver instance name (a RawPath segment).</param>
|
||||
/// <param name="driverType">The driver-type string (see <c>DriverTypeNames</c>).</param>
|
||||
/// <param name="driverConfigJson">The driver config JSON blob (opaque here; validated in Wave B).</param>
|
||||
/// <param name="driverConfigJson">The driver config JSON blob (opaque here; validated by the typed editors).</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>The create outcome; <c>CreatedId</c> is the new <c>DriverInstanceId</c> on success.</returns>
|
||||
Task<UnsMutationResult> CreateDriverAsync(string clusterId, string? folderId, string name, string driverType, string driverConfigJson, CancellationToken ct = default);
|
||||
@@ -212,13 +212,13 @@ public interface IRawTreeService
|
||||
// --- Device mutations ---
|
||||
|
||||
/// <summary>
|
||||
/// Creates a minimal <c>Device</c> under a driver. Full typed device-config authoring is Wave B
|
||||
/// (WP3) — this create takes the device config JSON verbatim. The device name must be unique among
|
||||
/// Creates a minimal <c>Device</c> under a driver. Full typed device-config authoring is not done here
|
||||
/// — this create takes the device config JSON verbatim. The device name must be unique among
|
||||
/// the driver's devices.
|
||||
/// </summary>
|
||||
/// <param name="driverInstanceId">The owning driver instance's logical id.</param>
|
||||
/// <param name="name">The device name (a RawPath segment).</param>
|
||||
/// <param name="deviceConfigJson">The device config JSON blob (opaque here; validated in Wave B).</param>
|
||||
/// <param name="deviceConfigJson">The device config JSON blob (opaque here; validated by the typed editors).</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>The create outcome; <c>CreatedId</c> is the new <c>DeviceId</c> on success.</returns>
|
||||
Task<UnsMutationResult> CreateDeviceAsync(string driverInstanceId, string name, string deviceConfigJson, CancellationToken ct = default);
|
||||
@@ -272,7 +272,7 @@ public interface IRawTreeService
|
||||
/// <returns>The delete outcome.</returns>
|
||||
Task<UnsMutationResult> DeleteTagGroupAsync(string tagGroupId, byte[] rowVersion, CancellationToken ct = default);
|
||||
|
||||
// --- Tag mutations / projections (create + update are WP4) ---
|
||||
// --- Tag mutations / projections ---
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a raw <c>Tag</c>. Blocked while any <c>UnsTagReference</c> points at it; the failure
|
||||
@@ -285,9 +285,9 @@ public interface IRawTreeService
|
||||
Task<UnsMutationResult> DeleteTagAsync(string tagId, byte[] rowVersion, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Loads a raw tag's editable fields as a detached projection. WP1 provides the read-only projection;
|
||||
/// the create/update authoring surface is WP4 (which should align its input shape with
|
||||
/// <see cref="RawTagEditDto"/>).
|
||||
/// Loads a raw tag's editable fields as a detached projection. The create/update authoring
|
||||
/// surface should align its input shape with
|
||||
/// <see cref="RawTagEditDto"/>.
|
||||
/// </summary>
|
||||
/// <param name="tagId">The tag's logical id.</param>
|
||||
/// <param name="ct">A token to cancel the load.</param>
|
||||
@@ -322,7 +322,7 @@ public interface IRawTreeService
|
||||
Task<UnsMutationResult> UpdateTagAsync(string tagId, RawTagInput input, byte[] rowVersion, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Commits a batch of raw tags under a device — the WP5 CSV-import path. Each row's
|
||||
/// Commits a batch of raw tags under a device — the CSV-import path. Each row's
|
||||
/// <c>TagGroupPath</c> is a <c>/</c>-separated tag-group path under the device whose segments are
|
||||
/// auto-created as nested <c>TagGroup</c>s (reusing existing ones). Per-row validation errors (bad
|
||||
/// name segment, malformed <c>TagConfig</c> JSON, a duplicate name within the device+group) are
|
||||
@@ -337,7 +337,7 @@ public interface IRawTreeService
|
||||
Task<RawTagImportResult> ImportTagsAsync(string deviceId, IReadOnlyList<RawTagImportRow> rows, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Loads a driver instance's editable fields as a detached projection for the WP3 "Configure driver"
|
||||
/// Loads a driver instance's editable fields as a detached projection for the "Configure driver"
|
||||
/// modal. <c>DriverType</c> is included for typed-editor dispatch (it is immutable).
|
||||
/// </summary>
|
||||
/// <param name="driverInstanceId">The driver instance's logical id.</param>
|
||||
@@ -346,7 +346,7 @@ public interface IRawTreeService
|
||||
Task<RawDriverEditDto?> LoadDriverForEditAsync(string driverInstanceId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates a driver instance's name + config from the WP3 "Configure driver" modal. The name is
|
||||
/// Updates a driver instance's name + config from the "Configure driver" modal. The name is
|
||||
/// validated as a sibling-unique RawPath segment (same scope as <see cref="RenameDriverAsync"/>);
|
||||
/// last-write-wins on <paramref name="rowVersion"/>. <c>DriverType</c> is immutable and is not changed.
|
||||
/// </summary>
|
||||
@@ -360,7 +360,7 @@ public interface IRawTreeService
|
||||
Task<UnsMutationResult> UpdateDriverAsync(string driverInstanceId, string name, string driverConfigJson, string? resilienceConfig, byte[] rowVersion, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Loads a device's editable fields as a detached projection for the WP3 "Configure device" modal.
|
||||
/// Loads a device's editable fields as a detached projection for the "Configure device" modal.
|
||||
/// <c>DriverType</c> is joined in from the parent driver so the modal can dispatch to the correct typed
|
||||
/// device-config editor.
|
||||
/// </summary>
|
||||
@@ -370,7 +370,7 @@ public interface IRawTreeService
|
||||
Task<RawDeviceEditDto?> LoadDeviceForEditAsync(string deviceId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates a device's name + config + enabled state from the WP3 "Configure device" modal. The name
|
||||
/// Updates a device's name + config + enabled state from the "Configure device" modal. The name
|
||||
/// is validated as a RawPath segment unique among the driver's devices; last-write-wins on
|
||||
/// <paramref name="rowVersion"/>.
|
||||
/// </summary>
|
||||
@@ -387,7 +387,7 @@ public interface IRawTreeService
|
||||
/// Builds the single merged config blob the driver-probe (test-connect) path binds from for one device:
|
||||
/// the parent driver's <c>DriverConfig</c> folded together with this device's <c>DeviceConfig</c> (via
|
||||
/// <c>DriverDeviceConfigMerger</c>, endpoint keys merging up from <c>DeviceConfig</c>). Used by the
|
||||
/// "Test connect" button inside the WP3 driver/device modals now that the endpoint lives in
|
||||
/// "Test connect" button inside the driver/device modals now that the endpoint lives in
|
||||
/// <c>DeviceConfig</c>.
|
||||
/// </summary>
|
||||
/// <param name="deviceId">The device to probe.</param>
|
||||
|
||||
@@ -12,7 +12,7 @@ using ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// Pure mapper for the WP6 "Browse device…" re-target: turns a selected driver-browse leaf into a
|
||||
/// Pure mapper for the "Browse device…" re-target: turns a selected driver-browse leaf into a
|
||||
/// <see cref="RawTagImportRow"/> ready for <see cref="IRawTreeService.ImportTagsAsync"/>. Under the v3
|
||||
/// identity contract the tag's identity is its RawPath (device + optional group + name), so the leaf's
|
||||
/// driver reference is written into the driver-typed <c>TagConfig</c> as an ordinary <b>address field</b>
|
||||
@@ -30,7 +30,7 @@ public static class RawBrowseCommitMapper
|
||||
/// captured <c>DriverAttributeInfo.FullName</c>) — becomes the driver-typed address field.</param>
|
||||
/// <param name="browseName">The leaf's browse name. Becomes the raw tag <c>Name</c> after
|
||||
/// <see cref="ToRawSegment"/> coerces it into a legal RawPath segment — a Sparkplug metric name
|
||||
/// legitimately contains <c>/</c> and a Name may not (Gitea #509). The leaf's binding address is
|
||||
/// legitimately contains <c>/</c> and a Name may not. The leaf's binding address is
|
||||
/// unaffected: it travels separately into the <c>TagConfig</c>.</param>
|
||||
/// <param name="driverDataType">The leaf's <see cref="DriverDataType"/> name (from the browse
|
||||
/// attribute side-channel), or null when the browser cannot report a type (e.g. the OPC UA Client tree).</param>
|
||||
@@ -72,7 +72,7 @@ public static class RawBrowseCommitMapper
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Coerces a browse leaf's name into a legal RawPath segment (Gitea #509): trims the ends and
|
||||
/// Coerces a browse leaf's name into a legal RawPath segment: trims the ends and
|
||||
/// replaces every <see cref="RawPaths.Separator"/> with <see cref="SeparatorReplacement"/>.
|
||||
/// </summary>
|
||||
/// <param name="browseName">The leaf's browse name, as the driver reported it.</param>
|
||||
@@ -122,7 +122,7 @@ public static class RawBrowseCommitMapper
|
||||
/// <returns>One operator-facing message per colliding set; empty when there is nothing to refuse.</returns>
|
||||
/// <remarks>
|
||||
/// Sanitising maps many names onto one — <c>A/B</c> and <c>A_B</c> both become <c>A_B</c> — and a
|
||||
/// silent collision is materially worse than the refusal #509 replaced: two distinct metrics would
|
||||
/// silent collision is materially worse than the refusal it replaced: two distinct metrics would
|
||||
/// become one tag bound to whichever address the import happened to keep. So the coercion is only
|
||||
/// safe when paired with this check.
|
||||
/// <para>
|
||||
@@ -205,7 +205,7 @@ public static class RawBrowseCommitMapper
|
||||
/// <b>The data type is not optional decoration for AB CIP / TwinCAT / FOCAS</b> — their tag-definition
|
||||
/// factories decode the wire bytes by this field, not by the raw tag row's OPC UA <c>DataType</c>. A
|
||||
/// committed leaf left at the model default therefore reads real data through the wrong decoder and
|
||||
/// publishes a plausible wrong number rather than an error (Gitea #536).
|
||||
/// publishes a plausible wrong number rather than an error.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>MQTT is the one driver whose address is not a single string</b> — a Sparkplug tag binds by a
|
||||
@@ -214,13 +214,13 @@ public static class RawBrowseCommitMapper
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>MTConnect binds by the driver-AGNOSTIC type, so it is forwarded from
|
||||
/// <paramref name="driverDataType"/> rather than from a stated native field</b> (Gitea #560). Its
|
||||
/// <paramref name="driverDataType"/> rather than from a stated native field</b>. Its
|
||||
/// coercion type IS a <see cref="DriverDataType"/> — the very enum the browse leaf carries, produced
|
||||
/// by the driver's own <c>/probe</c> inference — so there is no separate native spelling for the
|
||||
/// browser to state and nothing to re-derive. Left unforwarded, the model's <c>String</c> default was
|
||||
/// written as a non-blank authored type, which OUT-RANKS that same inference at the driver: every
|
||||
/// browsed SAMPLE published text into the numeric node <see cref="MapLeaf"/> had just typed from the
|
||||
/// identical inference. Same defect class as #536, one door along.
|
||||
/// identical inference. Same defect class as the type-inference bug above, one door along.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="driverType">The owning device's driver type.</param>
|
||||
@@ -268,7 +268,7 @@ public static class RawBrowseCommitMapper
|
||||
// editor's canonical spelling is fullName — committing under the generic key below would open
|
||||
// in that editor with an EMPTY id field and blank it on save.
|
||||
//
|
||||
// The coercion type is the leaf's own inferred DriverDataType (#560) — see the remarks above.
|
||||
// The coercion type is the leaf's own inferred DriverDataType — see the remarks above.
|
||||
// A stated native "dataType" address field still wins where a browser supplies one, so this
|
||||
// branch reads exactly like its AB CIP / TwinCAT / FOCAS siblings; the difference is only that
|
||||
// MTConnect's fallback is the leaf's inference rather than a fixed model default.
|
||||
@@ -295,11 +295,11 @@ public static class RawBrowseCommitMapper
|
||||
// - a driver type with a bespoke IDriverBrowser registered by AddAdminUI() — read back from the
|
||||
// real registration, not a list — must NOT reach here; and
|
||||
// - any type that does reach here must be named in that test's GenericKeyExemptions with a reason,
|
||||
// and every name on that list must still reach here (Gitea #558) — an exemption for a driver
|
||||
// and every name on that list must still reach here — an exemption for a driver
|
||||
// that has since gained a branch above is a stale claim, and a silently inert one.
|
||||
// The universal DiscoveryDriverBrowser's arm is not derivable from the AdminUI assembly (it needs
|
||||
// the runtime drivers + the Host's IDriverFactory), so it is covered by the exemption list rather
|
||||
// than mechanically — see the test's remarks for the residual trust that leaves (Gitea #555).
|
||||
// than mechanically — see the test's remarks for the residual trust that leaves.
|
||||
return WriteSingleKey("address", address);
|
||||
}
|
||||
|
||||
@@ -383,7 +383,7 @@ public static class RawBrowseCommitMapper
|
||||
/// <c>BadNodeIdUnknown</c> forever with nothing to point at.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Sql</b> (Gitea #552): a column of a table carrying no date/time column. A wide-row tag must say
|
||||
/// <b>Sql</b>: a column of a table carrying no date/time column. A wide-row tag must say
|
||||
/// which ROW it reads, and with no timestamp to order by the only remaining selector is a
|
||||
/// <c>whereColumn</c>/<c>whereValue</c> pair — whose value is <em>data</em>, not schema, and therefore
|
||||
/// is not something any browse can know. Refusing here rather than letting the import's own gate
|
||||
@@ -418,7 +418,7 @@ public static class RawBrowseCommitMapper
|
||||
|
||||
/// <summary>
|
||||
/// Describes a Sql column leaf whose wide-row binding cannot be completed from the catalog, or
|
||||
/// <c>null</c> when it can (Gitea #552).
|
||||
/// <c>null</c> when it can.
|
||||
/// </summary>
|
||||
/// <param name="browseName">The leaf's browse name, for the message.</param>
|
||||
/// <param name="addressFields">The structured address the browse session stated, if any.</param>
|
||||
@@ -451,7 +451,7 @@ public static class RawBrowseCommitMapper
|
||||
/// <param name="fallback">The model default to keep when no usable native type was stated.</param>
|
||||
/// <returns>The parsed native type, or <paramref name="fallback"/>.</returns>
|
||||
/// <remarks>
|
||||
/// <b>The type is READ, never derived (Gitea #536).</b> A commit used to leave this at the
|
||||
/// <b>The type is READ, never derived.</b> A commit used to leave this at the
|
||||
/// model default, so every browsed AB CIP leaf — a REAL, a BOOL, a STRING — was written as
|
||||
/// <c>DInt</c> and the driver decoded four bytes of a float as an integer: a plausible
|
||||
/// number, no error, wrong value. Deriving it from the leaf's driver-AGNOSTIC
|
||||
@@ -480,7 +480,7 @@ public static class RawBrowseCommitMapper
|
||||
/// Parses one stated type NAME into a driver's data-type enum, keeping <paramref name="fallback"/>
|
||||
/// for anything unusable — the rule <see cref="NativeType{TEnum}"/> applies to a stated address
|
||||
/// field, split out so a type that arrives by another route (MTConnect's leaf-level
|
||||
/// <see cref="DriverDataType"/>, Gitea #560) is judged by exactly the same one.
|
||||
/// <see cref="DriverDataType"/>) is judged by exactly the same one.
|
||||
/// </summary>
|
||||
/// <typeparam name="TEnum">The data-type enum the driver's tag config carries.</typeparam>
|
||||
/// <param name="raw">The stated type name, or null/blank.</param>
|
||||
@@ -509,7 +509,7 @@ public static class RawBrowseCommitMapper
|
||||
: null;
|
||||
|
||||
/// <summary>
|
||||
/// Combines a target-group path prefix with an optional mirrored sub-path, matching the WP5 CSV import
|
||||
/// Combines a target-group path prefix with an optional mirrored sub-path, matching the CSV import
|
||||
/// combine semantics: blank collapses to null, and a present prefix + suffix join with the RawPath
|
||||
/// separator. Returns null when both are absent (⇒ commit at the device root).
|
||||
/// </summary>
|
||||
@@ -542,10 +542,10 @@ public static class RawBrowseCommitMapper
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>A wide-row tag needs three things, not two: the table, the column, and WHICH ROW</b>
|
||||
/// (Gitea #552). This branch used to emit only the first two, and a selector-less wide-row blob is
|
||||
/// This branch used to emit only the first two, and a selector-less wide-row blob is
|
||||
/// refused by <c>SqlEquipmentTagParser</c> and by <c>SqlTagConfigModel.Validate</c> alike — so every
|
||||
/// browse-committed Sql tag was unreadable, first silently (<c>BadNodeIdUnknown</c> forever) and then,
|
||||
/// once #551 put the gate on the import, loudly at commit.
|
||||
/// once the gate was put on the import, loudly at commit.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The row selector is READ from the session's stated fields, never inferred here</b> — same rule
|
||||
|
||||
@@ -15,10 +15,14 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
public readonly record struct RawRenameResult(bool Ok, string? Error, IReadOnlyList<string> Warnings)
|
||||
{
|
||||
/// <summary>A successful rename carrying zero or more advisory warnings.</summary>
|
||||
/// <param name="warnings">Non-blocking advisories to show after the rename, or <see langword="null"/> for none.</param>
|
||||
/// <returns>A successful <see cref="RawRenameResult"/>.</returns>
|
||||
public static RawRenameResult Success(IReadOnlyList<string>? warnings = null) =>
|
||||
new(true, null, warnings ?? Array.Empty<string>());
|
||||
|
||||
/// <summary>A failed rename carrying the operator-facing message.</summary>
|
||||
/// <param name="error">The operator-facing failure message.</param>
|
||||
/// <returns>A failed <see cref="RawRenameResult"/>.</returns>
|
||||
public static RawRenameResult Failure(string error) =>
|
||||
new(false, error, Array.Empty<string>());
|
||||
}
|
||||
|
||||
@@ -5,9 +5,9 @@ using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// Read-only enumeration helper for the WP5 CSV export path. Reads a device's tag-groups + tags directly
|
||||
/// Read-only enumeration helper for the CSV export path. Reads a device's tag-groups + tags directly
|
||||
/// from the config DB and flattens them (with each tag's reconstructed <c>/</c>-separated
|
||||
/// <see cref="RawCsvExportTag.TagGroupPath"/>) into the export shape. Owned by WP5 — it does NOT extend
|
||||
/// <see cref="RawCsvExportTag.TagGroupPath"/>) into the export shape. Owned by the CSV export path — it does NOT extend
|
||||
/// <see cref="IRawTreeService"/> (that prelude is closed); it takes its own
|
||||
/// <see cref="IDbContextFactory{TContext}"/> so export never mutates. Also resolves a tag-group's path +
|
||||
/// owning device (the import modal's target-group prefix seam).
|
||||
|
||||
@@ -8,7 +8,7 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// The pure WP5 CSV import/export engine for the Raw tree. Bridges the T0-2 <see cref="CsvParser"/> /
|
||||
/// The pure CSV import/export engine for the Raw tree. Bridges the <see cref="CsvParser"/> /
|
||||
/// <see cref="CsvWriter"/> and the per-driver <see cref="CsvColumnMap"/> to the service's
|
||||
/// <see cref="RawTagImportRow"/> commit shape: <see cref="Parse"/> turns an uploaded CSV into a
|
||||
/// per-row review model with validation verdicts; <see cref="Export"/> renders a device's tags back to
|
||||
@@ -20,7 +20,7 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
/// fallback; the review model flags when that override happened so the operator sees it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The assembled blob is then put to the driver's own <see cref="TagConfigValidator"/> (Gitea #551): the
|
||||
/// The assembled blob is then put to the driver's own <see cref="TagConfigValidator"/>: the
|
||||
/// typed columns were always strictly parsed, but the free-form <c>TagConfigJson</c> column reached the
|
||||
/// database unread, so a CSV could author a tag the driver rejects — one that deploys clean and reads
|
||||
/// <c>BadNodeIdUnknown</c> forever. A failing row is an <b>error</b>, blocking the all-or-nothing commit;
|
||||
@@ -28,8 +28,8 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
/// commit); the guarantee lives in <see cref="IRawTreeService.ImportTagsAsync"/>, which re-runs it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// For that gate to mean anything the merge must hand it the row <b>as written</b>, which it did not
|
||||
/// (Gitea #553): the model-backed maps round-tripped the free-form cell through a driver model's lenient
|
||||
/// For that gate to mean anything the merge must hand it the row <b>as written</b>, which it did not:
|
||||
/// the model-backed maps round-tripped the free-form cell through a driver model's lenient
|
||||
/// <c>FromJson</c>/<c>ToJson</c>, so a typo'd value under a key the model owns was rewritten to that model's
|
||||
/// default before any gate ran — the row passed, and the tag the operator actually described was gone.
|
||||
/// <see cref="CsvDriverTagMap.ApplyTypedColumns"/> now preserves the cell's own tokens for every key no
|
||||
@@ -174,7 +174,7 @@ public static class RawTagCsvMapper
|
||||
|
||||
// Every spelling the column speaks for, not just the one it writes: an MTConnect cell
|
||||
// carrying "driverDataType" is overridden by the MtDataType column exactly as a
|
||||
// "dataType" cell is, and the operator must see that in the grid (Gitea #553). This
|
||||
// "dataType" cell is, and the operator must see that in the grid. This
|
||||
// report is also what makes accepting the override — rather than erroring on the
|
||||
// contradiction — an open decision instead of a silent one.
|
||||
if (col.OwnedJsonKeys.Any(baseKeys.Contains))
|
||||
@@ -217,7 +217,7 @@ public static class RawTagCsvMapper
|
||||
// Alarm flags — an alarm is authored iff Alarm.AlarmType is present.
|
||||
json = ApplyAlarm(json, Cell("Alarm.AlarmType"), Cell("Alarm.Severity"), Cell("Alarm.HistorizeToAveva"));
|
||||
|
||||
// The driver-typed config gate, on the FINAL assembled blob (Gitea #551 item 1). Until this,
|
||||
// The driver-typed config gate, on the FINAL assembled blob. Until this,
|
||||
// the typed columns above were strictly parsed but the free-form TagConfigJson column was not
|
||||
// checked at all, so a CSV could import a blob the driver rejects — a tag that deploys clean
|
||||
// and then reads BadNodeIdUnknown forever. Surfacing it here, on the staged review row, is what
|
||||
@@ -489,6 +489,7 @@ public sealed record RawCsvParseResult(
|
||||
public bool CanCommit => FatalError is null && Rows.Count > 0 && Rows.All(r => r.Ok);
|
||||
|
||||
/// <summary>The valid rows' assembled import rows, ready for <c>ImportTagsAsync</c>.</summary>
|
||||
/// <returns>The import rows of every valid row.</returns>
|
||||
public IReadOnlyList<RawTagImportRow> ToImportRows()
|
||||
=> Rows.Where(r => r is { Ok: true, ImportRow: not null }).Select(r => r.ImportRow!).ToArray();
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// Parameter object carrying the operator-editable fields for a raw <c>Tag</c> create or update in the
|
||||
/// v3 Raw tree (WP4 manual entry). Identity is the RawPath — the tag's device + optional tag-group +
|
||||
/// v3 Raw tree (manual entry). Identity is the RawPath — the tag's device + optional tag-group +
|
||||
/// name — so there is no <c>TagId</c> here: it is generated on create and never carried in the input.
|
||||
/// Whitespace-only <see cref="PollGroupId"/> collapses to <c>null</c>; <see cref="TagConfig"/> is
|
||||
/// validated for JSON well-formedness by the service (blank collapses to <c>{}</c>).
|
||||
@@ -24,7 +24,7 @@ public sealed record RawTagInput(
|
||||
string TagConfig);
|
||||
|
||||
/// <summary>
|
||||
/// One row of the WP5 CSV import commit. <see cref="TagGroupPath"/> is a <c>/</c>-separated tag-group
|
||||
/// One row of the CSV import commit. <see cref="TagGroupPath"/> is a <c>/</c>-separated tag-group
|
||||
/// path under the target device (its segments are auto-created as nested <c>TagGroup</c>s, reusing any
|
||||
/// that already exist); <c>null</c>/blank places the tag directly under the device. <see cref="Tag"/>
|
||||
/// carries the tag's editable fields.
|
||||
|
||||
@@ -513,7 +513,7 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
});
|
||||
|
||||
// Auto-create the default device so a freshly-created driver has a child to show. By design
|
||||
// (#596): every driver needs at least one device for browse + tags, and DriverDeviceConfigMerger treats
|
||||
// Every driver needs at least one device for browse + tags, and DriverDeviceConfigMerger treats
|
||||
// the sole device's DeviceConfig as the endpoint. It is renamable/deletable and carries an empty config.
|
||||
db.Devices.Add(new Device
|
||||
{
|
||||
@@ -620,7 +620,7 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
if (owner is null)
|
||||
return new UnsMutationResult(false, $"Driver '{driverInstanceId}' not found.");
|
||||
|
||||
// #628: MTConnect owns one agent client per driver — mirror the deploy gate
|
||||
// MTConnect owns one agent client per driver — mirror the deploy gate
|
||||
// (DraftValidator.ValidateMTConnectSingleDevice) so the second Device is refused where it is authored.
|
||||
if (string.Equals(owner.DriverType, DriverTypeNames.MTConnect, StringComparison.Ordinal)
|
||||
&& await db.Devices.AnyAsync(dev => dev.DriverInstanceId == driverInstanceId, ct))
|
||||
@@ -771,7 +771,7 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Tag delete + edit projection (create/update are WP4)
|
||||
// Tag delete + edit projection (create/update are handled elsewhere)
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -823,7 +823,7 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Tag create / update (WP4) + CSV import (WP5)
|
||||
// Tag create / update + CSV import
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -928,7 +928,7 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
return new RawTagImportResult(0, new[] { $"Device '{deviceId}' not found." });
|
||||
|
||||
// The device's driver type, resolved ONCE: an import is per-device, and a device hangs off exactly
|
||||
// one driver, so every row in the batch is judged by the same driver's rules (Gitea #551 item 1).
|
||||
// one driver, so every row in the batch is judged by the same driver's rules.
|
||||
var driverType = await ResolveDriverTypeAsync(db, deviceId, ct);
|
||||
|
||||
// Existing groups on the device: a (parent-group-id | null, name) → group-id lookup we extend with
|
||||
@@ -1071,7 +1071,7 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Driver config (WP3 "Configure driver")
|
||||
// Driver config ("Configure driver")
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -1126,7 +1126,7 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Device config (WP3 "Configure device") + merged probe config (Test connect)
|
||||
// Device config ("Configure device") + merged probe config (Test connect)
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -1255,7 +1255,7 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
|
||||
/// <summary>
|
||||
/// The driver-typed <c>TagConfig</c> gate, applied on the SERVER for every tag-writing path —
|
||||
/// create, update and bulk import alike (Gitea #551 item 1).
|
||||
/// create, update and bulk import alike.
|
||||
/// <para>
|
||||
/// <b>It lives here rather than in the modals because a form cannot be the guarantee.</b> The
|
||||
/// tag modal and the manual-entry grid already run <see cref="TagConfigValidator"/> for fast
|
||||
@@ -1288,10 +1288,12 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
{
|
||||
public static readonly GroupKeyComparer Instance = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool Equals((string? Parent, string Name) x, (string? Parent, string Name) y) =>
|
||||
string.Equals(x.Parent, y.Parent, StringComparison.Ordinal)
|
||||
&& string.Equals(x.Name, y.Name, StringComparison.Ordinal);
|
||||
|
||||
/// <inheritdoc />
|
||||
public int GetHashCode((string? Parent, string Name) obj) =>
|
||||
HashCode.Combine(
|
||||
obj.Parent is null ? 0 : StringComparer.Ordinal.GetHashCode(obj.Parent),
|
||||
@@ -1303,10 +1305,12 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
{
|
||||
public static readonly TagKeyComparer Instance = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool Equals((string? GroupId, string Name) x, (string? GroupId, string Name) y) =>
|
||||
string.Equals(x.GroupId, y.GroupId, StringComparison.Ordinal)
|
||||
&& string.Equals(x.Name, y.Name, StringComparison.Ordinal);
|
||||
|
||||
/// <inheritdoc />
|
||||
public int GetHashCode((string? GroupId, string Name) obj) =>
|
||||
HashCode.Combine(
|
||||
obj.GroupId is null ? 0 : StringComparer.Ordinal.GetHashCode(obj.GroupId),
|
||||
|
||||
@@ -16,14 +16,14 @@ public sealed class AbCipTagConfigModel
|
||||
/// <summary>Logix atomic type, or <c>AbCipDataType.Structure</c> for UDT-typed tags.</summary>
|
||||
public AbCipDataType DataType { get; set; } = AbCipDataType.DInt;
|
||||
|
||||
/// <summary>Optional writable flag (R2-11); <c>null</c> ⇒ omitted (driver default true), explicit
|
||||
/// <summary>Optional writable flag; <c>null</c> ⇒ omitted (driver default true), explicit
|
||||
/// <c>false</c> ⇒ read-only. Preserved across load→save.</summary>
|
||||
public bool? Writable { get; set; }
|
||||
|
||||
private JsonObject _bag = new();
|
||||
|
||||
// The blob this model was LOADED FROM, or null when built by object initialiser — the input to the
|
||||
// Gitea #554 parse consult (see TagConfigParseGate).
|
||||
// Parse consult (see TagConfigParseGate).
|
||||
private string? _source;
|
||||
|
||||
/// <summary>Loads a model from a TagConfig JSON string, defaulting any absent field and retaining
|
||||
@@ -63,9 +63,9 @@ public sealed class AbCipTagConfigModel
|
||||
/// <c>TryReadEnumStrict</c> — a present-but-invalid string rejects the whole tag at the driver
|
||||
/// (<c>BadNodeIdUnknown</c>). Without this the blob would be accepted here, silently rewritten to
|
||||
/// <c>DInt</c> on save, and — on the CSV-import and API paths, which save it verbatim — deployed
|
||||
/// into a tag the driver skips (Gitea #549).
|
||||
/// into a tag the driver skips.
|
||||
/// <para>
|
||||
/// <b>Declared UDT members are checked too (Gitea #551), and the rule is not restated here.</b>
|
||||
/// <b>Declared UDT members are checked too, and the rule is not restated here.</b>
|
||||
/// The member rules are as strict as the top-level ones — one member with no <c>name</c> or a
|
||||
/// typo'd <c>dataType</c> rejects the WHOLE tag — but nothing on the authoring side saw them:
|
||||
/// this editor emits only <c>tagPath</c>/<c>dataType</c>/<c>writable</c>, so <c>members</c>
|
||||
@@ -86,9 +86,9 @@ public sealed class AbCipTagConfigModel
|
||||
return memberError;
|
||||
}
|
||||
|
||||
// Gitea #554 backstop. The three checks above already mirror the whole of the reader's rejection
|
||||
// Backstop. The three checks above already mirror the whole of the reader's rejection
|
||||
// surface, so this is expected to be silent for AbCip — it is here so a future reader rule cannot
|
||||
// become a gap without anyone noticing, which is the failure #549/#551 kept repeating.
|
||||
// become a gap without anyone noticing, which is the failure that kept repeating.
|
||||
return TagConfigParseGate.Describe(
|
||||
_source,
|
||||
j => AbCipTagDefinitionFactory.FromTagConfig(j, TagConfigParseGate.AuthoringRawPath, out _),
|
||||
|
||||
@@ -16,14 +16,14 @@ public sealed class AbLegacyTagConfigModel
|
||||
/// <summary>PCCC data type that maps onto SLC / MicroLogix / PLC-5 files.</summary>
|
||||
public AbLegacyDataType DataType { get; set; } = AbLegacyDataType.Int;
|
||||
|
||||
/// <summary>Optional writable flag (R2-11); <c>null</c> ⇒ omitted (driver default true), explicit
|
||||
/// <summary>Optional writable flag; <c>null</c> ⇒ omitted (driver default true), explicit
|
||||
/// <c>false</c> ⇒ read-only. Preserved across load→save.</summary>
|
||||
public bool? Writable { get; set; }
|
||||
|
||||
private JsonObject _bag = new();
|
||||
|
||||
// The blob this model was LOADED FROM, or null when built by object initialiser — the input to the
|
||||
// Gitea #554 parse consult (see TagConfigParseGate).
|
||||
// Parse consult (see TagConfigParseGate).
|
||||
private string? _source;
|
||||
|
||||
/// <summary>Loads a model from a TagConfig JSON string, defaulting any absent field and retaining
|
||||
@@ -63,8 +63,7 @@ public sealed class AbLegacyTagConfigModel
|
||||
// Strict enum, read from the ORIGINAL key bag: AbLegacyTagDefinitionFactory reads "dataType" with
|
||||
// TryReadEnumStrict, so a present-but-invalid string rejects the whole tag at the driver
|
||||
// (BadNodeIdUnknown) while the lenient load above would default it to Int. Accepting it here would
|
||||
// let the CSV-import and API paths — which save the blob verbatim — deploy an unreadable tag
|
||||
// (Gitea #549).
|
||||
// let the CSV-import and API paths — which save the blob verbatim — deploy an unreadable tag.
|
||||
if (TagConfigJson.DescribeInvalidEnum<AbLegacyDataType>(_bag, "dataType") is { } dtError)
|
||||
return dtError;
|
||||
|
||||
@@ -74,7 +73,7 @@ public sealed class AbLegacyTagConfigModel
|
||||
if (AbLegacyAddress.Validate(Address, family: null) is { } addressError)
|
||||
return addressError;
|
||||
|
||||
// DELIBERATELY STRICTER THAN THE RUNTIME, and re-confirmed as such by the Gitea #554 survey — do not
|
||||
// DELIBERATELY STRICTER THAN THE RUNTIME, and re-confirmed as such by the parse-gate survey — do not
|
||||
// "align" it. PCCC data-file elements are limited to AbLegacyArray.MaxElements (256). The reader
|
||||
// CLAMPS an authored length above that (Math.Min) and serves the tag, but the driver-agnostic
|
||||
// materialiser sets OPC UA ArrayDimensions from the RAW authored length, so the served array is
|
||||
@@ -88,7 +87,7 @@ public sealed class AbLegacyTagConfigModel
|
||||
if (isArray && arrayLength > AbLegacyArray.MaxElements)
|
||||
return $"AbLegacy array length cannot exceed {AbLegacyArray.MaxElements} (PCCC data-file element limit).";
|
||||
|
||||
// Gitea #554 backstop: the checks above mirror the reader's whole rejection surface today, so this is
|
||||
// Parse-gate backstop: the checks above mirror the reader's whole rejection surface today, so this is
|
||||
// expected to be silent for AbLegacy. It is here so a future reader rule cannot become a gap.
|
||||
return TagConfigParseGate.Describe(
|
||||
_source,
|
||||
|
||||
@@ -23,7 +23,7 @@ public sealed class CalculationTagConfigModel
|
||||
private JsonObject _bag = new();
|
||||
|
||||
// The blob this model was LOADED FROM, or null when built by object initialiser — the input to the
|
||||
// Gitea #554 parse consult (see TagConfigParseGate).
|
||||
// parse consult (see TagConfigParseGate).
|
||||
private string? _source;
|
||||
|
||||
/// <summary>Loads a model from a TagConfig JSON string, defaulting any absent field and retaining every
|
||||
@@ -64,7 +64,7 @@ public sealed class CalculationTagConfigModel
|
||||
/// must be set, and a set timer must be ≥ 50 ms.</summary>
|
||||
/// <remarks>
|
||||
/// The trigger and 50 ms rules are <b>DELIBERATELY STRICTER THAN THE RUNTIME</b>, which serves a
|
||||
/// trigger-less tag (it simply never computes) and honours a 1 ms cadence. The Gitea #554 survey
|
||||
/// trigger-less tag (it simply never computes) and honours a 1 ms cadence. The tag-config survey
|
||||
/// re-confirmed both and left them alone — they are documented on
|
||||
/// <c>CalculationTagDefinitionFactory</c> as the reason its deploy gate must NOT restate them, since a
|
||||
/// deploy warning can be promoted to a blocking error against a config the driver serves. The parse
|
||||
@@ -86,7 +86,7 @@ public sealed class CalculationTagConfigModel
|
||||
return "Timer interval must be at least 50 ms.";
|
||||
}
|
||||
|
||||
// Gitea #554 backstop — expected silent for Calculation, whose reader refuses only a blob with no
|
||||
// Backstop — expected silent for Calculation, whose reader refuses only a blob with no
|
||||
// usable 'scriptId'. ⚠️ It gates on the PARSE VERDICT: this driver's Inspect emits advisories for
|
||||
// blobs the reader ACCEPTS (a non-boolean 'changeTriggered', a discarded 'timerIntervalMs'), so a
|
||||
// non-empty-inspection gate would refuse configs the runtime serves.
|
||||
|
||||
@@ -41,7 +41,7 @@ public sealed record CsvTypedColumn(
|
||||
/// A column that writes <c>dataType</c> while the driver prefers a <c>driverDataType</c> alias does
|
||||
/// not actually control the tag's type: the alias, carried through in the free-form
|
||||
/// <c>TagConfigJson</c> cell, out-ranks it at the driver, so an operator's edit of the typed column
|
||||
/// is accepted and then silently ignored (Gitea #553). Knowing the whole set is what lets the
|
||||
/// is accepted and then silently ignored. Knowing the whole set is what lets the
|
||||
/// export residual drop the alias, the export cell show the <i>effective</i> value, and a firing
|
||||
/// column clear the alias so it genuinely wins.
|
||||
/// </para>
|
||||
@@ -51,7 +51,7 @@ public sealed record CsvTypedColumn(
|
||||
|
||||
/// <summary>
|
||||
/// Per-driver mapping between a CSV row's <b>typed</b> columns and a tag's driver-specific
|
||||
/// <c>TagConfig</c> JSON. The map is the inverse pair the WP5 CSV import/export path pivots on:
|
||||
/// <c>TagConfig</c> JSON. The map is the inverse pair the CSV import/export path pivots on:
|
||||
/// <see cref="ApplyTypedColumns"/> folds a row's typed columns onto a base TagConfig (typed column wins
|
||||
/// on conflict); <see cref="ReadTypedColumns"/> projects a stored TagConfig back to its typed cells for
|
||||
/// export. Lives next to the <c><![CDATA[<Driver>TagConfigModel]]></c> types so model + map evolve
|
||||
@@ -74,14 +74,14 @@ public abstract class CsvDriverTagMap
|
||||
|
||||
/// <summary>The root TagConfig JSON keys this map owns (used to compute the export residual) — every
|
||||
/// <see cref="CsvTypedColumn.OwnedJsonKeys"/> entry, aliases included, so the residual can never carry a
|
||||
/// spelling that out-ranks the typed column beside it (Gitea #553).</summary>
|
||||
/// spelling that out-ranks the typed column beside it.</summary>
|
||||
public IReadOnlyList<string> JsonKeys => Columns.SelectMany(c => c.OwnedJsonKeys).Distinct(StringComparer.Ordinal).ToArray();
|
||||
|
||||
/// <summary>
|
||||
/// Applies one typed column's already-parsed effect to <paramref name="target"/>: removes every
|
||||
/// <b>other</b> spelling the runtime would read for that column, so the value the column supplied is
|
||||
/// the one the driver actually sees. Callers write the column's own <see cref="CsvTypedColumn.JsonKey"/>
|
||||
/// themselves (model-backed maps do it through the model). Gitea #553.
|
||||
/// themselves (model-backed maps do it through the model).
|
||||
/// </summary>
|
||||
/// <param name="target">The merged TagConfig object being assembled.</param>
|
||||
/// <param name="column">The typed column that supplied a value on this row.</param>
|
||||
@@ -100,7 +100,7 @@ public abstract class CsvDriverTagMap
|
||||
/// Reads one typed column's <b>effective</b> cell value out of a stored TagConfig for export: the
|
||||
/// first non-blank string under <see cref="CsvTypedColumn.OwnedJsonKeys"/>, in the runtime's own
|
||||
/// precedence. Exporting the canonical key alone would print the spelling the driver is <i>ignoring</i>
|
||||
/// and leave the winning alias in the residual, which is how the re-import trap in Gitea #553 got set.
|
||||
/// and leave the winning alias in the residual, which is how the re-import trap got set.
|
||||
/// </summary>
|
||||
/// <param name="source">The stored TagConfig object.</param>
|
||||
/// <param name="column">The typed column to project.</param>
|
||||
@@ -190,29 +190,25 @@ public sealed class ModelBackedCsvDriverTagMap<TModel> : CsvDriverTagMap
|
||||
public override IReadOnlyList<CsvTypedColumn> Columns { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>
|
||||
/// <b>The base cell's own tokens are restored verbatim over the model's serialisation (Gitea #553).</b>
|
||||
/// The model round-trip is what merges the typed columns and stamps a default for a key the row
|
||||
/// declares nowhere — but <c>FromJson</c> is deliberately lenient (it has to LOAD a poisoned blob so an
|
||||
/// operator can fix it), so a <c>"dataType":"Intt16"</c> the row carried in its free-form
|
||||
/// <c>TagConfigJson</c> cell came back out of <c>ToJson</c> as the model's <i>default</i> — the
|
||||
/// operator's typo replaced by a valid-looking value before the import gate, or any later gate, could
|
||||
/// ever see it. Only keys the model does not own survived to be judged.
|
||||
/// <para>
|
||||
/// So every key the base cell actually carried is written back afterwards, unless a non-blank
|
||||
/// typed column overrode it. Nothing here decides whether a token is <i>good</i>: that stays with
|
||||
/// <see cref="TagConfigValidator"/> on the assembled blob (and with
|
||||
/// <c>IRawTreeService.ImportTagsAsync</c> behind it), which asks each driver model's own
|
||||
/// <c>Validate()</c> — one authority. This method's only job is to stop destroying the evidence
|
||||
/// before that authority is consulted.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A key a non-blank column DID override is <b>not</b> restored, and its out-ranking aliases are
|
||||
/// cleared: the typed column winning over a conflicting cell key is this map's documented contract,
|
||||
/// the review grid reports each such override by name, and refusing the row instead would leave an
|
||||
/// operator no way to repair a poisoned cell through the very columns that exist for it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
// The base cell's own tokens are restored verbatim over the model's serialisation.
|
||||
// The model round-trip is what merges the typed columns and stamps a default for a key the row
|
||||
// declares nowhere — but FromJson is deliberately lenient (it has to LOAD a poisoned blob so an
|
||||
// operator can fix it), so a "dataType":"Intt16" the row carried in its free-form
|
||||
// TagConfigJson cell came back out of ToJson as the model's default — the
|
||||
// operator's typo replaced by a valid-looking value before the import gate, or any later gate, could
|
||||
// ever see it. Only keys the model does not own survived to be judged.
|
||||
//
|
||||
// So every key the base cell actually carried is written back afterwards, unless a non-blank
|
||||
// typed column overrode it. Nothing here decides whether a token is good: that stays with
|
||||
// TagConfigValidator on the assembled blob (and with
|
||||
// IRawTreeService.ImportTagsAsync behind it), which asks each driver model's own
|
||||
// Validate() — one authority. This method's only job is to stop destroying the evidence
|
||||
// before that authority is consulted.
|
||||
//
|
||||
// A key a non-blank column DID override is not restored, and its out-ranking aliases are
|
||||
// cleared: the typed column winning over a conflicting cell key is this map's documented contract,
|
||||
// the review grid reports each such override by name, and refusing the row instead would leave an
|
||||
// operator no way to repair a poisoned cell through the very columns that exist for it.
|
||||
public override string ApplyTypedColumns(string? baseJson, IReadOnlyDictionary<string, string> row)
|
||||
{
|
||||
var baseObj = TagConfigJson.ParseOrNew(baseJson);
|
||||
@@ -324,7 +320,7 @@ public sealed class RawKeyCsvDriverTagMap : CsvDriverTagMap
|
||||
};
|
||||
|
||||
// Without this the cell's alias survives beside the key just written and OUT-RANKS it at the
|
||||
// driver, so the operator's column edit is accepted and then ignored (Gitea #553).
|
||||
// driver, so the operator's column edit is accepted and then ignored.
|
||||
ClearOutrankingAliases(o, col.Meta);
|
||||
}
|
||||
}
|
||||
@@ -400,7 +396,7 @@ public static class CsvValues
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registry of the WP5 CSV column dictionary: the common columns every device's export/import carries,
|
||||
/// Registry of the CSV column dictionary: the common columns every device's export/import carries,
|
||||
/// plus the per-driver typed <see cref="CsvDriverTagMap"/> resolved by driver type. The single authority
|
||||
/// for "which columns does a device of driver X have".
|
||||
/// </summary>
|
||||
@@ -433,7 +429,7 @@ public static class CsvColumnMap
|
||||
|
||||
/// <summary>The Calculation (virtual-tag) driver-type string.</summary>
|
||||
/// <remarks>Retained as an alias for <see cref="DriverTypeNames.Calculation"/>, which now exists — the
|
||||
/// original note here ("not yet a DriverTypeNames constant; its factory lands in a later Batch-2
|
||||
/// original note here ("not yet a DriverTypeNames constant; its factory lands in a later
|
||||
/// package") went stale once the factory registered.</remarks>
|
||||
public const string CalculationDriverType = DriverTypeNames.Calculation;
|
||||
|
||||
@@ -595,7 +591,7 @@ public static class CsvColumnMap
|
||||
// MTConnect is the one driver whose blob accepts several spellings per field, so its two identity
|
||||
// columns declare the runtime's whole read precedence rather than just the key they write —
|
||||
// mirroring MTConnectTagDefinitionFactory.TryFromTagConfig's FirstNonBlank(...) pairs, and the
|
||||
// MTConnectTagConfigModel load that already mirrors them for the editor (Gitea #550/#553).
|
||||
// MTConnectTagConfigModel load that already mirrors them for the editor.
|
||||
new RawKeyCsvDriverTagMap(DriverTypeNames.MTConnect, new[]
|
||||
{
|
||||
new CsvRawKeyColumn(
|
||||
|
||||
@@ -20,7 +20,7 @@ public sealed class FocasTagConfigModel
|
||||
private JsonObject _bag = new();
|
||||
|
||||
// The blob this model was LOADED FROM, or null when built by object initialiser — the input to the
|
||||
// Gitea #554 parse consult (see TagConfigParseGate).
|
||||
// Parse consult (see TagConfigParseGate).
|
||||
private string? _source;
|
||||
|
||||
/// <summary>Loads a model from a TagConfig JSON string, defaulting any absent field and retaining
|
||||
@@ -54,14 +54,14 @@ public sealed class FocasTagConfigModel
|
||||
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
|
||||
/// <returns>An error message describing the validation failure, or null when the model is valid.</returns>
|
||||
/// <remarks>
|
||||
/// <b>This runs the driver's real parser, not a non-empty check</b> (Gitea #540). It used to
|
||||
/// <b>This runs the driver's real parser, not a non-empty check</b>. It used to
|
||||
/// accept any non-blank string, which is how 20 tags carrying a browse full reference
|
||||
/// (<c>10.201.31.5:8193/Identity/SeriesNumber</c>) were authored, deployed and sealed green —
|
||||
/// the driver then skipped every one at init and they read <c>BadNodeIdUnknown</c> forever
|
||||
/// with no signal anywhere. An address that cannot be parsed cannot be polled, so the
|
||||
/// rejection belongs here, where the operator is still looking at the field.
|
||||
/// <para><see cref="FocasAddress"/> lives in the driver's <c>Contracts</c> assembly precisely
|
||||
/// so this project can call it without referencing the runtime driver (Gitea #526).</para>
|
||||
/// so this project can call it without referencing the runtime driver.</para>
|
||||
/// </remarks>
|
||||
public string? Validate()
|
||||
{
|
||||
@@ -69,18 +69,18 @@ public sealed class FocasTagConfigModel
|
||||
// TryReadEnumStrict, so a present-but-invalid string rejects the whole tag at the driver
|
||||
// (BadNodeIdUnknown) while the lenient load above defaults it to Int32. Accepting it here would
|
||||
// let the CSV-import and API paths — which save the blob verbatim — deploy an unreadable tag,
|
||||
// the same shape as the #540 address gap this method already closes (Gitea #549).
|
||||
// the same shape as the address gap this method already closes.
|
||||
if (TagConfigJson.DescribeInvalidEnum<FocasDataType>(_bag, "dataType") is { } dtError) return dtError;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Address)) return "Address is required.";
|
||||
|
||||
// DELIBERATELY STRICTER THAN THE RUNTIME (Gitea #540), and the #554 survey re-confirmed it: the
|
||||
// DELIBERATELY STRICTER THAN THE RUNTIME, and the parse-gate survey re-confirmed it: the
|
||||
// reader ACCEPTS an unparseable address — FocasTagDefinitionFactory.Inspect only advises about it —
|
||||
// and the driver then skips the tag at startup. That is the defect #540 closed, so this check stays
|
||||
// and the driver then skips the tag at startup. That is the earlier defect closed, so this check stays
|
||||
// ahead of the parse consult below and is never replaced by it.
|
||||
if (FocasAddress.TryParse(Address) is null) return DescribeUnparseableAddress(Address);
|
||||
|
||||
// Gitea #554 backstop. ⚠️ It gates on the PARSE VERDICT, never on Inspect being non-empty: FOCAS's
|
||||
// Parse-gate backstop. ⚠️ It gates on the PARSE VERDICT, never on Inspect being non-empty: FOCAS's
|
||||
// inspector emits advisories for blobs the reader ACCEPTS (an unparseable address, an authored
|
||||
// 'writable':true on a read-only wire), and promoting those to authoring errors would make the modal
|
||||
// stricter than the runtime and leave rows nobody can save.
|
||||
@@ -99,7 +99,7 @@ public sealed class FocasTagConfigModel
|
||||
/// <param name="address">The rejected address.</param>
|
||||
/// <returns>The message.</returns>
|
||||
internal static string DescribeUnparseableAddress(string address) =>
|
||||
// An ALARM: address gets its selector's own reason — the same text the deploy-time inspector gives (#647).
|
||||
// An ALARM: address gets its selector's own reason — the same text the deploy-time inspector gives.
|
||||
FocasAddress.DescribeAlarmAddressError(address) is { } alarmError
|
||||
? $"'{address.Trim()}' is not a valid FOCAS alarm address: {alarmError}."
|
||||
: $"'{address.Trim()}' is not a FOCAS address. Expected a PMC address (X0.0, R100, F20.3), " +
|
||||
|
||||
@@ -13,7 +13,7 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
/// <para>
|
||||
/// <see cref="MtCategory"/>/<see cref="MtType"/>/<see cref="MtSubType"/>/<see cref="Units"/> come
|
||||
/// from the Agent's <c>/probe</c> response (see <c>MTConnectTagDefinition</c> in the driver's
|
||||
/// Contracts project) and are shown read-only by Task 18's editor — this model still round-trips
|
||||
/// Contracts project) and are shown read-only by the editor — this model still round-trips
|
||||
/// them so a browse-commit that stamped them survives a manual edit of <see cref="DataType"/>.
|
||||
/// <see cref="MtDevice"/>/<see cref="MtComponent"/> are pure author context (which physical
|
||||
/// device/component this DataItem belongs to); nothing downstream consumes them.
|
||||
@@ -66,7 +66,7 @@ public sealed class MTConnectTagConfigModel
|
||||
// Enum.TryParse falls back to String on ANY unrecognised value, so without this a misspelled name
|
||||
// ("Strng") or a quoted number ("8" -> Float64, the ParseEnum numeric-text trap) loads as a
|
||||
// *valid-looking* enum value with no signal that it is not what the operator wrote — while the strict
|
||||
// runtime rejects the tag outright (Gitea #549). Captured as the raw TOKEN, not via GetString: the
|
||||
// runtime rejects the tag outright. Captured as the raw TOKEN, not via GetString: the
|
||||
// runtime deserialises both spellings into `string?` DTO fields, so a NON-string token
|
||||
// (`"dataType": 8`, `"driverDataType": 8`) throws JsonException there and rejects the tag too, where
|
||||
// GetString would report it as simply absent. ToJson() always re-writes dataType as a name and drops
|
||||
@@ -80,7 +80,7 @@ public sealed class MTConnectTagConfigModel
|
||||
private string _rawDataTypeKey = CanonicalDataTypeKey;
|
||||
|
||||
// The blob this model was LOADED FROM, or null when built by object initialiser — the input to the
|
||||
// Gitea #554 parse consult (see TagConfigParseGate). Same capture-the-source pattern as _rawDataType.
|
||||
// Parse consult (see TagConfigParseGate). Same capture-the-source pattern as _rawDataType.
|
||||
private string? _source;
|
||||
|
||||
private JsonObject _bag = new();
|
||||
@@ -104,9 +104,9 @@ public sealed class MTConnectTagConfigModel
|
||||
/// <c>tags[]</c> field name, and the spelling a hand-authored or driver-shaped blob carries) wins
|
||||
/// over <c>dataType</c> (this model's canonical key).
|
||||
/// <para>
|
||||
/// Reading only <c>dataType</c> was Gitea <b>#550</b>, and it broke in both directions at once: a
|
||||
/// Reading only <c>dataType</c> was a defect, and it broke in both directions at once: a
|
||||
/// blob carrying <c>"driverDataType": "Strng"</c> sailed through authoring while the runtime
|
||||
/// rejected the tag (the residual slice of #549), and — worse — a VALID <c>driverDataType</c>
|
||||
/// rejected the tag, and — worse — a VALID <c>driverDataType</c>
|
||||
/// silently overrode whatever type this editor displayed and saved, so the editor showed a lie.
|
||||
/// </para>
|
||||
/// <para>
|
||||
@@ -159,7 +159,7 @@ public sealed class MTConnectTagConfigModel
|
||||
{
|
||||
// Normalise onto the canonical key: a blob loaded via an alias must not keep the alias, or the
|
||||
// two spellings drift on the next edit and the driver silently reads the stale one. This holds
|
||||
// for the coercion type (driverDataType, Gitea #550) exactly as it does for the id spellings —
|
||||
// for the coercion type (driverDataType) exactly as it does for the id spellings —
|
||||
// and because driverDataType OUT-RANKS dataType at the driver, leaving it behind would mean the
|
||||
// type the operator just picked never reached the runtime at all.
|
||||
TagConfigJson.Set(_bag, "dataItemId", null);
|
||||
@@ -185,7 +185,7 @@ public sealed class MTConnectTagConfigModel
|
||||
/// The <c>dataType</c> rule is not stated here — it is asked of
|
||||
/// <see cref="MTConnectTagDefinitionFactory.IsAcceptedDataTypeName"/>, the same predicate the driver
|
||||
/// and the deploy gate use. One authority, so this gate can never end up weaker than the runtime
|
||||
/// again (Gitea #549). Note that the previous, digits-only check is NOT retained as a tailored
|
||||
/// again. Note that the previous, digits-only check is NOT retained as a tailored
|
||||
/// special case: the runtime draws no distinction between a bare number and a misspelled name —
|
||||
/// both fail that one predicate and both draw the same <c>Inspect</c> warning — so a second message
|
||||
/// here would be a second statement of a rule this model does not own.
|
||||
@@ -206,7 +206,7 @@ public sealed class MTConnectTagConfigModel
|
||||
+ $"data type. Valid: {string.Join(", ", Enum.GetNames<DriverDataType>())}.";
|
||||
}
|
||||
|
||||
// Gitea #554 backstop — expected silent for MTConnect, whose reader refuses only an unparseable
|
||||
// Backstop — expected silent for MTConnect, whose reader refuses only an unparseable
|
||||
// document, a JSON null, an id under none of the three spellings, and an unreadable coercion type,
|
||||
// all covered above. It is here so a future reader rule cannot become a gap.
|
||||
return TagConfigParseGate.Describe(
|
||||
@@ -217,7 +217,7 @@ public sealed class MTConnectTagConfigModel
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advisory warnings (#613) — NEVER an error and never routed through <see cref="Validate"/>: the
|
||||
/// Advisory warnings — NEVER an error and never routed through <see cref="Validate"/>: the
|
||||
/// deploy gate does not refuse these, so authoring must not either ("never stricter than the gate").
|
||||
/// One rule: the declared <see cref="DataType"/> cannot hold the type
|
||||
/// <see cref="MTConnectDataTypeInference.Infer"/> derives from the stamped
|
||||
@@ -234,7 +234,7 @@ public sealed class MTConnectTagConfigModel
|
||||
/// <returns>Zero or more human-readable warnings.</returns>
|
||||
public IReadOnlyList<string> Warnings()
|
||||
{
|
||||
// #627: an array (TIME_SERIES) tag is served as double[] only. Independent of the probe metadata —
|
||||
// An array (TIME_SERIES) tag is served as double[] only. Independent of the probe metadata —
|
||||
// the array flag is authored on the tag, and any other declared element type reads BadNotSupported.
|
||||
var arrayWarning = _bag.TryGetPropertyValue("isArray", out var arr)
|
||||
&& arr is JsonValue av && av.TryGetValue<bool>(out var isArray) && isArray
|
||||
|
||||
@@ -25,7 +25,7 @@ public sealed class ModbusTagConfigModel
|
||||
/// <summary>String length in characters for String tags.</summary>
|
||||
public int StringLength { get; set; }
|
||||
|
||||
/// <summary>Optional writable flag (R2-11). <c>null</c> ⇒ the key is omitted (driver default true);
|
||||
/// <summary>Optional writable flag. <c>null</c> ⇒ the key is omitted (driver default true);
|
||||
/// an explicit <c>false</c> makes the equipment tag read-only. Preserved across load→save so an absent
|
||||
/// key stays absent.</summary>
|
||||
public bool? Writable { get; set; }
|
||||
@@ -80,10 +80,10 @@ public sealed class ModbusTagConfigModel
|
||||
/// present-but-invalid string rejects the whole tag at the driver (<c>BadNodeIdUnknown</c>) where
|
||||
/// the load silently substitutes <c>HoldingRegisters</c>/<c>Int16</c>/<c>BigEndian</c>/
|
||||
/// <c>HighByteFirst</c>. Accepting one here would let the CSV-import and API paths — which save the
|
||||
/// blob verbatim — deploy a tag the driver skips (Gitea #549). Also note a <b>wrong-but-valid</b>
|
||||
/// blob verbatim — deploy a tag the driver skips. Also note a <b>wrong-but-valid</b>
|
||||
/// region is not this gate's business: only a spelling the runtime cannot read is rejected.
|
||||
/// <para>
|
||||
/// <c>stringByteOrder</c> is the fourth (Gitea #551): the runtime has always read it as
|
||||
/// <c>stringByteOrder</c> is the fourth: the runtime has always read it as
|
||||
/// strictly as its three siblings, but this model — and the deploy gate's <c>Inspect</c> —
|
||||
/// checked only the other three, so a typo'd <c>stringByteOrder</c> passed BOTH gates and died
|
||||
/// at the driver. It is not in <see cref="ToJson"/>'s exposed-field list either, so it can only
|
||||
@@ -92,7 +92,7 @@ public sealed class ModbusTagConfigModel
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Everything the mapper refuses for a NON-enum reason is covered by the parse consult</b>
|
||||
/// (Gitea #554), not by rules restated here: an absent <c>region</c>, an absent or non-numeric or
|
||||
/// not by rules restated here: an absent <c>region</c>, an absent or non-numeric or
|
||||
/// out-of-0–65535 <c>address</c>, a <c>String</c> tag with no <c>stringLength</c>, a
|
||||
/// <c>BitInRegister</c> tag whose <c>bitIndex</c> narrows above 15. Modbus was the one editor with
|
||||
/// no required field at all, so <c>{}</c> — what the modal persists for a tag whose config was
|
||||
|
||||
@@ -84,7 +84,7 @@ public sealed class MqttTagConfigModel
|
||||
public string Topic { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// The topic a write PUBLISHES to (Plain mode), or blank when the tag is read-only (Gitea #508).
|
||||
/// The topic a write PUBLISHES to (Plain mode), or blank when the tag is read-only.
|
||||
/// Deliberately distinct from <see cref="Topic"/> — a tag legitimately reads <c>.../state</c> and
|
||||
/// writes <c>.../set</c> — and blank is meaningful: the driver refuses the write with
|
||||
/// <c>BadNotWritable</c> rather than publishing nowhere. Unused in Sparkplug mode, where a
|
||||
@@ -165,7 +165,7 @@ public sealed class MqttTagConfigModel
|
||||
private JsonObject _bag = new();
|
||||
|
||||
// The blob this model was LOADED FROM, or null when built by object initialiser — the input to the
|
||||
// Gitea #554 parse consult (see TagConfigParseGate).
|
||||
// parse consult (see TagConfigParseGate).
|
||||
private string? _source;
|
||||
|
||||
/// <summary>
|
||||
@@ -223,7 +223,7 @@ public sealed class MqttTagConfigModel
|
||||
/// (<see cref="DataType"/> for Plain — always present; <see cref="MetricDataType"/> for
|
||||
/// Sparkplug — omitted unless the operator set an override): which field wins is decided by
|
||||
/// <see cref="Mode"/> at write time. <c>payloadFormat</c> is the one key written for <b>Plain
|
||||
/// only</b> (Gitea #513) — see its line below for why it alone needs the mode test. Every other
|
||||
/// only</b> — see its line below for why it alone needs the mode test. Every other
|
||||
/// key is unconditional: mode-inapplicable keys (e.g. <c>topic</c> while Sparkplug, or
|
||||
/// <c>groupId</c> while Plain) are written/cleared exactly as their typed field says, and because
|
||||
/// each of those fields is blank-⇒-omit, an absent key stays absent while a retyped tag's real
|
||||
@@ -241,7 +241,7 @@ public sealed class MqttTagConfigModel
|
||||
// shape dropdown would lose authored configuration rather than merely tidy the blob.
|
||||
TagConfigJson.Set(_bag, WriteTopicKey, string.IsNullOrWhiteSpace(WriteTopic) ? null : WriteTopic.Trim());
|
||||
|
||||
// Plain-only (Gitea #513). Unlike every other Plain key here, PayloadFormat is a NON-NULLABLE
|
||||
// Plain-only. Unlike every other Plain key here, PayloadFormat is a NON-NULLABLE
|
||||
// typed field, so an unconditional write does not merely preserve a retyped tag's leftover — it
|
||||
// INVENTS "payloadFormat":"Json" in the blob of a Sparkplug tag that never had one. The key names
|
||||
// a decode path that Sparkplug does not use (MqttTagDefinitionFactory routes on the descriptor
|
||||
@@ -314,7 +314,7 @@ public sealed class MqttTagConfigModel
|
||||
var error = Mode == MqttMode.SparkplugB ? ValidateSparkplug() : ValidatePlain();
|
||||
if (error is not null) { return error; }
|
||||
|
||||
// Gitea #554 backstop, gating on the PARSE VERDICT (never on Inspect being non-empty — the MQTT
|
||||
// Parse-consult backstop, gating on the PARSE VERDICT (never on Inspect being non-empty — the MQTT
|
||||
// inspector advises about a wildcard topic on blobs the reader ACCEPTS). The verdict is the UNION of
|
||||
// the two readers, matching MqttTagDefinitionFactory.Inspect's own accept gate: a blob is served if
|
||||
// EITHER shape reads it, and this model's Mode is only an inference from the descriptor keys.
|
||||
@@ -437,8 +437,8 @@ public sealed class MqttTagConfigModel
|
||||
/// driver would happily default. The VERDICT is the runtime's own
|
||||
/// <see cref="RuntimeTagConfigJson.ClassifyEnumText{TEnum}"/>, never a local
|
||||
/// <see cref="Enum.TryParse{TEnum}(string, bool, out TEnum)"/>: a numeric spelling such as
|
||||
/// <c>"1"</c> parses there and names no member (Driver.Sql.Contracts-002), so restating the rule
|
||||
/// here would accept a blob the driver refuses — the Gitea #549 direction.
|
||||
/// <c>"1"</c> parses there and names no member, so restating the rule
|
||||
/// here would accept a blob the driver refuses — the same direction.
|
||||
/// </summary>
|
||||
/// <typeparam name="TEnum">The enum type expected.</typeparam>
|
||||
/// <param name="o">The TagConfig key bag.</param>
|
||||
|
||||
@@ -92,7 +92,7 @@ public sealed class NativeAlarmModel
|
||||
}
|
||||
else
|
||||
{
|
||||
// Toggled off (#642): the alarm object must go, or a "disabled" tag would still deploy as a condition.
|
||||
// Toggled off: the alarm object must go, or a "disabled" tag would still deploy as a condition.
|
||||
_root.Remove("alarm");
|
||||
}
|
||||
|
||||
|
||||
@@ -36,10 +36,10 @@ public sealed class OpcUaClientTagConfigModel
|
||||
/// or present-but-non-string <c>nodeId</c> beside a perfectly good legacy <c>NodeId</c> is a
|
||||
/// REJECTED blob, not a fallback. Copying <c>MTConnectTagConfigModel</c>'s <c>FirstNonBlank</c>
|
||||
/// walk here would make this editor accept a blob the driver skips — the absolute direction
|
||||
/// Gitea #554 forbids.
|
||||
/// the parse consult forbids.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Reading only <c>nodeId</c> was Gitea <b>#557</b> item 1: a legacy blob was SERVED by the driver
|
||||
/// Reading only <c>nodeId</c> was a defect: a legacy blob was SERVED by the driver
|
||||
/// yet opened with an empty reference field and could not be saved untouched, so the editor was
|
||||
/// stricter than the reader — authoring blocking config the runtime honours.
|
||||
/// <see cref="ToJson"/> therefore normalises onto <see cref="NodeIdKey"/> and DROPS the legacy
|
||||
@@ -59,7 +59,7 @@ public sealed class OpcUaClientTagConfigModel
|
||||
private JsonObject _bag = new();
|
||||
|
||||
// The blob this model was LOADED FROM, or null when built by object initialiser — the input to the
|
||||
// Gitea #554 parse consult (see TagConfigParseGate).
|
||||
// parse consult (see TagConfigParseGate).
|
||||
private string? _source;
|
||||
|
||||
/// <summary>Loads a model from a TagConfig JSON string, defaulting any absent field and retaining
|
||||
@@ -100,8 +100,8 @@ public sealed class OpcUaClientTagConfigModel
|
||||
|
||||
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
|
||||
/// <remarks>
|
||||
/// The Gitea #554 parse consult is a backstop here, and the required-reference check above is now
|
||||
/// exactly as precedence-aware as the reader (Gitea #557 item 1): <see cref="ReadNodeId"/> yields
|
||||
/// The parse consult is a backstop here, and the required-reference check above is now
|
||||
/// exactly as precedence-aware as the reader: <see cref="ReadNodeId"/> yields
|
||||
/// the very token <see cref="OpcUaClientTagDefinitionFactory.TryReadNodeId"/> would consult — blank
|
||||
/// for every shape it refuses, non-blank for every shape it serves — so the fast path agrees with
|
||||
/// the parse verdict on the whole rejection surface rather than under-reading one spelling of it.
|
||||
|
||||
@@ -16,7 +16,7 @@ public sealed class S7TagConfigModel
|
||||
/// <summary>For <c>DataType = String</c>: S7-string max length (max 254).</summary>
|
||||
public int StringLength { get; set; }
|
||||
|
||||
/// <summary>Optional writable flag (R2-11); <c>null</c> ⇒ omitted (driver default true), explicit
|
||||
/// <summary>Optional writable flag; <c>null</c> ⇒ omitted (driver default true), explicit
|
||||
/// <c>false</c> ⇒ read-only. Preserved across load→save.</summary>
|
||||
public bool? Writable { get; set; }
|
||||
|
||||
@@ -26,7 +26,7 @@ public sealed class S7TagConfigModel
|
||||
// because the lenient load above cannot tell "absent" from "present": both land on Int16. S7 is the one
|
||||
// driver whose reader REQUIRES the key (S7TagDefinitionFactory.DeclaresDataType), so a blob without one
|
||||
// is rejected outright and reads BadNodeIdUnknown; without this capture that blob validated clean
|
||||
// (Gitea #551). Same capture-the-as-loaded-shape pattern as MTConnectTagConfigModel's _rawDataType.
|
||||
// Same capture-the-as-loaded-shape pattern as MTConnectTagConfigModel's _rawDataType.
|
||||
//
|
||||
// It records a property of the SOURCE, so the default (false) is the honest answer for a model built by
|
||||
// object initialiser rather than loaded — RawBrowseCommitMapper does exactly that, and ToJson() then
|
||||
@@ -36,7 +36,7 @@ public sealed class S7TagConfigModel
|
||||
private bool _dataTypeMissingInSource;
|
||||
|
||||
// The blob this model was LOADED FROM, or null when built by object initialiser — the parse consult's
|
||||
// input (Gitea #554). Same capture-the-source reasoning as _dataTypeMissingInSource above.
|
||||
// input. Same capture-the-source reasoning as _dataTypeMissingInSource above.
|
||||
private string? _source;
|
||||
|
||||
/// <summary>Loads a model from a TagConfig JSON string, defaulting any absent field and retaining
|
||||
@@ -78,9 +78,9 @@ public sealed class S7TagConfigModel
|
||||
/// <see cref="DataType"/>: <c>S7TagDefinitionFactory</c> reads it with <c>TryReadEnumStrict</c>,
|
||||
/// so a present-but-invalid string rejects the whole tag at the driver (<c>BadNodeIdUnknown</c>)
|
||||
/// where the load silently substitutes <c>Int16</c>. Accepting it here would let the CSV-import
|
||||
/// and API paths — which save the blob verbatim — deploy an unreadable tag (Gitea #549).
|
||||
/// and API paths — which save the blob verbatim — deploy an unreadable tag.
|
||||
/// <para>
|
||||
/// <b>An ABSENT <c>dataType</c> is rejected too (Gitea #551), and S7 is the only driver where
|
||||
/// <b>An ABSENT <c>dataType</c> is rejected too, and S7 is the only driver where
|
||||
/// that is right.</b> <c>S7TagDefinitionFactory.FromTagConfig</c> refuses a blob that does not
|
||||
/// carry the key at all — unique in the fleet, where every sibling falls back to a default — so
|
||||
/// such a tag deployed clean through both gates and then read <c>BadNodeIdUnknown</c> forever.
|
||||
@@ -112,7 +112,7 @@ public sealed class S7TagConfigModel
|
||||
+ string.Join(", ", Enum.GetNames<S7DataType>()) + ".";
|
||||
}
|
||||
|
||||
// S7-016: array shape is judged against the driver's INIT guards, which THROW (failing the whole
|
||||
// Array shape is judged against the driver's INIT guards, which THROW (failing the whole
|
||||
// driver's init, not just this tag) for a wide-type array and for a writable Bool array. The
|
||||
// isArray / arrayLength keys themselves belong to the TagModal's generic array controls
|
||||
// (TagArrayConfig) and are intentionally not fields of this model, so read them off the key bag.
|
||||
@@ -134,7 +134,7 @@ public sealed class S7TagConfigModel
|
||||
}
|
||||
}
|
||||
|
||||
// Gitea #554: the reader's remaining, non-enum refusal — a String tag whose 'stringLength' is
|
||||
// the reader's remaining, non-enum refusal — a String tag whose 'stringLength' is
|
||||
// negative or above 254 (S7 REJECTS it rather than clamping) — is not restated here. The verdict and
|
||||
// the wording both come from the driver's own reader, via TagConfigParseGate.
|
||||
return TagConfigParseGate.Describe(
|
||||
|
||||
@@ -54,7 +54,7 @@ public sealed class SqlTagConfigModel
|
||||
private string? _invalidTypeRaw;
|
||||
|
||||
// The blob this model was LOADED FROM, or null when built by object initialiser — the input to the
|
||||
// Gitea #554 parse consult (see TagConfigParseGate).
|
||||
// parse consult (see TagConfigParseGate).
|
||||
private string? _source;
|
||||
|
||||
/// <summary>Loads a model from a TagConfig JSON string, defaulting any absent field and retaining every
|
||||
@@ -146,7 +146,7 @@ public sealed class SqlTagConfigModel
|
||||
return "model is not a supported Sql tag model.";
|
||||
}
|
||||
|
||||
// Gitea #554: the parser's RECOGNITION check is the rule nothing above states, and it is the one Sql
|
||||
// The parser's RECOGNITION check is the rule nothing above states, and it is the one Sql
|
||||
// rejection an otherwise-complete blob can hit — TryParse refuses a config that carries neither a
|
||||
// 'model' key nor "driver":"Sql", because it cannot tell a Sql tag from any other driver's blob. The
|
||||
// editor always writes 'model' on save, so this only ever fires for a hand-authored / CSV / API blob.
|
||||
@@ -161,7 +161,7 @@ public sealed class SqlTagConfigModel
|
||||
// non-string ⇒ (null, null) — nothing to validate; a present string that parses ⇒ (value, null); a
|
||||
// present string that does NOT parse ⇒ (null, offendingString) so Validate can reject it. The verdict is
|
||||
// the RUNTIME's own ClassifyEnumText, not a local Enum.TryParse — restating it is how the two doors drift
|
||||
// (Gitea #549), and a numeric spelling like "1" is exactly where they would have (Driver.Sql.Contracts-002).
|
||||
// and a numeric spelling like "1" is exactly where they would have.
|
||||
private static (TEnum? value, string? invalidRaw) ReadEnumStrict<TEnum>(JsonObject o, string name)
|
||||
where TEnum : struct, Enum
|
||||
{
|
||||
|
||||
@@ -99,9 +99,9 @@ public static class TagConfigJson
|
||||
/// <para>
|
||||
/// <see cref="GetEnum{TEnum}"/> is deliberately lenient — it must be, because a model has to
|
||||
/// LOAD a poisoned blob to let an operator fix it — but leniency at the save gate is the
|
||||
/// Gitea #549 defect: every driver's runtime read is
|
||||
/// defect: every driver's runtime read is
|
||||
/// <c>TagConfigJson.TryReadEnumStrict</c>, which REJECTS the whole tag on a present-but-invalid
|
||||
/// string (R2-11 Phase C), so a typo the modal accepts and rewrites is a tag the driver would
|
||||
/// string, so a typo the modal accepts and rewrites is a tag the driver would
|
||||
/// have skipped with <c>BadNodeIdUnknown</c> on the CSV-import and API paths, which save the
|
||||
/// blob verbatim. A model's <c>Validate()</c> therefore calls this on its ORIGINAL key bag.
|
||||
/// </para>
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
|
||||
/// <summary>
|
||||
/// The non-enum half of the authoring gate (Gitea #554): a driver's <c><Driver>TagConfigModel.Validate()</c>
|
||||
/// The non-enum half of the authoring gate: a driver's <c><Driver>TagConfigModel.Validate()</c>
|
||||
/// asks that driver's own runtime parse whether it would read the blob, and refuses the save when it
|
||||
/// would not. One authority for the VERDICT and for the WORDING — the same shape #551 gave the deploy
|
||||
/// would not. One authority for the VERDICT and for the WORDING — the same shape the deploy
|
||||
/// gate, pointed at the other end of the pipe.
|
||||
/// <para>
|
||||
/// <b>Why the models could not simply grow more rules.</b> Before this, <c>Validate()</c> judged the
|
||||
/// enum keys (against the runtime's own <c>DescribeInvalidEnumText</c>, Gitea #549/#551) plus a few
|
||||
/// enum keys (against the runtime's own <c>DescribeInvalidEnumText</c>) plus a few
|
||||
/// hand-written required-field checks, and nothing else. Every remaining reason a driver's reader
|
||||
/// refuses a tag — an absent <c>region</c>, an <c>"address":"notanumber"</c>, an S7
|
||||
/// <c>stringLength</c> of 300, a TwinCAT <c>dataType:"Structure"</c> — met no gate until deploy, or
|
||||
/// until the driver skipped the tag. Re-stating each of those in each model would have been N more
|
||||
/// hand-mirrored rules to drift, which is exactly the defect #551 closed on the deploy side.
|
||||
/// hand-mirrored rules to drift, which is exactly the defect already closed on the deploy side.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
@@ -22,7 +22,7 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
/// will be skipped and that <c>writable:true</c> cannot write, MQTT warns about a wildcard topic,
|
||||
/// Calculation warns about a discarded <c>timerIntervalMs</c>. Treating a non-empty inspection as an
|
||||
/// authoring error would make the modal STRICTER than the runtime and leave rows nobody can save —
|
||||
/// the #520/#535 trap. So <see cref="Describe"/> calls the inspector only once the parse has already
|
||||
/// the same trap as an over-strict gate. So <see cref="Describe"/> calls the inspector only once the parse has already
|
||||
/// refused, purely to word the refusal.
|
||||
/// </para>
|
||||
/// <para>
|
||||
|
||||
@@ -16,14 +16,14 @@ public sealed class TwinCATTagConfigModel
|
||||
/// <summary>TwinCAT / IEC 61131-3 atomic data type.</summary>
|
||||
public TwinCATDataType DataType { get; set; } = TwinCATDataType.DInt;
|
||||
|
||||
/// <summary>Optional writable flag (R2-11); <c>null</c> ⇒ omitted (driver default true), explicit
|
||||
/// <summary>Optional writable flag; <c>null</c> ⇒ omitted (driver default true), explicit
|
||||
/// <c>false</c> ⇒ read-only. Preserved across load→save.</summary>
|
||||
public bool? Writable { get; set; }
|
||||
|
||||
private JsonObject _bag = new();
|
||||
|
||||
// The blob this model was LOADED FROM, or null when built by object initialiser — the input to the
|
||||
// Gitea #554 parse consult (see TagConfigParseGate).
|
||||
// parse consult (see TagConfigParseGate).
|
||||
private string? _source;
|
||||
|
||||
/// <summary>Loads a model from a TagConfig JSON string, defaulting any absent field and retaining
|
||||
@@ -64,10 +64,10 @@ public sealed class TwinCATTagConfigModel
|
||||
/// <c>TryReadEnumStrict</c>, so a present-but-invalid string rejects the whole tag at the driver
|
||||
/// (<c>BadNodeIdUnknown</c>) where the load silently substitutes <c>DInt</c>. Accepting it here
|
||||
/// would let the CSV-import and API paths — which save the blob verbatim — deploy an unreadable
|
||||
/// tag (Gitea #549).
|
||||
/// tag.
|
||||
/// <para>
|
||||
/// <b><c>dataType: "Structure"</c> is the reason this model needed more than an enum check</b>
|
||||
/// (Gitea #554): it is a perfectly valid <see cref="TwinCATDataType"/> NAME, so every spelling
|
||||
/// <b><c>dataType: "Structure"</c> is the reason this model needed more than an enum check</b>:
|
||||
/// it is a perfectly valid <see cref="TwinCATDataType"/> NAME, so every spelling
|
||||
/// gate passes it, and the reader refuses it anyway — the driver does not serve UDT / FB-instance
|
||||
/// tags. The rule is not restated here; the parse consult asks the reader and quotes its answer.
|
||||
/// </para>
|
||||
|
||||
@@ -23,8 +23,8 @@ public sealed class UnsTreeService(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
|
||||
IEffectiveNameGuard? guard = null) : IUnsTreeService
|
||||
{
|
||||
// The v3 Batch-3 effective-name uniqueness guard (references / VirtualTags / ScriptedAlarms share the
|
||||
// equipment's UNS NodeId space). WP2 registers the real implementation; production DI always injects it.
|
||||
// The effective-name uniqueness guard (references / VirtualTags / ScriptedAlarms share the
|
||||
// equipment's UNS NodeId space). Production DI always injects it.
|
||||
// A null (unregistered) guard falls back to a no-op — collisions are then caught only at the deploy
|
||||
// gate — which also lets unit tests that don't exercise the guard construct the service with just a
|
||||
// db factory. Tests that DO exercise the guard pass a fake.
|
||||
@@ -471,12 +471,13 @@ public sealed class UnsTreeService(
|
||||
/// <summary>
|
||||
/// A no-op <see cref="IEffectiveNameGuard"/> used only when no guard was injected (unit tests that do
|
||||
/// not exercise the guard; a mis-wired DI). It always reports "free" — the deploy gate remains the
|
||||
/// backstop. Production always injects the real WP2 guard.
|
||||
/// backstop. Production always injects the real guard.
|
||||
/// </summary>
|
||||
private sealed class NoOpEffectiveNameGuard : IEffectiveNameGuard
|
||||
{
|
||||
public static readonly NoOpEffectiveNameGuard Instance = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<string?> CheckAsync(
|
||||
string equipmentId, string proposedEffectiveName, EffectiveNameSourceKind kind,
|
||||
string? excludeSourceId, CancellationToken ct = default) => Task.FromResult<string?>(null);
|
||||
@@ -842,7 +843,7 @@ public sealed class UnsTreeService(
|
||||
}
|
||||
|
||||
// v3: equipment no longer binds a driver — the reference-only Tags tab points at raw tags — so
|
||||
// the old decision-#122 driver-cluster guard is gone.
|
||||
// the old driver-cluster guard is gone.
|
||||
|
||||
var uuid = Guid.NewGuid();
|
||||
var equipmentId = $"EQ-{uuid.ToString("N")[..12]}";
|
||||
@@ -908,7 +909,7 @@ public sealed class UnsTreeService(
|
||||
|
||||
// An error rather than a skip: a skipped row is one the operator already has, while this is
|
||||
// one they meant to add and cannot. Left unchecked it would import and then block the next
|
||||
// deployment — fleet-wide, since a deployment snapshots the whole database (Gitea #535).
|
||||
// deployment — fleet-wide, since a deployment snapshots the whole database.
|
||||
if (!UnsSegment.IsValid(row.Name))
|
||||
{
|
||||
errors.Add($"Row '{row.MachineCode}': {UnsSegment.Describe("equipment name", row.Name)}.");
|
||||
@@ -974,7 +975,7 @@ public sealed class UnsTreeService(
|
||||
return new UnsMutationResult(false, $"MachineCode '{input.MachineCode}' already exists in this fleet.");
|
||||
}
|
||||
|
||||
// v3: equipment no longer binds a driver — no decision-#122 driver-cluster guard.
|
||||
// v3: equipment no longer binds a driver — no driver-cluster guard.
|
||||
|
||||
// Equipment↔driver binding retired in v3 — no DriverInstanceId column remains.
|
||||
entity.UnsLineId = input.UnsLineId;
|
||||
@@ -1258,7 +1259,7 @@ public sealed class UnsTreeService(
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// v3 (M1): when the bound script uses <c>{{equip}}/<RefName></c>, every <c><RefName></c> must
|
||||
/// v3: when the bound script uses <c>{{equip}}/<RefName></c>, every <c><RefName></c> must
|
||||
/// resolve to one of the owning equipment's <c>UnsTagReference</c> effective names (its
|
||||
/// <c>DisplayNameOverride</c> else the backing raw tag's <c>Name</c>) — the same set the compose seams
|
||||
/// substitute against and the deploy gate (<c>DraftValidator.ValidateEquipReferenceResolution</c>)
|
||||
@@ -1683,7 +1684,7 @@ public sealed class UnsTreeService(
|
||||
var nameGuard = await _guard.CheckAsync(equipmentId, input.Name, EffectiveNameSourceKind.ScriptedAlarm, null, ct);
|
||||
if (nameGuard is not null) return new UnsMutationResult(false, nameGuard);
|
||||
|
||||
// v3 Batch 3 (WP4): {{equip}}/<RefName> in the predicate script or message template must resolve.
|
||||
// v3: {{equip}}/<RefName> in the predicate script or message template must resolve.
|
||||
var equipGuard = await ValidateEquipTokenForAlarmAsync(db, equipmentId, input.PredicateScriptId, input.MessageTemplate, ct);
|
||||
if (equipGuard is not null) return equipGuard.Value;
|
||||
|
||||
@@ -1721,7 +1722,7 @@ public sealed class UnsTreeService(
|
||||
var nameGuard = await _guard.CheckAsync(entity.EquipmentId, input.Name, EffectiveNameSourceKind.ScriptedAlarm, scriptedAlarmId, ct);
|
||||
if (nameGuard is not null) return new UnsMutationResult(false, nameGuard);
|
||||
|
||||
// v3 Batch 3 (WP4): {{equip}}/<RefName> in the predicate script or message template must resolve.
|
||||
// v3: {{equip}}/<RefName> in the predicate script or message template must resolve.
|
||||
var equipGuard = await ValidateEquipTokenForAlarmAsync(db, entity.EquipmentId, input.PredicateScriptId, input.MessageTemplate, ct);
|
||||
if (equipGuard is not null) return equipGuard.Value;
|
||||
|
||||
@@ -1778,7 +1779,7 @@ public sealed class UnsTreeService(
|
||||
/// The rejection for a UNS name that is not a usable path segment.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ <b>The service is where this is enforced, not the form (Gitea #535).</b> Before this, the
|
||||
/// ⚠️ <b>The service is where this is enforced, not the form.</b> Before this, the
|
||||
/// rule lived on the equipment form alone, so an area or line could be named "Packaging" or
|
||||
/// "Line 1", save cleanly, and then fail the deploy — and because a deployment snapshots the
|
||||
/// whole database, one bad name blocked <b>every</b> cluster's deploy, discovered by someone
|
||||
|
||||
@@ -29,7 +29,7 @@ public sealed class AdminOperationsActor : ReceiveActor
|
||||
private readonly TagConfigValidationMode _tagConfigValidationMode;
|
||||
|
||||
/// <summary>
|
||||
/// Where central→node commands are handed off (per-cluster mesh Phase 2), or
|
||||
/// Where central→node commands are handed off (per-cluster mesh), or
|
||||
/// <see langword="null"/> to publish on the mediator directly.
|
||||
/// </summary>
|
||||
private readonly IActorRef? _meshRouter;
|
||||
@@ -43,7 +43,7 @@ public sealed class AdminOperationsActor : ReceiveActor
|
||||
/// <param name="tagConfigValidationMode">Deploy-gate TagConfig strictness mode (Warn default | Error opt-in).</param>
|
||||
/// <param name="meshRouter">
|
||||
/// Central comm actor commands are handed to, or <see langword="null"/> to publish on the
|
||||
/// DistributedPubSub mediator directly (the pre-Phase-2 behaviour).
|
||||
/// DistributedPubSub mediator directly (the behaviour before the per-cluster mesh).
|
||||
/// </param>
|
||||
/// <returns>Props configured to create an AdminOperationsActor.</returns>
|
||||
public static Props Props(
|
||||
@@ -87,8 +87,8 @@ public sealed class AdminOperationsActor : ReceiveActor
|
||||
/// no router is wired.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The fallback keeps ~12 existing tests (and any node wired before per-cluster mesh Phase 2)
|
||||
/// on exactly the pre-Phase-2 behaviour. The DistributedPubSub branch behind it is
|
||||
/// The fallback keeps ~12 existing tests (and any node wired before per-cluster mesh)
|
||||
/// on exactly the pre-mesh behaviour. The DistributedPubSub branch behind it is
|
||||
/// <b>retained</b>, not scheduled for deletion — an earlier note here promised Phase 6 would
|
||||
/// remove it, which was deliberately reversed (<c>docs/Configuration.md</c> § "The compiled
|
||||
/// transport-mode defaults were deliberately NOT flipped"): DPS is still the correct and
|
||||
@@ -245,12 +245,12 @@ public sealed class AdminOperationsActor : ReceiveActor
|
||||
errors.AddRange(DraftValidator.ValidateClusterTopology(cluster, nodes));
|
||||
}
|
||||
|
||||
// R2-11 (05/CONV-2): deploy-time TagConfig strictness inspection over equipment tags. The
|
||||
// Deploy-time TagConfig strictness inspection over equipment tags. The
|
||||
// driver-typed EquipmentTagConfigInspector surfaces every blob the driver's own reader would
|
||||
// REJECT — a present-but-invalid enum value, an absent or malformed address field, a
|
||||
// structurally-unparseable TagConfig — each of which makes the driver skip the tag so it reads
|
||||
// BadNodeIdUnknown forever. (Pre-R2-11 Phase C the runtime silently defaulted a typo'd enum;
|
||||
// it has rejected the tag since, so these warnings describe rejections.) In Warn mode
|
||||
// BadNodeIdUnknown forever. (The runtime used to silently default a typo'd enum;
|
||||
// it now rejects the tag, so these warnings describe rejections.) In Warn mode
|
||||
// (default) these are non-blocking (logged + appended to the accepted result Message); in Error
|
||||
// mode they fold into the reject list so a typo'd config cannot be re-deployed until fixed.
|
||||
// Running servers are untouched either way — the gate only sees re-deploys.
|
||||
|
||||
@@ -51,7 +51,7 @@ public static class ConfigComposer
|
||||
UnsAreas = await db.UnsAreas.AsNoTracking().OrderBy(x => x.UnsAreaId).ToListAsync(ct),
|
||||
UnsLines = await db.UnsLines.AsNoTracking().OrderBy(x => x.UnsLineId).ToListAsync(ct),
|
||||
NodeAcls = await db.NodeAcls.AsNoTracking().OrderBy(x => x.NodeAclId).ToListAsync(ct),
|
||||
// #520 stage 4: the data-plane credentials travel with the config, because a driver-only node
|
||||
// the data-plane credentials travel with the config, because a driver-only node
|
||||
// has no ConfigDb to read them from (per-cluster mesh Phase 4) and would otherwise deny every
|
||||
// UserName token. Only the peppered HMAC of each secret ships — never the secret — and the
|
||||
// pepper is node configuration that is deliberately NOT in the artifact, so a stolen blob
|
||||
|
||||
+3
-3
@@ -15,17 +15,17 @@ using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
|
||||
|
||||
/// <summary>
|
||||
/// Deploy-time TagConfig inspection dispatched by <c>DriverType</c> (R2-11, 05/CONV-2). Mirrors the
|
||||
/// Deploy-time TagConfig inspection dispatched by <c>DriverType</c>. Mirrors the
|
||||
/// AdminUI <c>TagConfigValidator</c> shape: a case-insensitive map from the canonical driver-type string
|
||||
/// to that driver's lightweight <c>Contracts</c> parser <c>Inspect()</c>, returning human-readable
|
||||
/// warnings. Never throws.
|
||||
/// <para><b>The warnings describe REJECTIONS, not silent defaults.</b> Every driver's runtime read has
|
||||
/// been strict since R2-11 Phase C (<c>TagConfigJson.TryReadEnumStrict</c>): a present-but-invalid enum
|
||||
/// been strict (<c>TagConfigJson.TryReadEnumStrict</c>): a present-but-invalid enum
|
||||
/// value — like an absent or malformed address field, or an unparseable document — makes the reader
|
||||
/// refuse the whole tag, so the driver skips it and it reads <c>BadNodeIdUnknown</c> forever with the
|
||||
/// deployment otherwise green. That is what these warnings are for. (Before Phase C the runtime did
|
||||
/// silently substitute a default for a typo'd enum; the doc-comments here and on the per-driver
|
||||
/// <c>Inspect</c> methods still said so until Gitea #551.) Because a warning can be PROMOTED to a
|
||||
/// <c>Inspect</c> methods used to say so.) Because a warning can be PROMOTED to a
|
||||
/// blocking error by <c>TagConfigValidationMode.Error</c>, each <c>Inspect</c> asks its driver's own
|
||||
/// reader for the verdict and only explains a refusal — never restating the rule, and so never able to
|
||||
/// be stricter than the runtime it reports on.</para>
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
|
||||
|
||||
/// <summary>
|
||||
/// How the deploy gate treats TagConfig strictness inspection results (R2-11, 05/CONV-2). Sourced from
|
||||
/// How the deploy gate treats TagConfig strictness inspection results. Sourced from
|
||||
/// <c>Deployment:TagConfigValidationMode</c> in appsettings; defaults to <see cref="Warn"/> so existing
|
||||
/// deployments are never blocked by a previously-tolerated typo.
|
||||
/// </summary>
|
||||
|
||||
+2
-2
@@ -75,7 +75,7 @@ public sealed class CentralCommunicationActor : ReceiveActor, IWithTimers
|
||||
|
||||
// Memoize-on-first-use, NOT PreStart-only: PreStart runs on a dispatcher thread concurrently
|
||||
// with the builder's remaining starters, so its resolve can lose a millisecond-wide race
|
||||
// against AuditWriterActorKey's registration (starter #4) and Resolve() would swallow that
|
||||
// against AuditWriterActorKey's registration (the fourth starter) and Resolve() would swallow that
|
||||
// into a silent null — the known-cluster gate unpublished, fail-open. These properties
|
||||
// retry on first use, but the first RefreshContacts is sent from PreStart (TimeSpan.Zero) and
|
||||
// can ALSO land inside the starter window (a fast DB load finishes in ms), so first-use is not
|
||||
@@ -241,7 +241,7 @@ public sealed class CentralCommunicationActor : ReceiveActor, IWithTimers
|
||||
//
|
||||
// Quiet (firstUse: false — see Resolve): PreStart runs on a dispatcher thread concurrently
|
||||
// with the builder's remaining starters and loses a millisecond-wide race against
|
||||
// AuditWriterActorKey's registration (starter #4) on essentially every admin boot (live
|
||||
// AuditWriterActorKey's registration (the fourth starter) on essentially every admin boot (live
|
||||
// gates: 1A 2/3 boots, 1B 4/4 boots). That is the ORDINARY case, not a malfunction — the
|
||||
// memoized PskProvider/Dialer properties retry on first use, and the bounded bootstrap
|
||||
// retry re-arms RefreshContacts until the gate publishes (the first refresh itself can
|
||||
|
||||
@@ -181,7 +181,7 @@ public sealed class MeshGrpcCommandDialer : IMeshCommandDialer, IDisposable
|
||||
await call.ResponseAsync.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Marks the dialer disposed and disposes every cached gRPC channel.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
_disposed = true;
|
||||
|
||||
+18
-13
@@ -58,7 +58,7 @@ public sealed class ConfigPublishCoordinator : ReceiveActor, IWithTimers
|
||||
public const string DeploymentsTopic = "deployments";
|
||||
public const string DeploymentAcksTopic = "deployment-acks";
|
||||
/// <summary>Central's apply deadline — the shared <see cref="DeployTimings.ApplyDeadline"/>, which the node's
|
||||
/// apply-outcome timeout is derived from (Gitea #601).</summary>
|
||||
/// apply-outcome timeout is derived from.</summary>
|
||||
public static readonly TimeSpan DefaultApplyDeadline = DeployTimings.ApplyDeadline;
|
||||
|
||||
private readonly IDbContextFactory<OtOpcUaConfigDbContext> _dbFactory;
|
||||
@@ -69,9 +69,9 @@ public sealed class ConfigPublishCoordinator : ReceiveActor, IWithTimers
|
||||
/// <see langword="null"/> to publish on the mediator directly.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Null in tests and on any node wired before Phase 2, where the fallback reproduces the
|
||||
/// pre-Phase-2 behaviour exactly. The DistributedPubSub branch behind it is <b>retained</b>,
|
||||
/// not scheduled for deletion — an earlier note here promised Phase 6 would remove it, which
|
||||
/// Null in tests and on any node wired without a mesh router, where the fallback reproduces the
|
||||
/// original behaviour exactly. The DistributedPubSub branch behind it is <b>retained</b>,
|
||||
/// not scheduled for deletion — an earlier note here promised it would be removed, which
|
||||
/// was deliberately reversed (<c>docs/Configuration.md</c> § "The compiled transport-mode
|
||||
/// defaults were deliberately NOT flipped"): DPS is still the correct and default transport
|
||||
/// for a single-mesh topology; gRPC is what crosses a mesh boundary.
|
||||
@@ -85,12 +85,12 @@ public sealed class ConfigPublishCoordinator : ReceiveActor, IWithTimers
|
||||
private static readonly IEqualityComparer<NodeId> NodeIdComparer = new CaseInsensitiveNodeIdComparer();
|
||||
private readonly Dictionary<NodeId, ApplyAckOutcome> _acks = new(NodeIdComparer);
|
||||
|
||||
/// <summary>Gitea #601 — the expected nodes whose Applied ack was DEGRADED (address-space apply partial).
|
||||
/// <summary>The expected nodes whose Applied ack was DEGRADED (address-space apply partial).
|
||||
/// Recovered from the DB on failover via <see cref="IsDegraded"/>.</summary>
|
||||
private readonly HashSet<NodeId> _degraded = new(NodeIdComparer);
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #601 — prefix of <c>NodeDeploymentState.FailureReason</c> on an <b>Applied</b> row whose apply
|
||||
/// Prefix of <c>NodeDeploymentState.FailureReason</c> on an <b>Applied</b> row whose apply
|
||||
/// was degraded. The status model is deliberately unchanged (no new enum value, no migration): a
|
||||
/// degraded node DID apply the revision, so its status stays <see cref="NodeDeploymentStatus.Applied"/>
|
||||
/// and the degradation rides the existing reason column, which <c>/fleet</c> already renders.
|
||||
@@ -98,7 +98,7 @@ public sealed class ConfigPublishCoordinator : ReceiveActor, IWithTimers
|
||||
public const string DegradedReasonPrefix = ApplyAck.DegradedReasonPrefix;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #601 — whether a <c>NodeDeploymentState</c> row records a degraded apply: status
|
||||
/// Whether a <c>NodeDeploymentState</c> row records a degraded apply: status
|
||||
/// <see cref="NodeDeploymentStatus.Applied"/> with a non-empty <c>FailureReason</c>. A clean Applied row
|
||||
/// never carries a reason (neither this coordinator nor <c>DriverHostActor</c> writes one), so an
|
||||
/// old node's row reads as clean exactly as before.
|
||||
@@ -121,7 +121,7 @@ public sealed class ConfigPublishCoordinator : ReceiveActor, IWithTimers
|
||||
/// <returns>The Akka.NET <see cref="Akka.Actor.Props"/> used to spawn this actor.</returns>
|
||||
/// <param name="meshRouter">
|
||||
/// Central comm actor the dispatch is handed to, or <see langword="null"/> to publish on the
|
||||
/// DistributedPubSub mediator directly (the pre-Phase-2 behaviour).
|
||||
/// DistributedPubSub mediator directly (the original behaviour).
|
||||
/// </param>
|
||||
public static Props Props(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
|
||||
@@ -267,7 +267,7 @@ public sealed class ConfigPublishCoordinator : ReceiveActor, IWithTimers
|
||||
}
|
||||
|
||||
_acks[msg.NodeId] = msg.Outcome;
|
||||
// #601 — an old node never sets Degraded, so its ack takes the clean branch exactly as before.
|
||||
// An old node never sets Degraded, so its ack takes the clean branch exactly as before.
|
||||
if (IsDegradedAck(msg)) _degraded.Add(msg.NodeId);
|
||||
else _degraded.Remove(msg.NodeId);
|
||||
PersistNodeAck(msg);
|
||||
@@ -291,7 +291,7 @@ public sealed class ConfigPublishCoordinator : ReceiveActor, IWithTimers
|
||||
? NodeDeploymentStatus.Applied
|
||||
: NodeDeploymentStatus.Failed;
|
||||
row.AppliedAtUtc = DateTime.UtcNow;
|
||||
// #601 — an Applied+degraded ack keeps status Applied and records WHY in the reason column, so the row
|
||||
// An Applied+degraded ack keeps status Applied and records WHY in the reason column, so the row
|
||||
// reads as degraded (IsDegraded) rather than clean. A clean or Failed ack writes what it always did.
|
||||
row.FailureReason = IsDegradedAck(msg)
|
||||
? Truncate(DegradedReasonPrefix + (string.IsNullOrWhiteSpace(msg.DegradedReason)
|
||||
@@ -317,7 +317,7 @@ public sealed class ConfigPublishCoordinator : ReceiveActor, IWithTimers
|
||||
UpdateDeploymentStatus(db, _current.Value, DeploymentStatus.Sealed, sealNow: true);
|
||||
if (_degraded.Count > 0)
|
||||
{
|
||||
// #601 — still Sealed: every node DID apply this revision and is running it, and "latest sealed" is
|
||||
// Still Sealed: every node DID apply this revision and is running it, and "latest sealed" is
|
||||
// what drift detection and the publish actor's fallback key on, so a degraded deploy that was not
|
||||
// sealed would make the fleet look one revision behind what it actually runs. Sealed is not the
|
||||
// same as clean, though: the deployment's reason names the degraded nodes (per-node detail is on
|
||||
@@ -432,11 +432,16 @@ public sealed class ConfigPublishCoordinator : ReceiveActor, IWithTimers
|
||||
/// expected-ack set and incoming acks agree regardless of host-name casing.</summary>
|
||||
private sealed class CaseInsensitiveNodeIdComparer : IEqualityComparer<NodeId>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
/// <summary>Compares two node ids by value, ignoring case.</summary>
|
||||
/// <param name="x">The first node id.</param>
|
||||
/// <param name="y">The second node id.</param>
|
||||
/// <returns><see langword="true"/> when the ids are equal ignoring case.</returns>
|
||||
public bool Equals(NodeId x, NodeId y) =>
|
||||
string.Equals(x.Value, y.Value, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Gets a case-insensitive hash code for a node id.</summary>
|
||||
/// <param name="obj">The node id.</param>
|
||||
/// <returns>The hash code of the id value.</returns>
|
||||
public int GetHashCode(NodeId obj) =>
|
||||
StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Value ?? string.Empty);
|
||||
}
|
||||
|
||||
+1
-1
@@ -181,7 +181,7 @@ public sealed class ClusterNodeAddressReconcilerActor : ReceiveActor, IWithTimer
|
||||
public static IReadOnlyList<(string Host, int Port)> FilterOwnClusterDriverMembers(
|
||||
IEnumerable<Member> members, string? ownClusterRole)
|
||||
{
|
||||
// R3-04 (01/C-9): GossipedRolesHave, not ordinal Contains — a peer gossiping a miscased
|
||||
// GossipedRolesHave, not ordinal Contains — a peer gossiping a miscased
|
||||
// 'Driver' (rolling-upgrade window) must not read as a phantom RunningNodeHasNoRow finding.
|
||||
var driverMembers = members
|
||||
.Where(m => m.Status == MemberStatus.Up && RoleParser.GossipedRolesHave(m.Roles, DriverRole));
|
||||
|
||||
@@ -31,7 +31,7 @@ public sealed record ManualFailoverSnapshot(string? PrimaryAddress, IReadOnlyLis
|
||||
/// moves with it and OPC UA clients re-select.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Post-Phase-6 (per-cluster mesh split): the Primary is elected per application
|
||||
/// With the per-cluster mesh split: the Primary is elected per application
|
||||
/// <c>Cluster</c>, because each cluster's redundant pair now runs its own independent
|
||||
/// 2-node Akka mesh. This service therefore acts on <b>this pair's own</b> Primary.
|
||||
/// </para>
|
||||
@@ -39,6 +39,7 @@ public sealed record ManualFailoverSnapshot(string? PrimaryAddress, IReadOnlyLis
|
||||
public interface IManualFailoverService
|
||||
{
|
||||
/// <summary>Reads the current driver membership from live cluster state.</summary>
|
||||
/// <returns>A snapshot of the current driver membership.</returns>
|
||||
ManualFailoverSnapshot GetSnapshot();
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -123,7 +123,7 @@ public sealed class ManualFailoverService : IManualFailoverService
|
||||
}
|
||||
|
||||
private static List<Member> OrderedDrivers(IEnumerable<Member> members) => members
|
||||
// R3-04 (01/C-9): GossipedRolesHave, not ordinal Contains — a peer gossiping a miscased
|
||||
// GossipedRolesHave, not ordinal Contains — a peer gossiping a miscased
|
||||
// 'Driver' (rolling-upgrade window) must still be a manual-failover candidate.
|
||||
.Where(m => m.Status == MemberStatus.Up && RoleParser.GossipedRolesHave(m.Roles, RedundancyStateActor.DriverRole))
|
||||
.OrderBy(m => m, Member.AgeOrdering)
|
||||
|
||||
@@ -190,13 +190,12 @@ public sealed class RedundancyStateActor : ReceiveActor, IWithTimers
|
||||
if (string.IsNullOrWhiteSpace(host)) continue;
|
||||
|
||||
var isPrimary = driverPrimary is not null && driverPrimary == member.Address;
|
||||
// R3-04 (01/C-9): GossipedRolesHave, not ordinal Contains — a peer running an older build
|
||||
// GossipedRolesHave, not ordinal Contains — a peer running an older build
|
||||
// can still gossip a miscased 'Driver' during a rolling upgrade, and this list is what
|
||||
// /hosts and /fleet render, so a miscased-but-genuinely-driver member must not read as
|
||||
// Detached. NOTE: this does NOT reach SelectDriverPrimary/driverPrimary above — that
|
||||
// delegates to ClusterActiveNode.OldestUpMember (ZB.MOM.WW.Health.Akka), which matches via
|
||||
// Akka's own ordinal Member.HasRole and is not this repo's to change (see R3-04 execution
|
||||
// notes). So a member gossiping only 'Driver' now classifies Secondary rather than
|
||||
// Akka's own ordinal Member.HasRole and is not this repo's to change. So a member gossiping only 'Driver' now classifies Secondary rather than
|
||||
// Detached, but a PAIR that BOTH gossip 'Driver' still elects no Primary.
|
||||
var role = RoleParser.GossipedRolesHave(member.Roles, DriverRole)
|
||||
? (isPrimary ? CommonsRedundancyRole.Primary : CommonsRedundancyRole.Secondary)
|
||||
|
||||
@@ -21,7 +21,7 @@ namespace ZB.MOM.WW.OtOpcUa.ControlPlane;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
// R3-04 (theme 4 guard): alias RoleParser.Admin rather than duplicating the literal — the same
|
||||
// Alias RoleParser.Admin rather than duplicating the literal — the same
|
||||
// pattern Runtime/ServiceCollectionExtensions.DriverRole already follows for RoleParser.Driver.
|
||||
public const string AdminRole = RoleParser.Admin;
|
||||
|
||||
@@ -120,7 +120,7 @@ public static class ServiceCollectionExtensions
|
||||
var dbFactory = resolver.GetService<IDbContextFactory<OtOpcUaConfigDbContext>>();
|
||||
var coordinator = registry.Get<ConfigPublishCoordinatorKey>();
|
||||
var probes = resolver.GetService<IEnumerable<IDriverProbe>>() ?? Enumerable.Empty<IDriverProbe>();
|
||||
// R2-11 (05/CONV-2): deploy-gate TagConfig strictness mode from appsettings
|
||||
// Deploy-gate TagConfig strictness mode from appsettings
|
||||
// (Deployment:TagConfigValidationMode = Warn default | Error opt-in).
|
||||
var mode = Enum.TryParse<TagConfigValidationMode>(
|
||||
resolver.GetService<IConfiguration>()?["Deployment:TagConfigValidationMode"],
|
||||
@@ -139,11 +139,11 @@ public static class ServiceCollectionExtensions
|
||||
},
|
||||
singletonOptions);
|
||||
|
||||
// Gitea #542 (2026-08-06): the fleet-status singleton is NO LONGER registered here — the
|
||||
// The fleet-status singleton is NO LONGER registered here — the
|
||||
// whole FleetStatusBroadcaster → DPS → SignalR bridge → FleetStatusHub chain was DELETED.
|
||||
// It had zero clients end-to-end (no browser JS, no HubConnection — /fleet reads
|
||||
// NodeDeploymentState from the ConfigDb on a poll), its DriverHostStatusHeartbeat input had
|
||||
// no production sender, and after mesh Phase 6 its "fleet" was only central's own pair.
|
||||
// no production sender, and after the mesh split its "fleet" was only central's own pair.
|
||||
|
||||
// Per-cluster mesh Phase 6: the redundancy-state singleton is NO LONGER registered here.
|
||||
// It was an admin-scoped, fleet-wide singleton — one Primary elected across the whole mesh.
|
||||
@@ -194,7 +194,7 @@ public static class ServiceCollectionExtensions
|
||||
/// redundancy.
|
||||
/// </summary>
|
||||
/// <param name="builder">The Akka configuration builder.</param>
|
||||
/// <param name="roleInfo">The local node's cluster-role view (Task 1's <c>ClusterRoleInfo</c>).</param>
|
||||
/// <param name="clusterOptions">The Akka cluster options, including the node's cluster role.</param>
|
||||
/// <returns>The builder for fluent chaining.</returns>
|
||||
/// <remarks>
|
||||
/// After the mesh split, central's mesh sees only its own pair — a driver-only site pair therefore
|
||||
@@ -225,7 +225,7 @@ public static class ServiceCollectionExtensions
|
||||
/// <remarks>
|
||||
/// Cluster-scope is the robust choice — it survives an accidental two-mesh merge by keeping one
|
||||
/// singleton per cluster. The <c>driver</c>-role fallback deliberately does NOT throw when a node
|
||||
/// has no cluster role, because (a) that is the pre-Phase-6 fleet-wide behavior on a legacy single
|
||||
/// has no cluster role, because (a) that is the earlier fleet-wide behavior on a legacy single
|
||||
/// mesh, keeping legacy/harness nodes and any not-yet-migrated deployment booting unchanged, and
|
||||
/// (b) on a genuinely split 2-node mesh the <c>driver</c> role is already pair-local — the mesh IS
|
||||
/// the pair. So the fallback is correct in both worlds and costs no availability.
|
||||
|
||||
@@ -115,6 +115,7 @@ public sealed class TelemetryStreamClient : IDisposable
|
||||
/// <param name="onEvent">Sink for each received raw <see cref="TelemetryEvent"/>.</param>
|
||||
/// <param name="onError">Invoked once on an abnormal transport failure (the reconnect trigger).</param>
|
||||
/// <param name="ct">Cancels the stream (normal shutdown).</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
/// <exception cref="ArgumentException"><paramref name="correlationId"/> is null or empty.</exception>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="onEvent"/> or <paramref name="onError"/> is null.</exception>
|
||||
/// <exception cref="ObjectDisposedException">This client has been disposed.</exception>
|
||||
@@ -171,7 +172,7 @@ public sealed class TelemetryStreamClient : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Marks the client disposed and releases the channel if this client created it.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
_disposed = true;
|
||||
|
||||
@@ -5,7 +5,7 @@ using Microsoft.Extensions.Logging;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host;
|
||||
|
||||
/// <summary>
|
||||
/// Down-if-alone recovery watchdog (arch-review #459). The 2-node <c>keep-oldest</c> split-brain
|
||||
/// Down-if-alone recovery watchdog. The 2-node <c>keep-oldest</c> split-brain
|
||||
/// resolver downs the <b>lone survivor</b> when the OLDEST node crashes (the survivor is "the side
|
||||
/// without the oldest" → <c>DownReachable</c>), and <c>run-coordinated-shutdown-when-down = on</c>
|
||||
/// terminates that node's <see cref="ActorSystem"/>. Recovery is by <b>exit-and-rejoin</b>: the
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
/// <summary>
|
||||
/// Node-level defaults for the AbCip driver (final review I1), bound from <c>Drivers:AbCip</c>. Lets an
|
||||
/// operator pin the transport an AbCip driver with NO <c>transport</c> key runs on — at upgrade time,
|
||||
/// without editing (or being able to edit, #576) every driver config. An explicit per-driver
|
||||
/// without editing (or being able to edit) every driver config. An explicit per-driver
|
||||
/// <c>transport</c> key still wins. Overridable by environment variable
|
||||
/// <c>Drivers__AbCip__DefaultTransport</c>.
|
||||
/// </summary>
|
||||
@@ -66,7 +66,7 @@ public static class AbCipDriverDefaultsRegistration
|
||||
/// Binds + validates <c>Drivers:AbCip</c>. Unconditional on every role: BOTH consumers need it — the driver
|
||||
/// factory (driver role) and the Test Connect probe (admin role, which must probe the stack the driver would
|
||||
/// run). A bad value fails host start rather than silently falling back. <c>Program.cs</c> must call this
|
||||
/// (pinned by <c>AbCipDriverDefaultsOptionsTests</c>): without it an operator's pin is silently ignored (#585).
|
||||
/// (pinned by <c>AbCipDriverDefaultsOptionsTests</c>): without it an operator's pin is silently ignored.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configuration">The host configuration.</param>
|
||||
|
||||
@@ -12,7 +12,7 @@ using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Composition of the OPC UA data-plane authentication + authorization seams (#520), extracted from
|
||||
/// Composition of the OPC UA data-plane authentication + authorization seams, extracted from
|
||||
/// <c>Program.cs</c> so the role-conditional branches are reachable from a test.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
@@ -42,7 +42,7 @@ public static class DataPlaneAuthRegistration
|
||||
// matched". TryAdd so a test (or a future host) can substitute one before this runs.
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
|
||||
// #520 stage 5 — the node-scoped ACL gate. Registered on EVERY node, not just driver ones, because
|
||||
// the node-scoped ACL gate. Registered on EVERY node, not just driver ones, because
|
||||
// OtOpcUaServerHostedService takes it as a constructor parameter and the built-in container resolves
|
||||
// constructors by whether every parameter is registered, not by whether the parameter type is nullable.
|
||||
// That is the same trap the IOpcUaUserAuthenticator and IDataPlaneAuthoritySource registrations below
|
||||
|
||||
@@ -19,6 +19,8 @@ public static class HistorianBackendRegistry
|
||||
public const string SqlServerBackendName = "SqlServer";
|
||||
|
||||
/// <summary>Binds the <c>ServerHistorian:SqlServer</c> section (shared by the registry and the validator).</summary>
|
||||
/// <param name="configuration">The host configuration.</param>
|
||||
/// <returns>The bound SQL Server historian options.</returns>
|
||||
public static SqlServerHistorianOptions BindSqlServer(IConfiguration configuration)
|
||||
{
|
||||
var o = new SqlServerHistorianOptions();
|
||||
@@ -30,6 +32,8 @@ public static class HistorianBackendRegistry
|
||||
public const string QuestDbBackendName = "QuestDb";
|
||||
|
||||
/// <summary>Binds the <c>ServerHistorian:QuestDb</c> section (shared by the registry and the validator).</summary>
|
||||
/// <param name="configuration">The host configuration.</param>
|
||||
/// <returns>The bound QuestDB historian options.</returns>
|
||||
public static QuestDbHistorianOptions BindQuestDb(IConfiguration configuration)
|
||||
{
|
||||
var o = new QuestDbHistorianOptions();
|
||||
@@ -49,10 +53,15 @@ public static class HistorianBackendRegistry
|
||||
public static IReadOnlyCollection<string> KnownBackends { get; } = Factories.Keys.ToArray();
|
||||
|
||||
/// <summary>True when <paramref name="backend"/> names a registered backend.</summary>
|
||||
/// <param name="backend">The backend name to look up; may be null.</param>
|
||||
/// <returns><see langword="true"/> when the name is registered.</returns>
|
||||
public static bool IsKnown(string? backend) =>
|
||||
!string.IsNullOrWhiteSpace(backend) && Factories.ContainsKey(backend.Trim());
|
||||
|
||||
/// <summary>Builds the provider for <c>options.Backend</c>; throws <see cref="OptionsValidationException"/> if unknown.</summary>
|
||||
/// <param name="options">The server historian options naming the backend.</param>
|
||||
/// <param name="configuration">The host configuration passed to the backend factory.</param>
|
||||
/// <returns>The provider for the configured backend.</returns>
|
||||
public static IHistorianBackendProvider Resolve(ServerHistorianOptions options, IConfiguration configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
@@ -51,7 +51,7 @@ public sealed class LdapOptionsValidator : OptionsValidatorBase<LdapOptions>
|
||||
// A non-positive connect/search timeout would leave the shared library unbounded against a hung
|
||||
// directory, so fail fast. The other four S2 knobs validated here (AuthTimeoutMs,
|
||||
// MaxConcurrentAuthentications, OutageFailureThreshold, OutageCooldownSeconds) went with
|
||||
// LdapOpcUaUserAuthenticator in #520 — they guarded an LDAP bind running inline on OPC UA
|
||||
// LdapOpcUaUserAuthenticator — they guarded an LDAP bind running inline on OPC UA
|
||||
// session activation, and no remaining LDAP consumer runs on that path.
|
||||
builder.RequireThat(options.ConnectionTimeoutMs > 0,
|
||||
"Ldap:ConnectionTimeoutMs must be > 0.");
|
||||
|
||||
@@ -19,7 +19,7 @@ namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
/// <c>Program.cs</c> so the wiring is covered by a DI resolution test — <c>Program.cs</c> is
|
||||
/// top-level statements and cannot be exercised directly, which is exactly how a
|
||||
/// "registered but never resolvable" defect ships unnoticed. This family has shipped that
|
||||
/// defect three times (Secrets 0.2.0, Secrets 0.2.2, ScadaBridge #22).
|
||||
/// defect three times (Secrets 0.2.0, Secrets 0.2.2, ScadaBridge).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Driver-role nodes only.</b> Admin-only nodes have no deployed configuration to cache,
|
||||
|
||||
@@ -170,7 +170,7 @@ public static class LocalDbSetup
|
||||
/// <returns><c>true</c> when this node is configured to replicate.</returns>
|
||||
public static bool ReplicationIsConfigured(IConfiguration configuration)
|
||||
{
|
||||
// Defined beside the alarm-historian drain gate's stricter predicate (#603, review R1 m3): every node
|
||||
// Defined beside the alarm-historian drain gate's stricter predicate: every node
|
||||
// the gate treats as sharing rows also captures, so the two never disagree in the harmful direction.
|
||||
return Runtime.LocalDbReplicationPolicy.CapturesChanges(configuration);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ internal static class RoleOverlay
|
||||
/// The overlay filename suffix: the plain (non-cluster) roles, ordinal-sorted and joined with <c>-</c>, or
|
||||
/// <see langword="null"/> when the node has none.
|
||||
/// </summary>
|
||||
/// <param name="gossipedRoles">The roles gossiped for this node.</param>
|
||||
/// <returns>The suffix, or <see langword="null"/> when the node has no plain roles.</returns>
|
||||
public static string? Suffix(IReadOnlyList<string> gossipedRoles)
|
||||
{
|
||||
var plain = gossipedRoles
|
||||
|
||||
@@ -48,7 +48,7 @@ public static class DriverFactoryBootstrap
|
||||
/// <returns>The same service collection, for chaining.</returns>
|
||||
public static IServiceCollection AddOtOpcUaDriverFactories(this IServiceCollection services)
|
||||
{
|
||||
// Gitea #511: the Primary data-plane verdict the MQTT driver's Sparkplug host claim is gated on.
|
||||
// The Primary data-plane verdict the MQTT driver's Sparkplug host claim is gated on.
|
||||
//
|
||||
// Registered HERE as well as in AddOtOpcUaRuntime, and both sides TryAdd the same concrete
|
||||
// RedundancyRoleView, so whichever extension runs first creates it and the other resolves the
|
||||
@@ -71,7 +71,7 @@ public static class DriverFactoryBootstrap
|
||||
// The Galaxy driver resolves its Gateway.ApiKeySecretRef secret: arm through the
|
||||
// shared ISecretResolver (registered unconditionally by AddZbSecrets on every node).
|
||||
var secretResolver = sp.GetRequiredService<ISecretResolver>();
|
||||
// Gitea #511: the MQTT driver's Sparkplug primary-host claim is Primary-gated, and unlike
|
||||
// The MQTT driver's Sparkplug primary-host claim is Primary-gated, and unlike
|
||||
// every other gated operation it is a STANDING assertion on the driver's own connection —
|
||||
// there is no inbound message for DriverHostActor to intercept — so the verdict has to
|
||||
// reach the driver itself. Registered unconditionally on driver-role nodes by
|
||||
@@ -111,7 +111,7 @@ public static class DriverFactoryBootstrap
|
||||
});
|
||||
|
||||
// The tracker's operator-facing reader: a periodic DPS publisher that mirrors each snapshot to the
|
||||
// AdminUI resilience panel (R2-10 — closes the "tracker fed by everything, read by nothing" gap).
|
||||
// AdminUI resilience panel (closes the "tracker fed by everything, read by nothing" gap).
|
||||
// Lives on driver nodes exactly where the tracker exists. The lazy Func<ActorSystem> is idempotent
|
||||
// with the one Program.cs registers for the alarm-command router.
|
||||
services.TryAddSingleton<Func<Akka.Actor.ActorSystem>>(sp => () => sp.GetRequiredService<Akka.Actor.ActorSystem>());
|
||||
|
||||
@@ -58,13 +58,11 @@ public sealed class RoslynVirtualTagEvaluator : IVirtualTagEvaluator, IScriptCac
|
||||
_runTimeout = runTimeout ?? TimeSpan.FromSeconds(2);
|
||||
}
|
||||
|
||||
// Value-only overload. Every dependency reaches the script stamped Good with an
|
||||
// evaluation-time timestamp — bare values carry nothing better. The runtime calls the snapshot
|
||||
// overload instead; this one remains for callers that genuinely hold only
|
||||
// values.
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// <b>Value-only overload.</b> Every dependency reaches the script stamped <c>Good</c> with an
|
||||
/// evaluation-time timestamp — bare values carry nothing better. The runtime calls the snapshot
|
||||
/// overload instead (archreview 02/S14); this one remains for callers that genuinely hold only
|
||||
/// values.
|
||||
/// </remarks>
|
||||
public VirtualTagEvalResult Evaluate(string virtualTagId, string expression, IReadOnlyDictionary<string, object?> dependencies)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dependencies);
|
||||
@@ -76,15 +74,13 @@ public sealed class RoslynVirtualTagEvaluator : IVirtualTagEvaluator, IScriptCac
|
||||
return Evaluate(virtualTagId, expression, snapshots);
|
||||
}
|
||||
|
||||
// Overrides the interface's default (which projects to values and loses the status codes): the
|
||||
// snapshots pass through as the script's read cache, so
|
||||
// ctx.GetTag(path).StatusCode and .SourceTimestampUtc are the input's own rather
|
||||
// than a fabricated Good stamped at evaluation time. The virtual-tag
|
||||
// actor holds evaluation entirely while any input is Bad, so what a script observes here is Good
|
||||
// or Uncertain.
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Overrides the interface's default (which projects to values and loses the status codes): the
|
||||
/// snapshots pass <b>through</b> as the script's read cache, so
|
||||
/// <c>ctx.GetTag(path).StatusCode</c> and <c>.SourceTimestampUtc</c> are the input's own rather
|
||||
/// than a fabricated <c>Good</c> stamped at evaluation time (archreview 02/S14). The virtual-tag
|
||||
/// actor holds evaluation entirely while any input is Bad, so what a script observes here is Good
|
||||
/// or Uncertain.
|
||||
/// </remarks>
|
||||
public VirtualTagEvalResult Evaluate(
|
||||
string virtualTagId, string expression, IReadOnlyDictionary<string, DataValueSnapshot> dependencies)
|
||||
{
|
||||
|
||||
@@ -148,7 +148,7 @@ public static class TelemetryProtoMapNode
|
||||
}
|
||||
}
|
||||
|
||||
// Gitea #541. The field's own presence carries "driver cannot report skips" vs "reports none",
|
||||
// The field's own presence carries "driver cannot report skips" vs "reports none",
|
||||
// so leaving it unset is meaningful — do not replace this with an always-set empty message.
|
||||
if (e.SkippedTags is { } skipped)
|
||||
{
|
||||
@@ -164,7 +164,7 @@ public static class TelemetryProtoMapNode
|
||||
msg.SkippedTags = payload;
|
||||
}
|
||||
|
||||
// Gitea #640. Presence flag first - an empty map cannot say whether the driver has the capability.
|
||||
// Presence flag first - an empty map cannot say whether the driver has the capability.
|
||||
if (e.Diagnostics is not null)
|
||||
{
|
||||
msg.HasDiagnostics = true;
|
||||
|
||||
@@ -99,7 +99,10 @@ public sealed class LocalDbReplicationHealthCheck : IHealthCheck
|
||||
_degradedThreshold = degradedThreshold;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Reports LocalDb replication health: Healthy when absent or in sync, otherwise Degraded per the peer state.</summary>
|
||||
/// <param name="context">The health check context.</param>
|
||||
/// <param name="cancellationToken">Cancels the check.</param>
|
||||
/// <returns>The replication health result.</returns>
|
||||
public Task<HealthCheckResult> CheckHealthAsync(
|
||||
HealthCheckContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
|
||||
@@ -9,7 +9,7 @@ namespace ZB.MOM.WW.OtOpcUa.Host.Logging;
|
||||
/// reproduced it via handle contention on one path, where <c>RollingFileSink</c> logs the
|
||||
/// lock-out to SelfLog and quietly falls through to the next name in sequence.
|
||||
///
|
||||
/// ⚠️ It is NOT, however, what Gitea #561 turned out to be. That investigation concluded from
|
||||
/// ⚠️ It is NOT, however, what the reported 0-byte production log turned out to be. That investigation concluded from
|
||||
/// <c>Get-ChildItem</c> that production's log was 0 bytes for six weeks; opening the file showed
|
||||
/// 4.66 MB of correct output. See <see cref="MeasureFamilyBytes"/> for the Windows metadata
|
||||
/// behaviour behind the false reading — the same trap this class must not fall into, since a
|
||||
@@ -89,6 +89,7 @@ public static class LogPipelineDiagnostics
|
||||
/// </summary>
|
||||
/// <param name="configuration">The host configuration carrying <c>Serilog:WriteTo</c>.</param>
|
||||
/// <param name="emitMarker">Emits one event through the live pipeline (synchronous sinks flush before returning).</param>
|
||||
/// <returns>A warning describing the silent sink, or <see langword="null"/> when the check passes.</returns>
|
||||
public static string? CheckFileSinkGrowth(IConfiguration configuration, Action emitMarker)
|
||||
{
|
||||
try
|
||||
@@ -137,6 +138,7 @@ public static class LogPipelineDiagnostics
|
||||
/// useless when the file sink is the broken part, but it reaches the console sink and any
|
||||
/// OTel export.
|
||||
/// </summary>
|
||||
/// <param name="warning">The warning text to write.</param>
|
||||
public static void ReportFailure(string warning) => SelfLog.WriteLine("{0}", warning);
|
||||
|
||||
/// <summary>
|
||||
@@ -153,7 +155,7 @@ public static class LogPipelineDiagnostics
|
||||
/// size-at-open — typically 0, with <c>LastWriteTime == CreationTime</c> — for the entire life
|
||||
/// of the handle. Measuring that way makes this check declare every healthy sink silent at
|
||||
/// boot: instrumentation that manufactures the exact failure it exists to detect. It also
|
||||
/// produced the false 0-byte reading that made Gitea #561 look like a six-week production
|
||||
/// produced the false 0-byte reading that made that report look like a six-week production
|
||||
/// logging outage when the file in fact held 4.66 MB of correct output.
|
||||
///
|
||||
/// <see cref="FileShare.ReadWrite"/> is required to open the file at all while the sink holds
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||
|
||||
/// <summary>
|
||||
/// Derives the interim server-wide role set for an authenticated principal from its
|
||||
/// <see cref="NodeAcl"/> grants (#520, stage 2).
|
||||
/// <see cref="NodeAcl"/> grants.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
|
||||
@@ -9,7 +9,7 @@ using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||
|
||||
/// <summary>
|
||||
/// Production <see cref="IOpcUaUserAuthenticator"/> for the machine-to-machine data plane (#520):
|
||||
/// Production <see cref="IOpcUaUserAuthenticator"/> for the machine-to-machine data plane:
|
||||
/// the client presents the static username <c>apikey</c> with an
|
||||
/// <c>otua_<keyId>_<secret></c> credential as the password. Replaces the LDAP adapter.
|
||||
/// </summary>
|
||||
@@ -156,12 +156,10 @@ public sealed class ApiKeyOpcUaUserAuthenticator : IOpcUaUserAuthenticator
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Resolves the <c>anonymous</c> principal's grants through the same mapper a key's principal
|
||||
/// goes through, so anonymous is authorized by ordinary ACL rows rather than by a special case.
|
||||
/// Whether anonymous may connect at all is the host's decision
|
||||
/// (<c>OpcUa:AllowAnonymous</c>) and is settled before this is called.
|
||||
/// </remarks>
|
||||
// Resolves the <c>anonymous</c> principal's grants through the same mapper a key's principal
|
||||
// goes through, so anonymous is authorized by ordinary ACL rows rather than by a special case.
|
||||
// Whether anonymous may connect at all is the host's decision
|
||||
// (<c>OpcUa:AllowAnonymous</c>) and is settled before this is called.
|
||||
public async Task<OpcUaUserAuthResult> AuthenticateAnonymousAsync(CancellationToken ct)
|
||||
{
|
||||
IReadOnlyList<NodeAcl> grants;
|
||||
@@ -171,9 +169,9 @@ public sealed class ApiKeyOpcUaUserAuthenticator : IOpcUaUserAuthenticator
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Deny rather than admit-with-no-roles. Admitting would be the pre-#520 behaviour, and doing
|
||||
// Deny rather than admit-with-no-roles. Admitting would be the previous behaviour, and doing
|
||||
// it precisely when we could not read what anonymous is allowed to do is the wrong default:
|
||||
// once reads are gated (stage 6) a no-roles admission stops being harmless.
|
||||
// once reads are gated a no-roles admission stops being harmless.
|
||||
_log.LogError(ex, "ApiKeyAuth: key store threw resolving anonymous grants");
|
||||
return OpcUaUserAuthResult.Deny("Anonymous access is unavailable.");
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="IApiKeyStore"/> served from the deployment artifact (#520, stage 4). This is the key
|
||||
/// <see cref="IApiKeyStore"/> served from the deployment artifact. This is the key
|
||||
/// source for a <b>driver-only</b> node, which has had no ConfigDb since per-cluster mesh Phase 4 and
|
||||
/// until now denied every UserName token.
|
||||
/// </summary>
|
||||
@@ -32,7 +32,7 @@ namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||
/// keys.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Returns a principal's FULL grant set</b> (#520 stage 5). It used to narrow to the
|
||||
/// <b>Returns a principal's FULL grant set</b>. It used to narrow to the
|
||||
/// <c>Cluster</c>-scoped rows, because those were the only ones that could confer an interim role.
|
||||
/// The narrowing was always redundant — <see cref="ApiKeyInterimRoleMapper"/> applies exactly the
|
||||
/// same filter itself — and it became a trap once scope was enforced: a caller reasonably reading
|
||||
|
||||
@@ -95,14 +95,12 @@ public sealed class BoundedApiKeyStore : IApiKeyStore
|
||||
/// <remarks>Same reason as <see cref="Inner"/>: the shared-clock invariant is only pinnable by reference.</remarks>
|
||||
public TimeProvider Clock => _time;
|
||||
|
||||
// Keyed by apiKeyId alone. utcNow is deliberately not part
|
||||
// of the key — it changes on every call, so including it would defeat the cache entirely — but a
|
||||
// cached hit IS re-checked against it before being served, so the TTL can never resurrect a key
|
||||
// that has expired by the clock in the meantime. The TTL therefore bounds how stale an
|
||||
// Enabled revocation may be, and nothing else.
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Keyed by <paramref name="apiKeyId"/> alone. <paramref name="utcNow"/> is deliberately not part
|
||||
/// of the key — it changes on every call, so including it would defeat the cache entirely — but a
|
||||
/// cached hit IS re-checked against it before being served, so the TTL can never resurrect a key
|
||||
/// that has expired by the clock in the meantime. The TTL therefore bounds how stale an
|
||||
/// <c>Enabled</c> revocation may be, and nothing else.
|
||||
/// </remarks>
|
||||
public async Task<ApiKeyLookup?> FindAsync(string apiKeyId, DateTime utcNow, CancellationToken ct)
|
||||
{
|
||||
var ttl = Ttl;
|
||||
@@ -123,8 +121,8 @@ public sealed class BoundedApiKeyStore : IApiKeyStore
|
||||
return fresh;
|
||||
}
|
||||
|
||||
// Keyed by principal — the anonymous activation path, which runs on every unauthenticated connect.
|
||||
/// <inheritdoc />
|
||||
/// <remarks>Keyed by principal — the anonymous activation path, which runs on every unauthenticated connect.</remarks>
|
||||
public async Task<IReadOnlyList<NodeAcl>> FindGrantsAsync(string principal, CancellationToken ct)
|
||||
{
|
||||
var ttl = Ttl;
|
||||
@@ -209,7 +207,7 @@ public sealed class BoundedApiKeyStore : IApiKeyStore
|
||||
/// <c>CancellationTokenSource(TimeSpan, TimeProvider)</c> linked into the inner call, which
|
||||
/// bounds the call only if the callee chooses to observe the token.
|
||||
/// <c>Microsoft.Data.SqlClient</c> does not, while a connect or login is in flight on a
|
||||
/// black-holed socket: the 2026-08-20 R3-05 gate paused the SQL container and watched a keyed
|
||||
/// black-holed socket: a 2026-08-20 live gate paused the SQL container and watched a keyed
|
||||
/// activation hang <b>42.9 s</b> past its 2000 ms bound (the Warning below fired only at
|
||||
/// unpause), stalling an unrelated already-open subscription for <b>57 s</b> behind the
|
||||
/// <c>SessionManager</c> lock — the exact hazard the bound exists to prevent. So the inner call
|
||||
|
||||
@@ -7,12 +7,12 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="IApiKeyStore"/> reading the config database directly (#520, stage 2). Usable on a node
|
||||
/// <see cref="IApiKeyStore"/> reading the config database directly. Usable on a node
|
||||
/// that has a ConfigDb — i.e. one carrying the <c>admin</c> role, including a fused admin+driver node.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>A driver-only node has no ConfigDb since per-cluster mesh Phase 4</b>, so this store cannot be
|
||||
/// registered there; that node uses <see cref="ArtifactApiKeyStore"/> instead (#520 stage 4).
|
||||
/// registered there; that node uses <see cref="ArtifactApiKeyStore"/> instead.
|
||||
/// <para>
|
||||
/// Reading per activation is what makes revocation prompt here, which is why this store is
|
||||
/// preferred wherever a ConfigDb exists — including on a fused admin+driver node. ⚠️ It reaches a
|
||||
@@ -72,7 +72,7 @@ public sealed class ConfigDbApiKeyStore : IApiKeyStore
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This is the same derivation <see cref="Cluster.ClusterRoleInfo"/> performs for
|
||||
/// <c>IClusterRoleInfo.ClusterId</c> — literally so as of Gitea #532, which collapsed four
|
||||
/// <c>IClusterRoleInfo.ClusterId</c> — literally so now that the shared derivation collapsed four
|
||||
/// open-coded copies into <see cref="RoleParser.ClusterIdFromRoles"/>. The copies had drifted:
|
||||
/// three classified the cluster role case-sensitively and one did not, and only one applied
|
||||
/// the <c>OTOPCUA_ROLES</c> fallback. On a node that hit either gap this method returned
|
||||
@@ -127,7 +127,7 @@ public sealed class ConfigDbApiKeyStore : IApiKeyStore
|
||||
|
||||
/// <summary>Loads a principal's grants, confined to this node's cluster.</summary>
|
||||
/// <remarks>
|
||||
/// <b>The full grant set, not just the <c>Cluster</c>-scoped rows</b> (#520 stage 5). The old
|
||||
/// <b>The full grant set, not just the <c>Cluster</c>-scoped rows</b>. The old
|
||||
/// narrowing existed because only cluster-scoped rows could confer an interim role, but
|
||||
/// <see cref="ApiKeyInterimRoleMapper"/> applies that same filter itself, so it was redundant even
|
||||
/// then — and once scope is enforced it becomes a trap, silently truncating what
|
||||
|
||||
@@ -3,7 +3,7 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||
|
||||
/// <summary>
|
||||
/// Source of API keys and their ACL grants for OPC UA session activation (#520).
|
||||
/// Source of API keys and their ACL grants for OPC UA session activation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A seam rather than a direct ConfigDb read because the two sources differ by node role. A fused
|
||||
|
||||
@@ -65,7 +65,7 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
|
||||
/// node, the same source the address-space sink + node-write gateway come from), or the configured
|
||||
/// HistorianGateway read client when <c>AddServerHistorian</c> enabled it. Wired onto the node manager in
|
||||
/// <see cref="StartAsync"/>.</param>
|
||||
/// <param name="dataPlaneAuthority">#520 stage 4 — the node's data-plane authorization inputs (its
|
||||
/// <param name="dataPlaneAuthority">The node's data-plane authorization inputs (its
|
||||
/// cluster, the current auth generation, and the ACL grants), refreshed from every applied deployment
|
||||
/// artifact. Wired onto the node manager in <see cref="StartAsync"/> so the stage 5/6 enforcement seams
|
||||
/// have a cluster identity to build a <c>NodeScope</c> with. Resolves to the empty source on a node with
|
||||
@@ -81,10 +81,10 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
|
||||
/// <param name="actorRegistry">The Akka.Hosting actor registry, used to resolve the local
|
||||
/// <c>DriverHostActor</c> ref (<c>DriverHostActorKey</c>) the inbound node-write router Asks. Resolved
|
||||
/// in <see cref="StartAsync"/> after the runtime actors have been registered.</param>
|
||||
/// <param name="dataPlaneAuthorizer">#520 stage 5 — the node-scoped ACL gate. Nullable, and null means
|
||||
/// <param name="dataPlaneAuthorizer">The node-scoped ACL gate. Nullable, and null means
|
||||
/// "not wired" rather than "deny": an unregistered gate must not lock a node out of writes to protect a
|
||||
/// feature that is off by default. Once wired AND enabled, every unresolvable case inside it denies.</param>
|
||||
/// <param name="aclOptions">#520 / arch-review 03/S19 — the <c>Security:Acl</c> options, read for
|
||||
/// <param name="aclOptions">The <c>Security:Acl</c> options, read for
|
||||
/// <see cref="AclOptions.RevalidationIntervalSeconds"/>. Required, not optional: the built-in DI
|
||||
/// container does not fall back to a default for an unregistered service, the same trap the
|
||||
/// <c>IOpcUaUserAuthenticator</c> and <c>IDataPlaneAuthoritySource</c> parameters carry comments
|
||||
@@ -92,7 +92,7 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
|
||||
/// <param name="time">The shared clock — the one instance the authorizer and the API-key store bound
|
||||
/// also read. Its <c>CreateTimer</c> arms the revalidation tick.</param>
|
||||
/// <param name="loggerFactory">The logger factory for creating loggers.</param>
|
||||
/// <param name="primaryRoleSource">Gitea #597 — the data-plane Primary verdict <c>DriverHostActor</c>
|
||||
/// <param name="primaryRoleSource">The data-plane Primary verdict <c>DriverHostActor</c>
|
||||
/// publishes on every redundancy snapshot (the same one its native-ack route and device-write gates apply).
|
||||
/// Wired onto the node manager so a native-condition Acknowledge on a non-Primary node is refused rather than
|
||||
/// applied and then reverted. Optional: a node with no driver role registers none, and null means "allow" —
|
||||
@@ -100,7 +100,7 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
|
||||
/// <param name="historianUpdater">The selected historian backend's optional HistoryUpdate capability.
|
||||
/// Optional: when unregistered it resolves to <see cref="HistorianUpdaterHolder.None"/> and HistoryUpdate is
|
||||
/// reported unsupported on every node.</param>
|
||||
/// <param name="historyUpdateRoleView">Gitea #674 — the HistoryUpdate verdict <c>DriverHostActor</c> publishes (the
|
||||
/// <param name="historyUpdateRoleView">The HistoryUpdate verdict <c>DriverHostActor</c> publishes (the
|
||||
/// data-plane Primary verdict, closed until this node's first redundancy snapshot). Optional: registered on every
|
||||
/// driver-role node; an admin-only node has none and wires no HistoryUpdate Primary gate (allow).</param>
|
||||
public OtOpcUaServerHostedService(
|
||||
@@ -253,7 +253,7 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
|
||||
}
|
||||
});
|
||||
|
||||
// Gitea #597: a native ack is accepted only on the Primary. The verdict is the one DriverHostActor
|
||||
// A native ack is accepted only on the Primary. The verdict is the one DriverHostActor
|
||||
// publishes and gates the route above on, so a Secondary refuses (BadInvalidState) instead of applying an
|
||||
// ack the driver will never see and then reverting it. A volatile property read — safe under the SDK Lock.
|
||||
if (_primaryRoleSource is { } primaryRoleSource)
|
||||
@@ -291,12 +291,12 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
|
||||
// The node manager's HistoryRead overrides block-bridge to whatever source is set here.
|
||||
_server.SetHistorianDataSource(_historianDataSource);
|
||||
|
||||
// #520 stage 4 — hand the node manager the node's data-plane authorization inputs. It exists because
|
||||
// Hand the node manager the node's data-plane authorization inputs. It exists because
|
||||
// the node manager had NO cluster identity, and NodeScope.ClusterId is required while the evaluator
|
||||
// fails closed on a mismatch — every scope it could have built would have been denied.
|
||||
_server.SetDataPlaneAuthority(_dataPlaneAuthority);
|
||||
|
||||
// #520 stage 5 — hand it the node-scoped ACL gate. Still behaviour-neutral unless
|
||||
// Hand it the node-scoped ACL gate. Still behaviour-neutral unless
|
||||
// Security:Acl:Enabled is set: the gate reports itself disabled and every seam keeps its
|
||||
// server-wide role check.
|
||||
_server.SetDataPlaneAuthorizer(_dataPlaneAuthorizer);
|
||||
@@ -317,10 +317,10 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
|
||||
// section is absent; NodeManager is non-null here (guarded above).
|
||||
_server.NodeManager.MaxTieClusterOverfetch = _serverHistorianOptions.MaxTieClusterOverfetch;
|
||||
|
||||
// Gitea #691: the ReturnBounds kill switch (default on).
|
||||
// The ReturnBounds kill switch (default on).
|
||||
_server.NodeManager.HonorReturnBounds = HonorReturnBounds;
|
||||
|
||||
// Gitea #694: the opt-in HistoryReadEvents horizon (default off = exact to each read's own window).
|
||||
// The opt-in HistoryReadEvents horizon (default off = exact to each read's own window).
|
||||
_server.NodeManager.EventReadHorizon = EventReadHorizon;
|
||||
|
||||
// Propagate the S3 HistoryRead concurrency bounds (arch-review 03/S3): bounded per-node fan-out
|
||||
@@ -330,7 +330,7 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
|
||||
_server.NodeManager.MaxConcurrentHistoryReadBatches = _serverHistorianOptions.MaxConcurrentHistoryReadBatches;
|
||||
_server.NodeManager.HistoryReadDeadline = _serverHistorianOptions.HistoryReadDeadline;
|
||||
|
||||
// Gitea #674: HistoryUpdate is Primary-only (BadInvalidState elsewhere, and closed until this node's first
|
||||
// HistoryUpdate is Primary-only (BadInvalidState elsewhere, and closed until this node's first
|
||||
// redundancy snapshot) and refuses a SourceTimestamp beyond now + HistoryUpdateMaxFutureSkew (BadOutOfRange),
|
||||
// judged against the host clock.
|
||||
var historyUpdate = BuildHistoryUpdatePosture();
|
||||
@@ -379,19 +379,19 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #691 — the <c>ServerHistorian:HonorReturnBounds</c> kill switch <c>StartAsync</c> applies to the node manager
|
||||
/// The <c>ServerHistorian:HonorReturnBounds</c> kill switch <c>StartAsync</c> applies to the node manager
|
||||
/// (default <c>true</c> when the section or key is absent). Exposed so the wiring is testable without booting the SDK.
|
||||
/// </summary>
|
||||
internal bool HonorReturnBounds => _serverHistorianOptions.HonorReturnBounds;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #694 — the <c>ServerHistorian:EventReadHorizon</c> <c>StartAsync</c> applies to the node manager (default
|
||||
/// The <c>ServerHistorian:EventReadHorizon</c> <c>StartAsync</c> applies to the node manager (default
|
||||
/// <see cref="TimeSpan.Zero"/> = none). Exposed so the wiring is testable without booting the SDK.
|
||||
/// </summary>
|
||||
internal TimeSpan EventReadHorizon => _serverHistorianOptions.EventReadHorizon;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #674 — what <c>StartAsync</c> wires onto the node manager for HistoryUpdate: the Primary gate (read from
|
||||
/// What <c>StartAsync</c> wires onto the node manager for HistoryUpdate: the Primary gate (read from
|
||||
/// the <see cref="IHistoryUpdateRoleView"/> on every call; null when none is registered, i.e. allow), the
|
||||
/// future-timestamp bound from <c>ServerHistorian:HistoryUpdateMaxFutureSkew</c>, and the host clock it is judged
|
||||
/// against. Separate from <c>StartAsync</c> so the wiring is testable without booting the SDK.
|
||||
@@ -406,7 +406,7 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
|
||||
return new HistoryUpdatePosture(gate, _serverHistorianOptions.HistoryUpdateMaxFutureSkew, _time);
|
||||
}
|
||||
|
||||
/// <summary>Gitea #674 — the HistoryUpdate settings <see cref="BuildHistoryUpdatePosture"/> produces.</summary>
|
||||
/// <summary>The HistoryUpdate settings <see cref="BuildHistoryUpdatePosture"/> produces.</summary>
|
||||
/// <param name="PrimaryGate">The node manager's HistoryUpdate Primary gate; null allows.</param>
|
||||
/// <param name="MaxFutureSkew">The future-timestamp bound; non-positive disables it.</param>
|
||||
/// <param name="Clock">The clock the bound is judged against.</param>
|
||||
|
||||
@@ -57,7 +57,7 @@ using ZB.MOM.WW.Secrets.DependencyInjection;
|
||||
using ZB.MOM.WW.Secrets.Replicator.AkkaDotNet;
|
||||
using ZB.MOM.WW.Secrets.Sqlite;
|
||||
|
||||
// #618 — the composition root runs inside one try/catch (not re-indented, to keep the diff reviewable)
|
||||
// the composition root runs inside one try/catch (not re-indented, to keep the diff reviewable)
|
||||
// so ANY failure to start — an unknown role, a ValidateOnStart rejection, a hosted service that
|
||||
// throws from StartAsync — is logged Fatal, flushed, and ends the process with a NON-ZERO exit code.
|
||||
// Left as an unhandled exception the host could stay up as a zombie under a supervisor/container
|
||||
@@ -68,7 +68,7 @@ try
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// The SINGLE role authority: Cluster:Roles first, OTOPCUA_ROLES as the fallback (archreview
|
||||
// 01/S-12 = 03/C5, R3-04). Resolved off builder.Configuration BEFORE the per-role overlay is
|
||||
// 01/S-12 = 03/C5). Resolved off builder.Configuration BEFORE the per-role overlay is
|
||||
// added, because the overlay's FILENAME depends on the answer — which is why a role overlay may
|
||||
// not itself set Cluster:Roles (AkkaClusterOptionsValidator fails start if one ever does).
|
||||
// EffectiveRoles drives the entire conditional wiring below — see ZB.MOM.WW.OtOpcUa.Cluster.
|
||||
@@ -91,7 +91,7 @@ builder.Host.UseWindowsService(options => options.ServiceName = "OtOpcUaHost");
|
||||
|
||||
// Per-role appsettings overlay: appsettings.{role}.json (single role) or appsettings.admin-driver.json
|
||||
// (both). Optional — base appsettings.json carries enough to boot if these don't exist. Built from the
|
||||
// PLAIN roles only (#678.6): a cluster-{ClusterId} role in the name pointed at a file that never exists.
|
||||
// PLAIN roles only: a cluster-{ClusterId} role in the name pointed at a file that never exists.
|
||||
var roleSuffix = RoleOverlay.Suffix(roles);
|
||||
if (roleSuffix is not null)
|
||||
{
|
||||
@@ -142,7 +142,7 @@ await using (var secretsProvider = new ServiceCollection()
|
||||
// untouched. UseWindowsService already roots the content root at the base dir, so this only affects sinks.
|
||||
Directory.SetCurrentDirectory(AppContext.BaseDirectory);
|
||||
|
||||
// #561 — Serilog SelfLog to logs/serilog-selflog.txt + stderr, enabled before the first logger
|
||||
// Serilog SelfLog to logs/serilog-selflog.txt + stderr, enabled before the first logger
|
||||
// builds. Serilog isolates per-sink failures, so a file sink whose writes fail leaves a 0-byte
|
||||
// log and a healthy process with no signal anywhere; SelfLog is the only channel that sees the
|
||||
// swallowed exception. Placed after the CWD anchor above so the relative path lands in the
|
||||
@@ -159,14 +159,14 @@ builder.AddZbSerilog(o => o.ServiceName = "otopcua");
|
||||
|
||||
// ConfigDb is admin-only now (per-cluster mesh Phase 4). Only the admin role owns a ConfigDb
|
||||
// connection string; a driver-only node holds none at all and gets its deployed configuration from
|
||||
// central over the Phase-3 fetch-and-cache path (ConfigSource:Mode=FetchAndCache). Registering it
|
||||
// central over the fetch-and-cache path (ConfigSource:Mode=FetchAndCache). Registering it
|
||||
// unconditionally would throw on a driver-only node, since AddOtOpcUaConfigDb requires the string.
|
||||
// A fused admin+driver node keeps ConfigDb via its admin role — byte-for-byte unaffected.
|
||||
// #520 — bound unconditionally: a driver node needs the pepper to VERIFY keys, an admin node needs it
|
||||
// bound unconditionally: a driver node needs the pepper to VERIFY keys, an admin node needs it
|
||||
// to MINT them, and getting this wrong on either side is a silent authentication failure rather than a
|
||||
// startup error.
|
||||
builder.Services.Configure<ApiKeyOptions>(builder.Configuration.GetSection(ApiKeyOptions.SectionName));
|
||||
// #520 stage 5 — Security:Acl:Enabled. Default false; see AclOptions for why the flip must be a config
|
||||
// Security:Acl:Enabled. Default false; see AclOptions for why the flip must be a config
|
||||
// change rather than a redeploy, and what it swaps rather than adds.
|
||||
builder.Services.Configure<AclOptions>(builder.Configuration.GetSection(AclOptions.SectionName));
|
||||
|
||||
@@ -327,7 +327,7 @@ if (hasDriver)
|
||||
builder.Services.AddSingleton<IHistorianValueWriter>(sp => sp.GetRequiredService<IHistorianBackendProvider>().CreateValueWriter(sp));
|
||||
}
|
||||
|
||||
// Historian retention (#667). Only a backend that manages retention in-process returns a purger
|
||||
// Historian retention. Only a backend that manages retention in-process returns a purger
|
||||
// (SqlServer with RetentionDays set); otherwise the service is inert. Primary-gated via IHistorizationRoleView.
|
||||
if (serverHistorianOptions.Enabled)
|
||||
builder.Services.AddHostedService(sp => new HistorianRetentionService(
|
||||
@@ -402,11 +402,11 @@ if (hasDriver)
|
||||
builder.Services.TryAddScoped<ILdapGroupRoleMappingService, NullLdapGroupRoleMappingService>();
|
||||
builder.Services.TryAddScoped<IGroupRoleMapper<string>, OtOpcUaGroupRoleMapper>();
|
||||
|
||||
// #520 stage 2 — the OPC UA DATA PLANE is machine-to-machine, so its identity is an API key
|
||||
// the OPC UA DATA PLANE is machine-to-machine, so its identity is an API key
|
||||
// (static username "apikey", the otua_ credential as the password), not an LDAP user. The LDAP
|
||||
// registrations above are deliberately retained: they back ADMINUI LOGIN, which keeps LDAP.
|
||||
// Only IOpcUaUserAuthenticator moves.
|
||||
// #520 stage 4 — the node's data-plane authorization inputs (keys + ACL grants + cluster + auth
|
||||
// the node's data-plane authorization inputs (keys + ACL grants + cluster + auth
|
||||
// generation), refreshed by OpcUaPublishActor from every applied artifact. Registered on any
|
||||
// driver-role node because that is what serves an OPC UA data plane; the two interfaces resolve to
|
||||
// the SAME instance, since the writer and the readers must see one snapshot.
|
||||
@@ -416,7 +416,7 @@ if (hasDriver)
|
||||
builder.Services.AddSingleton<IDataPlaneAuthoritySource>(sp => sp.GetRequiredService<DataPlaneAuthorityState>());
|
||||
builder.Services.AddSingleton<IDataPlaneAuthoritySink>(sp => sp.GetRequiredService<DataPlaneAuthorityState>());
|
||||
|
||||
// #520 stage 5 — the tries the ACL gate evaluates against are taken FROM the authority state, not
|
||||
// the tries the ACL gate evaluates against are taken FROM the authority state, not
|
||||
// registered independently. The state builds the trie inside the same Apply() call that installs
|
||||
// the snapshot, so resolving any other cache here would hand the evaluator a permanently empty
|
||||
// one — and an empty trie denies everything, which looks exactly like "the ACLs are working" until
|
||||
@@ -437,7 +437,7 @@ if (hasDriver)
|
||||
builder.Services.AddSingleton<PermissionTrieCache>();
|
||||
}
|
||||
|
||||
// #520 + arch-review 03/S18 — the node-scoped ACL gate, the shared clock and the role-appropriate
|
||||
// The node-scoped ACL gate, the shared clock and the role-appropriate
|
||||
// API-key store + user authenticator, all in one extension so the role-conditional branches are
|
||||
// reachable from a test (Program.cs's top-level statements are not). See DataPlaneAuthRegistration.
|
||||
builder.Services.AddOtOpcUaDataPlaneAuth(hasAdmin, hasDriver);
|
||||
@@ -501,7 +501,7 @@ builder.Services.AddHostedService(sp => new ClusterBootstrapCoordinator(
|
||||
sp.GetRequiredService<IOptions<AkkaClusterOptions>>(),
|
||||
sp.GetRequiredService<ILogger<ClusterBootstrapCoordinator>>()));
|
||||
|
||||
// Down-if-alone recovery watchdog (#459). Registered AFTER AddAkka so it starts after Akka's own
|
||||
// Down-if-alone recovery watchdog. Registered AFTER AddAkka so it starts after Akka's own
|
||||
// hosted service has built the ActorSystem; it resolves the system lazily (never at construction) so
|
||||
// it can't race startup. On an unexpected SBR self-down it stops the host so the service supervisor
|
||||
// (sc.exe failure / docker restart: unless-stopped) restarts this node — the 2-node keep-oldest
|
||||
@@ -748,14 +748,14 @@ Log.Information(
|
||||
"OtOpcUa.Host starting with roles=[{Roles}] (admin={HasAdmin}, driver={HasDriver}) source={Source}",
|
||||
string.Join(",", roles), hasAdmin, hasDriver, effectiveRoles.Source);
|
||||
|
||||
// R3-04 choice 5: Cluster:Roles and OTOPCUA_ROLES set to different normalized role sets is almost
|
||||
// Cluster:Roles and OTOPCUA_ROLES set to different normalized role sets is almost
|
||||
// certainly not what the operator intended (Cluster:Roles still wins outright — this is
|
||||
// observability, not a behaviour change). Logged here rather than inside the static factory because
|
||||
// Serilog is not configured yet when EffectiveRoles.Resolve runs at the top of this file.
|
||||
if (effectiveRoles.DisagreementWarning is not null)
|
||||
Log.Warning("{Warning}", effectiveRoles.DisagreementWarning);
|
||||
|
||||
// #561 — startup growth self-check: the banner above went through the live pipeline; verify each
|
||||
// startup growth self-check: the banner above went through the live pipeline; verify each
|
||||
// configured file sink physically grew. A sink whose writes silently fail (the wonder-app-vd03
|
||||
// 0-byte-log incident) is reported at boot — through SelfLog + stderr, which don't depend on the
|
||||
// broken sink, and through the normal logger for the console sink's benefit.
|
||||
@@ -770,7 +770,7 @@ await app.RunAsync();
|
||||
}
|
||||
// HostAbortedException is how design-time tooling (EF `dotnet ef … --startup-project`, OpenAPI generators —
|
||||
// HostFactoryResolver) stops Program once it has the built host. It is not a failure: letting it reach the
|
||||
// Fatal + Environment.Exit(1) below would kill the tool's own process (#618, review R1 m5).
|
||||
// Fatal + Environment.Exit(1) below would kill the tool's own process.
|
||||
catch (Exception ex) when (ex is not HostAbortedException)
|
||||
{
|
||||
// Log.Logger is still the silent bootstrap logger when the failure happened before AddZbSerilog
|
||||
|
||||
@@ -9,7 +9,7 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
/// Side-effecting orchestrator over <see cref="AddressSpacePlan"/>. Drives an
|
||||
/// <see cref="IOpcUaAddressSpaceSink"/> to materialise the diff between two
|
||||
/// <see cref="AddressSpaceComposition"/> snapshots, routing each delta class to its MINIMAL mutation
|
||||
/// (R2-07 03/P1) via <see cref="AddressSpaceChangeClassifier"/> instead of rebuilding the whole
|
||||
/// via <see cref="AddressSpaceChangeClassifier"/> instead of rebuilding the whole
|
||||
/// address space for any topology change (which would kill every client's server-wide subscriptions):
|
||||
///
|
||||
/// <list type="bullet">
|
||||
@@ -127,7 +127,7 @@ public sealed class AddressSpaceApplier
|
||||
plan.AddedEquipmentVirtualTags.Count +
|
||||
plan.AddedRawContainers.Count + plan.AddedRawTags.Count + plan.AddedUnsReferenceVariables.Count;
|
||||
|
||||
// R2-07 03/P1 — classify the plan and route each delta class to its MINIMAL mutation instead of
|
||||
// Classify the plan and route each delta class to its MINIMAL mutation instead of
|
||||
// rebuilding the world for any topology change. AddressSpaceChangeClassifier is a pure policy over
|
||||
// the planner's diff (the planner stays a pure diff). The routing here:
|
||||
// • PureAdd / AttributeOnly ⇒ NO rebuild. The idempotent Materialise passes in
|
||||
@@ -162,7 +162,7 @@ public sealed class AddressSpaceApplier
|
||||
// MaterialiseHierarchy re-creates every folder with the new display names, so the renames are
|
||||
// covered for free and need no separate surgical pass.
|
||||
var renamedFolders = plan.RenamedFolders;
|
||||
// v3 surgical raw-tag + UNS-reference deltas (2026-08-15 surgical-deploys, Task 5). When kind is not
|
||||
// v3 surgical raw-tag + UNS-reference deltas (2026-08-15 surgical-deploys). When kind is not
|
||||
// Rebuild the classifier has ALREADY guaranteed every ChangedRawTags entry is surgical-eligible; the
|
||||
// filter is defense-in-depth so a future classifier change can only ever under-apply here (the
|
||||
// non-eligible remainder is left to the rebuild the classifier would have demanded).
|
||||
@@ -230,7 +230,7 @@ public sealed class AddressSpaceApplier
|
||||
|
||||
// Compute writable + historian EXACTLY as MaterialiseRawSubtree does so the in-place update
|
||||
// matches what a rebuild would have produced: an array is writable only when its driver can
|
||||
// write arrays (#610) and a blank historian override falls back to the RawPath (== NodeId).
|
||||
// write arrays and a blank historian override falls back to the RawPath (== NodeId).
|
||||
var writable = RawNodeWritable(d.Current);
|
||||
var historian = d.Current.IsHistorized
|
||||
? (string.IsNullOrWhiteSpace(d.Current.HistorianTagname) ? d.Current.NodeId : d.Current.HistorianTagname)
|
||||
@@ -275,7 +275,7 @@ public sealed class AddressSpaceApplier
|
||||
if (IsIdentityPreservingUnsRefDelta(d))
|
||||
{
|
||||
// Resolve writable + historian EXACTLY as MaterialiseUnsReferences does when it creates
|
||||
// the node: an array is writable only when its backing driver can write arrays (#610), and
|
||||
// the node: an array is writable only when its backing driver can write arrays, and
|
||||
// a blank override falls back to the backing RawPath (which IS the backing raw tag's NodeId).
|
||||
var writable = UnsReferenceNodeWritable(d.Current);
|
||||
var historian = d.Current.BackingIsHistorized
|
||||
@@ -300,7 +300,7 @@ public sealed class AddressSpaceApplier
|
||||
if (!SafeWriteValue(d.Previous.NodeId, null, OpcUaStatus.Bad, ts, AddressSpaceRealm.Uns)) failedNodes++;
|
||||
if (!SafeRemoveVariable(surgical, d.Previous.NodeId, AddressSpaceRealm.Uns)) { allApplied = false; break; }
|
||||
}
|
||||
// R2-07 Phase 2/3 — scoped remove teardown for PureRemove AND AddRemoveMix (runs after any
|
||||
// Scoped remove teardown for PureRemove AND AddRemoveMix (runs after any
|
||||
// coincident surgical attribute updates / renames succeeded). Terminal Bad /
|
||||
// RemovedConditionState writes then in-place removes; any false/throw flips allApplied → the
|
||||
// rebuild ratchet below. For AddRemoveMix the adds are materialised afterward by the publish
|
||||
@@ -339,12 +339,12 @@ public sealed class AddressSpaceApplier
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R2-07 Phase 2 — apply a PureRemove plan by tearing down ONLY the affected nodes IN PLACE (no full
|
||||
/// Apply a PureRemove plan by tearing down ONLY the affected nodes IN PLACE (no full
|
||||
/// rebuild). Removed equipment "own" their child tags/vtags/alarms, so any removed child whose
|
||||
/// <c>EquipmentId</c> is itself in <see cref="AddressSpacePlan.RemovedEquipment"/> is SUBSUMED by the
|
||||
/// subtree removal and skipped individually. For surviving-equipment removals: a removed value
|
||||
/// variable gets a terminal Bad <c>WriteValue</c> then <see cref="ISurgicalAddressSpaceSink.RemoveVariableNode"/>;
|
||||
/// a removed alarm-bearing tag (the pre-R2-07 today-gap: it got no terminal condition write) and a
|
||||
/// a removed alarm-bearing tag (previously it got no terminal condition write) and a
|
||||
/// removed scripted alarm get a terminal <see cref="RemovedConditionState"/> write then
|
||||
/// <see cref="ISurgicalAddressSpaceSink.RemoveAlarmConditionNode"/>; each removed equipment gets one
|
||||
/// <see cref="ISurgicalAddressSpaceSink.RemoveEquipmentSubtree"/>. The terminal write is what an
|
||||
@@ -408,7 +408,7 @@ public sealed class AddressSpaceApplier
|
||||
// unchanged) or to its own remove-the-previous + recreate-the-current pair (identity changed). That
|
||||
// second shape does remove a node, but it removes it from Apply's delta loop, not from here.
|
||||
//
|
||||
// Gitea #599 (archreview 03/P5): this runs BEFORE the equipment-subtree removal below, deliberately,
|
||||
// This runs BEFORE the equipment-subtree removal below, deliberately,
|
||||
// instead of skipping a removed equipment's references with the NotSubsumed filter the loops above
|
||||
// use. Whether the subtree removal reaches a reference depends on its NodeId, not on its parent: the
|
||||
// node manager scopes a subtree by id prefix, and a reference's NodeId is Area/Line/Equipment/Name
|
||||
@@ -457,7 +457,26 @@ public sealed class AddressSpaceApplier
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 2026-08-15 review I2 — true when a <see cref="AddressSpacePlan.UnsReferenceDelta"/> can be applied
|
||||
/// The ONE writability rule for a raw value node, shared by the materialise pass and the
|
||||
/// surgical in-place update so the two can never disagree: authored <c>ReadWrite</c>, and — for an array —
|
||||
/// a driver that can write the array (<see cref="RawTagPlan.ArrayWritable"/>, from
|
||||
/// <see cref="ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverArrayWriteSupport"/>). An array tag on a driver
|
||||
/// with no array encoder stays read-only, so a client write is refused at the node (<c>BadNotWritable</c>)
|
||||
/// instead of being optimistically applied and reverted.
|
||||
/// </summary>
|
||||
/// <param name="t">The raw tag plan.</param>
|
||||
/// <returns><c>true</c> when the materialised raw node is writable.</returns>
|
||||
internal static bool RawNodeWritable(RawTagPlan t) => t.Writable && (!t.IsArray || t.ArrayWritable);
|
||||
|
||||
/// <summary>The UNS-mirror counterpart of <see cref="RawNodeWritable"/>, off the inherited <c>Backing*</c>
|
||||
/// fields — a mirror is writable exactly when its raw node is.</summary>
|
||||
/// <param name="v">The UNS reference variable.</param>
|
||||
/// <returns><c>true</c> when the mirror node is writable.</returns>
|
||||
internal static bool UnsReferenceNodeWritable(UnsReferenceVariable v) =>
|
||||
v.Writable && (!v.BackingIsArray || v.BackingArrayWritable);
|
||||
|
||||
/// <summary>
|
||||
/// True when a <see cref="AddressSpacePlan.UnsReferenceDelta"/> can be applied
|
||||
/// as an IN-PLACE attribute update rather than a remove+recreate: the node's identity is untouched.
|
||||
/// Three fields make up that identity — the <c>NodeId</c> (an OPC UA NodeId is immutable, so a moved
|
||||
/// effective name is a different node), the <c>EquipmentId</c> (the node's PARENT, which
|
||||
@@ -467,26 +486,11 @@ public sealed class AddressSpaceApplier
|
||||
/// </summary>
|
||||
/// <param name="d">The UNS reference delta to classify.</param>
|
||||
/// <returns><c>true</c> when the delta is attribute-only and may be applied in place.</returns>
|
||||
/// <summary>
|
||||
/// Gitea #610 — the ONE writability rule for a raw value node, shared by the materialise pass and the
|
||||
/// surgical in-place update so the two can never disagree: authored <c>ReadWrite</c>, and — for an array —
|
||||
/// a driver that can write the array (<see cref="RawTagPlan.ArrayWritable"/>, from
|
||||
/// <see cref="ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverArrayWriteSupport"/>). An array tag on a driver
|
||||
/// with no array encoder stays read-only, so a client write is refused at the node (<c>BadNotWritable</c>)
|
||||
/// instead of being optimistically applied and reverted.
|
||||
/// </summary>
|
||||
internal static bool RawNodeWritable(RawTagPlan t) => t.Writable && (!t.IsArray || t.ArrayWritable);
|
||||
|
||||
/// <summary>The UNS-mirror counterpart of <see cref="RawNodeWritable"/>, off the inherited <c>Backing*</c>
|
||||
/// fields — a mirror is writable exactly when its raw node is.</summary>
|
||||
internal static bool UnsReferenceNodeWritable(UnsReferenceVariable v) =>
|
||||
v.Writable && (!v.BackingIsArray || v.BackingArrayWritable);
|
||||
|
||||
private static bool IsIdentityPreservingUnsRefDelta(AddressSpacePlan.UnsReferenceDelta d) =>
|
||||
string.Equals(d.Previous.NodeId, d.Current.NodeId, StringComparison.Ordinal) &&
|
||||
string.Equals(d.Previous.EquipmentId, d.Current.EquipmentId, StringComparison.Ordinal) &&
|
||||
string.Equals(d.Previous.BackingRawPath, d.Current.BackingRawPath, StringComparison.Ordinal) &&
|
||||
// #610 review R6: the array-on-scalar refusal is fixed at node creation (UpdateTagAttributes does not
|
||||
// The array-on-scalar refusal is fixed at node creation (UpdateTagAttributes does not
|
||||
// carry it), so a change to it recreates the mirror rather than updating it in place.
|
||||
d.Previous.BackingScalarRefusesArrayValue == d.Current.BackingScalarRefusesArrayValue;
|
||||
|
||||
@@ -518,7 +522,7 @@ public sealed class AddressSpaceApplier
|
||||
/// on a removed variable so an in-flight MonitoredItem observes the removal.
|
||||
/// <para>Every caller passes the generic <see cref="OpcUaStatus.Bad"/>, which is exactly what the
|
||||
/// retired 3-state projection produced here. A removal could say something more specific (the node is
|
||||
/// about to stop existing, so <c>BadNodeIdUnknown</c> would be truer), but #519 was about not LOSING a
|
||||
/// about to stop existing, so <c>BadNodeIdUnknown</c> would be truer), but the point was not LOSING a
|
||||
/// sub-code the driver already had — inventing a new one for the deploy path is a separate call, so
|
||||
/// this deliberately stays behaviour-identical.</para></summary>
|
||||
/// <returns><c>true</c> when the write landed; <c>false</c> when the sink threw.</returns>
|
||||
@@ -563,7 +567,7 @@ public sealed class AddressSpaceApplier
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #599 (archreview 01/U-8 = 03/P6) — the Raw-realm counterpart of
|
||||
/// The Raw-realm counterpart of
|
||||
/// <see cref="ComputeAddAnnouncements"/>: the deduplicated, ordinal-sorted RAW parent node ids to announce
|
||||
/// after a non-rebuild apply materialised added raw containers (Folder/Driver/Device/TagGroup) and raw
|
||||
/// tags. Each added node announces its <c>ParentNodeId</c> — the exact parent
|
||||
@@ -631,7 +635,7 @@ public sealed class AddressSpaceApplier
|
||||
/// Uses the existing, guard-covered, realm-aware
|
||||
/// <see cref="IOpcUaAddressSpaceSink.RaiseNodesAddedModelChange"/> — no new sink member. Each call is
|
||||
/// Safe-wrapped: a faulting announcement can never break a deploy (the nodes already stand).
|
||||
/// <para>Gitea #599: until the raw half existed, surgically-added raw containers/tags were materialised
|
||||
/// <para>until the raw half existed, surgically-added raw containers/tags were materialised
|
||||
/// but never announced, so a connected model-aware client could not discover them until it happened to
|
||||
/// re-browse. (Before surgical deploys every raw add rebuilt the address space, which hid the gap.)</para>
|
||||
/// </summary>
|
||||
@@ -678,7 +682,7 @@ public sealed class AddressSpaceApplier
|
||||
AddRequest(tag);
|
||||
}
|
||||
|
||||
// #600.1: a historization flip (false->true) or a historian-name / data-type change on an
|
||||
// A historization flip (false->true) or a historian-name / data-type change on an
|
||||
// EXISTING raw tag never appears in AddedRawTags, so EnsureTags never fired for it. EnsureTags is
|
||||
// an upsert, so re-ensuring is safe; an unchanged pair is a no-op. Only raw deltas are used - a
|
||||
// UNS mirror delta must not double-provision the same historian tag.
|
||||
@@ -902,7 +906,7 @@ public sealed class AddressSpaceApplier
|
||||
/// nodes (Folder→Driver→Device→TagGroup, each an <c>Object</c>/<c>Folder</c>) parents-before-children,
|
||||
/// then the tag Variable nodes keyed <c>s=<RawPath></c>. A native-alarm tag (<see cref="RawTagPlan.Alarm"/>
|
||||
/// non-null) materialises as a Part 9 condition node at its RawPath (ConditionId = RawPath, parent = its
|
||||
/// device/group folder) instead of a value variable — the single condition instance; WP4 wires the extra
|
||||
/// device/group folder) instead of a value variable — the single condition instance; the extra
|
||||
/// per-equipment notifiers. A historized value tag resolves its effective historian tagname HERE
|
||||
/// (override else the RawPath). Array tags are forced read-only (the driver write path can't handle
|
||||
/// arrays). Every sink call passes <see cref="AddressSpaceRealm.Raw"/> EXPLICITLY — the driver binds
|
||||
@@ -938,7 +942,7 @@ public sealed class AddressSpaceApplier
|
||||
if (!SafeEnsureFolder(c.NodeId, c.ParentNodeId, c.DisplayName, AddressSpaceRealm.Raw)) failed++;
|
||||
}
|
||||
|
||||
// v3 Batch 4 WP4 — reverse map (equipment UNS folder PATH → EquipmentId) so a native alarm's
|
||||
// Reverse map (equipment UNS folder PATH → EquipmentId) so a native alarm's
|
||||
// ReferencingEquipmentPaths (Area/Line/Equipment name paths, from the composer) resolve to the
|
||||
// EquipmentId the equipment folders were materialised under by MaterialiseHierarchy. Built lazily only
|
||||
// when at least one raw tag carries an alarm (the common no-alarm deploy pays nothing).
|
||||
@@ -956,7 +960,7 @@ public sealed class AddressSpaceApplier
|
||||
}
|
||||
else if (t.ReferencingEquipmentPaths.Count > 0)
|
||||
{
|
||||
// WP4 multi-notifier fan-out: wire the SINGLE condition as an event notifier of each
|
||||
// Multi-notifier fan-out: wire the SINGLE condition as an event notifier of each
|
||||
// referencing equipment's UNS folder, so one ReportEvent reaches every referencing equipment
|
||||
// (never re-reported per root). Resolve the folder PATHS to their EquipmentId folder NodeIds
|
||||
// (the id scheme MaterialiseHierarchy created the equipment folders under).
|
||||
@@ -969,7 +973,7 @@ public sealed class AddressSpaceApplier
|
||||
.ToList();
|
||||
if (equipFolderNodeIds.Count == 0)
|
||||
{
|
||||
// Wave C review M2: the alarm HAS referencing equipment but NONE of its paths resolved
|
||||
// Wave C review the alarm HAS referencing equipment but NONE of its paths resolved
|
||||
// to an EquipmentId folder — the native fan-out to those equipment is silently absent.
|
||||
// Surface it (Warning + a failed-node tally so the degraded-apply meter fires) instead
|
||||
// of swallowing zero operator signal. Today this only happens on a broken UNS ancestry /
|
||||
@@ -991,7 +995,7 @@ public sealed class AddressSpaceApplier
|
||||
{
|
||||
// A historized tag materialises Historizing + HistoryRead; the effective historian tagname is
|
||||
// the override else the RawPath (== NodeId). An array is writable only when its driver can
|
||||
// write arrays (#610) — see RawNodeWritable.
|
||||
// write arrays — see RawNodeWritable.
|
||||
string? historianTagname = t.IsHistorized
|
||||
? (string.IsNullOrWhiteSpace(t.HistorianTagname) ? t.NodeId : t.HistorianTagname)
|
||||
: null;
|
||||
@@ -1014,7 +1018,7 @@ public sealed class AddressSpaceApplier
|
||||
/// UNS→Raw to its backing raw node. The UNS node inherits DataType / writable / array shape / historian
|
||||
/// tagname from its backing RAW tag, so a historized raw tag's UNS reference registers the SAME tagname
|
||||
/// (both NodeIds → one tagname → HistoryRead works against either). The UNS node binds NO driver — the
|
||||
/// driver publish path fans values to it (WP3 runtime binding). Every sink call passes
|
||||
/// driver publish path fans values to it (runtime binding). Every sink call passes
|
||||
/// <see cref="AddressSpaceRealm.Uns"/> / the Organizes edge passes both realms EXPLICITLY. Idempotent.
|
||||
/// <para><b>The inherited values are read from the <see cref="UnsReferenceVariable"/> RECORD, not
|
||||
/// re-looked-up from <see cref="AddressSpaceComposition.RawTags"/> by RawPath.</b> The record is the
|
||||
@@ -1140,7 +1144,7 @@ public sealed class AddressSpaceApplier
|
||||
? (string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.FullName : tag.HistorianTagname)
|
||||
: null;
|
||||
// Retired equipment-tag path (structurally empty in v3): array tags stay forced read-only
|
||||
// here. The live array-write capability (#610) is carried on RawTagPlan /
|
||||
// here. The live array-write capability is carried on RawTagPlan /
|
||||
// UnsReferenceVariable only — see RawNodeWritable.
|
||||
var writable = tag.Writable && !tag.IsArray;
|
||||
if (!SafeEnsureVariable(nodeId, parent, tag.Name, tag.DataType, writable, AddressSpaceRealm.Uns, historianTagname, tag.IsArray, tag.ArrayLength)) failed++;
|
||||
@@ -1285,7 +1289,7 @@ public sealed class AddressSpaceApplier
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: EnsureVariable threw for {Node}", nodeId); return false; }
|
||||
}
|
||||
|
||||
// The tag/vtag surgical-eligibility predicates moved to AddressSpaceChangeClassifier (R2-07 T1) so the
|
||||
// The tag/vtag surgical-eligibility predicates moved to AddressSpaceChangeClassifier so the
|
||||
// whole delta classification is one pure, table-testable unit; the applier references them there (and the
|
||||
// classifier's Classify names the routing). The `with`-expression whitelist technique is preserved there
|
||||
// byte-for-byte.
|
||||
@@ -1322,7 +1326,7 @@ public sealed class AddressSpaceApplier
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: MaterialiseAlarmCondition threw for {Node}", alarmNodeId); return false; }
|
||||
}
|
||||
|
||||
/// <summary>Wire a native alarm condition's extra equipment-folder notifiers (WP4 multi-notifier),
|
||||
/// <summary>Wire a native alarm condition's extra equipment-folder notifiers (multi-notifier),
|
||||
/// swallowing (and Warning-logging) any sink fault. Returns <c>true</c> on success, <c>false</c> when the
|
||||
/// sink threw — callers tally the false into their pass's failed-node count (archreview 01/S-1).</summary>
|
||||
/// <returns><c>true</c> when the notifiers were wired; <c>false</c> when the sink threw.</returns>
|
||||
@@ -1333,7 +1337,7 @@ public sealed class AddressSpaceApplier
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// v3 Batch 4 WP4 — build the reverse map <c>equipment UNS folder PATH (Area/Line/Equipment) →
|
||||
/// Build the reverse map <c>equipment UNS folder PATH (Area/Line/Equipment) →
|
||||
/// EquipmentId</c>. The composer emits a native alarm's <see cref="RawTagPlan.ReferencingEquipmentPaths"/>
|
||||
/// as name paths (<c>V3NodeIds.Uns(areaName, lineName, equipName)</c>), but the equipment folders were
|
||||
/// materialised under their logical <c>EquipmentId</c> by <see cref="MaterialiseHierarchy"/>, so the
|
||||
|
||||
@@ -2,7 +2,7 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
|
||||
/// <summary>How <see cref="AddressSpaceApplier"/> must mutate the address space for a given
|
||||
/// <see cref="AddressSpacePlan"/>. A pure policy classification OVER the planner's diff (the planner stays
|
||||
/// a pure diff; the kind is the applier's routing policy over that diff — see R2-07 03/P1).</summary>
|
||||
/// a pure diff; the kind is the applier's routing policy over that diff).</summary>
|
||||
public enum AddressSpaceChangeKind
|
||||
{
|
||||
/// <summary><see cref="AddressSpacePlan.IsEmpty"/> — nothing to do.</summary>
|
||||
@@ -58,7 +58,7 @@ public static class AddressSpaceChangeClassifier
|
||||
// RawTagDeltaIsSurgicalEligible, and a changed vtag UNLESS it is node-irrelevant. Evaluated BEFORE the
|
||||
// add/remove split so a non-surgical change alongside pure adds still rebuilds.
|
||||
//
|
||||
// WP4 BINDING GUARD (Wave C review M3), as narrowed by the 2026-08-15 surgical-deploys work. The
|
||||
// BINDING GUARD, as narrowed by the 2026-08-15 surgical-deploys work. The
|
||||
// guard's subject is NATIVE-ALARM NOTIFIER WIRING: a native alarm's set of referencing equipment lives
|
||||
// in RawTagPlan.ReferencingEquipmentPaths (part of its record equality), and only a rebuild clears +
|
||||
// re-wires those notifiers, so an ALARM-BEARING raw tag (Alarm != null on EITHER side) still routes to
|
||||
@@ -130,6 +130,9 @@ public static class AddressSpaceChangeClassifier
|
||||
// so a delta changing ONLY those three leaves a byte-identical node and needs no address-space rebuild.
|
||||
// Whitelist-of-may-differ via `with` + the record's custom Equals: any OTHER field difference (current
|
||||
// or future) makes the override unequal → falls back to a full rebuild (safe default).
|
||||
/// <summary>Whether a changed equipment virtual tag differs only in fields that do not affect its materialised node.</summary>
|
||||
/// <param name="d">The virtual-tag delta to classify.</param>
|
||||
/// <returns><see langword="true"/> when only Expression / DependencyRefs / Historize differ; otherwise <see langword="false"/>.</returns>
|
||||
internal static bool VtagDeltaIsNodeIrrelevant(AddressSpacePlan.EquipmentVirtualTagDelta d) =>
|
||||
(d.Previous with
|
||||
{
|
||||
@@ -153,6 +156,9 @@ public static class AddressSpaceChangeClassifier
|
||||
// TagConfigIntent.Parse falls back to the RAW TagConfig blob as FullName, so a DataType/
|
||||
// array edit also mutates that blob ⇒ FullName differs ⇒ this returns false ⇒ full rebuild. That is the
|
||||
// correct safe default (a protocol driver's subscription needs re-spawning for a new shape anyway).
|
||||
/// <summary>Whether a changed equipment tag can be updated in place on its existing node instead of rebuilding.</summary>
|
||||
/// <param name="d">The equipment-tag delta to classify.</param>
|
||||
/// <returns><see langword="true"/> when the tag has no alarm and differs only in the in-place-updatable attributes; otherwise <see langword="false"/>.</returns>
|
||||
internal static bool TagDeltaIsSurgicalEligible(AddressSpacePlan.EquipmentTagDelta d) =>
|
||||
d.Previous.Alarm is null && d.Current.Alarm is null &&
|
||||
(d.Previous with
|
||||
@@ -167,12 +173,15 @@ public static class AddressSpaceChangeClassifier
|
||||
|
||||
// v3 surgical raw-tag eligibility: a NON-alarm raw tag whose only differences are the in-place-updatable
|
||||
// attributes (Writable / IsHistorized / HistorianTagname / DataType / IsArray / ArrayLength / ArrayWritable)
|
||||
// and/or its ReferencingEquipmentPaths. ArrayWritable (Gitea #610) only feeds the node's writability — the
|
||||
// and/or its ReferencingEquipmentPaths. ArrayWritable only feeds the node's writability — the
|
||||
// same AccessLevel + OnWriteValue swap Writable already drives in place — so it must be listed here or
|
||||
// every array-tag edit that flips it would fall through to a full rebuild. The referencing set is node-inert for a VALUE tag — it only drives native-alarm
|
||||
// notifier wiring (the WP4 guard), which is why Alarm != null on EITHER side disqualifies. Identity fields
|
||||
// notifier wiring (the binding guard), which is why Alarm != null on EITHER side disqualifies. Identity fields
|
||||
// (NodeId is the diff key; TagId / DriverInstanceId / Name / ParentNodeId / Realm) still force a rebuild via
|
||||
// the with-equality default-closed pattern.
|
||||
/// <summary>Whether a changed raw tag can be updated in place on its existing node instead of rebuilding.</summary>
|
||||
/// <param name="d">The raw-tag delta to classify.</param>
|
||||
/// <returns><see langword="true"/> when the tag has no alarm and differs only in the in-place-updatable attributes or its referencing equipment paths; otherwise <see langword="false"/>.</returns>
|
||||
internal static bool RawTagDeltaIsSurgicalEligible(AddressSpacePlan.RawTagDelta d) =>
|
||||
d.Previous.Alarm is null && d.Current.Alarm is null &&
|
||||
(d.Previous with
|
||||
|
||||
@@ -73,7 +73,7 @@ public sealed record AddressSpaceComposition(
|
||||
/// <c>(realm=Raw, s=<RawPath>)</c>. The RawPath IS the node identity + the single value source
|
||||
/// the driver binds; every referencing UNS variable fans out from it. Carries DataType / writable /
|
||||
/// historize + array shape, the optional native-alarm intent (attached at the RAW tag —
|
||||
/// ConditionId = RawPath), and the list of referencing equipment UNS folder paths (so WP4 wires the
|
||||
/// ConditionId = RawPath), and the list of referencing equipment UNS folder paths (so the applier wires the
|
||||
/// multi-notifier alarm). Init-only, defaults empty.
|
||||
/// </summary>
|
||||
public IReadOnlyList<RawTagPlan> RawTags { get; init; } = Array.Empty<RawTagPlan>();
|
||||
@@ -134,16 +134,16 @@ public sealed record RawContainerNode(
|
||||
/// <see cref="ArrayLength"/> are parsed from <c>Tag.TagConfig</c> via
|
||||
/// <see cref="TagConfigIntent"/> (the single byte-parity authority). <see cref="ReferencingEquipmentPaths"/>
|
||||
/// is the (possibly empty) set of UNS equipment-folder paths (<c>Area/Line/Equipment</c>) that reference
|
||||
/// this tag — WP4 wires one notifier per path so the raw tag's single alarm condition fans to every
|
||||
/// this tag — the applier wires one notifier per path so the raw tag's single alarm condition fans to every
|
||||
/// referencing equipment. <see cref="Realm"/> is always <see cref="AddressSpaceRealm.Raw"/>.
|
||||
/// A null <see cref="HistorianTagname"/> means the historian tagname defaults to the RawPath (resolved
|
||||
/// later, not here).
|
||||
/// <see cref="ArrayWritable"/> (Gitea #610) is whether the owning driver can write this tag as a whole
|
||||
/// <see cref="ArrayWritable"/> is whether the owning driver can write this tag as a whole
|
||||
/// 1-D array (<see cref="ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverArrayWriteSupport"/>); always
|
||||
/// <see langword="false"/> for a scalar. The materialised node is writable iff
|
||||
/// <c>Writable && (!IsArray || ArrayWritable)</c>, so an array tag on a driver with no array
|
||||
/// encoder stays read-only exactly as before #610.
|
||||
/// <see cref="ScalarRefusesArrayValue"/> (#610 review R6) is true when the owning driver's SCALAR write path
|
||||
/// encoder stays read-only exactly as before.
|
||||
/// <see cref="ScalarRefusesArrayValue"/> is true when the owning driver's SCALAR write path
|
||||
/// cannot take an array value
|
||||
/// (<see cref="ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverArrayWriteSupport.RefusesArrayValueOnScalarTag"/>);
|
||||
/// the node manager refuses an array write to the node inline while it is scalar-shaped. It is a fact about
|
||||
@@ -243,9 +243,9 @@ public sealed record RawTagPlan(
|
||||
/// existing machinery repairs it: the applier removes the previous UNS node and
|
||||
/// <see cref="AddressSpaceApplier.MaterialiseUnsReferences"/> recreates it from these values.
|
||||
/// <see cref="DataType"/> / <see cref="Writable"/> were already inherited onto the record and are
|
||||
/// covered by the same mechanism, as is <see cref="BackingArrayWritable"/> (Gitea #610 — the backing
|
||||
/// covered by the same mechanism, as is <see cref="BackingArrayWritable"/> (the backing
|
||||
/// tag's <see cref="RawTagPlan.ArrayWritable"/>, so a mirror is writable exactly when its raw node
|
||||
/// is), and <see cref="BackingScalarRefusesArrayValue"/> (#610 review R6 — the backing tag's
|
||||
/// is), and <see cref="BackingScalarRefusesArrayValue"/> (the backing tag's
|
||||
/// <see cref="RawTagPlan.ScalarRefusesArrayValue"/>, so a mirror refuses an array write exactly when its raw
|
||||
/// node does; a change to it is treated as an identity change by the applier, so the mirror is recreated
|
||||
/// rather than updated in place).</para>
|
||||
@@ -676,7 +676,7 @@ public static class AddressSpaceComposer
|
||||
Enabled: a.Enabled));
|
||||
}
|
||||
|
||||
// Raw subtree (device-oriented) + UNS subtree (equipment-oriented) — the Batch-4 dual namespace.
|
||||
// Raw subtree (device-oriented) + UNS subtree (equipment-oriented) — the dual namespace.
|
||||
var (rawContainers, rawTags) = BuildRawSubtree(
|
||||
rawFolders, driverInstances, devices, tagGroups, tags, unsTagReferences,
|
||||
unsAreas, unsLines, equipment, topology);
|
||||
@@ -826,7 +826,7 @@ public static class AddressSpaceComposer
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The per-driver array-write capability (Gitea #610) as a per-tag predicate over the owning driver's
|
||||
/// The per-driver array-write capability as a per-tag predicate over the owning driver's
|
||||
/// <c>DriverType</c>, the driver's <c>DriverConfig</c> and the tag's own device's <c>DeviceConfig</c>
|
||||
/// (either config can carry a write cap such as Modbus <c>maxRegistersPerWrite</c>; the table takes the
|
||||
/// smaller). First row wins on a duplicate id, matching the composer's other id maps. Built once per
|
||||
@@ -845,7 +845,7 @@ public static class AddressSpaceComposer
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #610 review R6 MINOR-1 — whether a tag's owning driver refuses an array value on a SCALAR tag
|
||||
/// Whether a tag's owning driver refuses an array value on a SCALAR tag
|
||||
/// (<see cref="DriverArrayWriteSupport.RefusesArrayValueOnScalarTag"/>), as a per-tag predicate resolved
|
||||
/// the same way as <see cref="BuildArrayWriteCapability"/>. Shared by the raw and UNS passes so a mirror
|
||||
/// refuses exactly when its raw node does.
|
||||
@@ -985,7 +985,7 @@ public static class AddressSpaceComposer
|
||||
|
||||
/// <summary>
|
||||
/// <c>TagId → distinct, ordinal-sorted list of referencing equipment UNS folder paths</c>
|
||||
/// (<c>Area/Line/Equipment</c>). Drives the native-alarm multi-notifier (WP4): the raw tag's single
|
||||
/// (<c>Area/Line/Equipment</c>). Drives the native-alarm multi-notifier: the raw tag's single
|
||||
/// alarm condition fans one event to each referencing equipment's UNS folder. A tag with no
|
||||
/// resolvable reference is absent (⇒ an empty list at the call site).
|
||||
/// </summary>
|
||||
@@ -1021,6 +1021,7 @@ public static class AddressSpaceComposer
|
||||
/// </summary>
|
||||
private sealed class RawTopology
|
||||
{
|
||||
/// <summary>The RawPath authority over the folder/driver/device/group ancestry.</summary>
|
||||
public required RawPathResolver Resolver { get; init; }
|
||||
|
||||
/// <summary><c>RawFolderId → RawPath</c> (absent when the ancestry is broken).</summary>
|
||||
@@ -1038,6 +1039,12 @@ public static class AddressSpaceComposer
|
||||
/// <summary><c>DeviceId → DriverInstanceId</c> — the raw tag's owning driver (for RawTagPlan).</summary>
|
||||
public required IReadOnlyDictionary<string, string> DriverIdByDevice { get; init; }
|
||||
|
||||
/// <summary>Builds the topology from the raw-tree rows.</summary>
|
||||
/// <param name="rawFolders">The raw folders, or null for none.</param>
|
||||
/// <param name="driverInstances">The driver instances.</param>
|
||||
/// <param name="devices">The devices, or null for none.</param>
|
||||
/// <param name="tagGroups">The tag groups, or null for none.</param>
|
||||
/// <returns>The resolved topology.</returns>
|
||||
public static RawTopology Build(
|
||||
IReadOnlyList<RawFolder>? rawFolders,
|
||||
IReadOnlyList<DriverInstance> driverInstances,
|
||||
|
||||
@@ -6,7 +6,7 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #682 — make <c>HistoryServerCapabilities/AggregateFunctions</c> (i=11201) list exactly the aggregates the
|
||||
/// Make <c>HistoryServerCapabilities/AggregateFunctions</c> (i=11201) list exactly the aggregates the
|
||||
/// wired historian backend serves for HistoryRead (<see cref="IHistorianDataSource.SupportedAggregates"/>), instead of
|
||||
/// the SDK's full registered set.
|
||||
/// </summary>
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
/// <summary>
|
||||
/// The one table between OPC UA Part 13 standard aggregate-function NodeIds (<c>ObjectIds.AggregateFunction_*</c>)
|
||||
/// and <see cref="HistoryAggregateType"/>. HistoryRead-Processed maps a request's aggregate through it, and the
|
||||
/// advertised <c>AggregateFunctions</c> folders (#682) map a backend's served set back through it — one table, so a
|
||||
/// advertised <c>AggregateFunctions</c> folders map a backend's served set back through it — one table, so a
|
||||
/// NodeId the server can dispatch is exactly a NodeId it can advertise.
|
||||
/// </summary>
|
||||
internal static class AggregateFunctionMap
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #694 item 2: the opt-in <c>ServerHistorian:EventReadHorizon</c> — HistoryReadEvents never reads events older than
|
||||
/// The opt-in <c>ServerHistorian:EventReadHorizon</c> — HistoryReadEvents never reads events older than
|
||||
/// <c>now − horizon</c>. Off (<see cref="TimeSpan.Zero"/>, the default) a read is exact down to whatever its window says,
|
||||
/// which on the Gateway means a reverse / end-only read walked back with forward reads proves "nothing older" with one
|
||||
/// read down to the Unix epoch (#691) — ~3.8 s live on <c>Runtime.dbo.Events</c>, and an unfiltered one can hit the
|
||||
/// read down to the Unix epoch — ~3.8 s live on <c>Runtime.dbo.Events</c>, and an unfiltered one can hit the
|
||||
/// gateway's 30 s SQL timeout. A forward read whose StartTime is far in the past costs the same on that table. The
|
||||
/// horizon bounds both, at a stated price: events older than it are not returned, in either direction, on any backend.
|
||||
/// </summary>
|
||||
|
||||
@@ -4,7 +4,7 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
|
||||
/// <summary>
|
||||
/// The end bound still owed to a HistoryRead-Raw read with <c>ReturnBounds = true</c> (Gitea #691). It rides the
|
||||
/// The end bound still owed to a HistoryRead-Raw read with <c>ReturnBounds = true</c>. It rides the
|
||||
/// continuation point, because Part 11 returns the end bound with the LAST page only.
|
||||
/// </summary>
|
||||
/// <param name="AtUtc">The request's end time (the later bound of a forward read, the earlier of a reverse one).</param>
|
||||
@@ -18,7 +18,7 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
/// <param name="DataExhausted">The data is drained and the end bound did not fit on the previous page.</param>
|
||||
/// <param name="LastReturnedUtc">The last value's time returned on an earlier page (stamps an open bound's placeholder).</param>
|
||||
/// <param name="Resolved">
|
||||
/// Gitea #692: the end bound an earlier page already looked up (a stored sample — never a placeholder), so the page that
|
||||
/// the end bound an earlier page already looked up (a stored sample — never a placeholder), so the page that
|
||||
/// returns it does not read the backend again. <c>null</c> = not looked up yet.
|
||||
/// </param>
|
||||
internal sealed record HistoryEndBound(
|
||||
@@ -26,7 +26,7 @@ internal sealed record HistoryEndBound(
|
||||
DataValueSnapshot? Resolved = null);
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #691: OPC UA Part 11 bounding values for HistoryRead-Raw (<c>ReadRawModifiedDetails.ReturnBounds</c>), composed
|
||||
/// OPC UA Part 11 bounding values for HistoryRead-Raw (<c>ReadRawModifiedDetails.ReturnBounds</c>), composed
|
||||
/// in the server from every backend's existing raw read — the ONE authority, so SqlServer, QuestDB, Gateway and Null
|
||||
/// answer identically.
|
||||
/// </summary>
|
||||
@@ -49,7 +49,7 @@ internal sealed record HistoryEndBound(
|
||||
/// declares <see cref="IHistorianDataSource.SupportsReverseRaw"/>, otherwise <see cref="ReverseRawReader"/>'s walk-back
|
||||
/// from bounded forward reads (the Gateway: NOT AVEVA's synthetic boundary row, which is stamped at the query start
|
||||
/// rather than at the prior sample's own time). "At or after T" is a forward read of <c>[T, MaxValue)</c> capped at one (on a
|
||||
/// forward-only backend ending at now + <see cref="ReverseRawReader.LookAhead"/> instead — #691 review);
|
||||
/// forward-only backend ending at now + <see cref="ReverseRawReader.LookAhead"/> instead);
|
||||
/// "strictly after T" starts one storage resolution later (lossless: nothing is stored in between). Inside a tie
|
||||
/// cluster the bound is the cluster's first sample in the read's direction — the forward read's first, the reverse
|
||||
/// read's first (the forward order's last).
|
||||
@@ -65,7 +65,7 @@ internal static class HistoryBounds
|
||||
private static readonly TimeSpan OpenBoundStep = TimeSpan.FromSeconds(1);
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #692: the placeholders this server synthesised (<see cref="NotFound"/>), by reference. A placeholder is known by
|
||||
/// the placeholders this server synthesised (<see cref="NotFound"/>), by reference. A placeholder is known by
|
||||
/// this marker, never by its shape: a stored row can legitimately carry <c>Bad_BoundNotFound</c> with a null value (a
|
||||
/// client may write one through HistoryUpdate), and that row is data.
|
||||
/// </summary>
|
||||
@@ -82,7 +82,7 @@ internal static class HistoryBounds
|
||||
|
||||
/// <summary>
|
||||
/// Whether a returned value is a <see cref="NotFound"/> placeholder this server synthesised rather than a stored sample
|
||||
/// — by an explicit marker (#692), so a stored row whose status happens to be <c>Bad_BoundNotFound</c> is not taken for one.
|
||||
/// — by an explicit marker, so a stored row whose status happens to be <c>Bad_BoundNotFound</c> is not taken for one.
|
||||
/// </summary>
|
||||
/// <param name="value">The value.</param>
|
||||
/// <returns><c>true</c> only for an instance <see cref="NotFound"/> produced.</returns>
|
||||
@@ -111,6 +111,14 @@ internal static class HistoryBounds
|
||||
/// The start bound to return ahead of the data, or <c>null</c> when a stored sample sits on StartTime (that sample is
|
||||
/// the bound, and it is already the data's first sample).
|
||||
/// </summary>
|
||||
/// <param name="source">The backend.</param>
|
||||
/// <param name="tagname">Historian tag.</param>
|
||||
/// <param name="startUtc">The read's StartTime.</param>
|
||||
/// <param name="reverse">Whether the read runs newest-first.</param>
|
||||
/// <param name="maxTieCluster">The walk-back's row budget (forward-only backends).</param>
|
||||
/// <param name="nowUtc">Now (the walk-back's look-ahead clamp).</param>
|
||||
/// <param name="ct">Cancellation.</param>
|
||||
/// <returns>The start bound, or null when a stored sample sits on StartTime.</returns>
|
||||
public static async Task<DataValueSnapshot?> StartBoundAsync(
|
||||
IHistorianDataSource source, string tagname, DateTime startUtc, bool reverse, int maxTieCluster, DateTime nowUtc,
|
||||
CancellationToken ct)
|
||||
@@ -131,6 +139,7 @@ internal static class HistoryBounds
|
||||
/// <param name="maxTieCluster">The walk-back's row budget (forward-only backends).</param>
|
||||
/// <param name="nowUtc">Now (the walk-back's look-ahead clamp).</param>
|
||||
/// <param name="ct">Cancellation.</param>
|
||||
/// <returns>The end bound sample, or a placeholder when none is stored.</returns>
|
||||
public static async Task<DataValueSnapshot> EndBoundAsync(
|
||||
IHistorianDataSource source, string tagname, HistoryEndBound bound, DateTime previousReturnedUtc,
|
||||
int maxTieCluster, DateTime nowUtc, CancellationToken ct)
|
||||
@@ -143,7 +152,7 @@ internal static class HistoryBounds
|
||||
return NotFound(new DateTime(ticks, DateTimeKind.Utc));
|
||||
}
|
||||
|
||||
if (bound.Resolved is { } resolved) return resolved; // #692: an earlier page already looked it up
|
||||
if (bound.Resolved is { } resolved) return resolved; // an earlier page already looked it up
|
||||
|
||||
DataValueSnapshot? found;
|
||||
if (bound.Reverse)
|
||||
@@ -166,6 +175,10 @@ internal static class HistoryBounds
|
||||
/// sample there is the end bound), compared at the storage resolution. A backend whose forward end is inclusive, or a
|
||||
/// resumed read that steps onto the end time, would otherwise return the end bound twice.
|
||||
/// </summary>
|
||||
/// <param name="sample">The data sample to test.</param>
|
||||
/// <param name="bound">The read's end bound.</param>
|
||||
/// <param name="resolution">The backend's storage resolution in ticks.</param>
|
||||
/// <returns><see langword="true"/> when the sample belongs to the data window.</returns>
|
||||
public static bool InDataWindow(DataValueSnapshot sample, HistoryEndBound bound, long resolution)
|
||||
{
|
||||
if (!bound.Inclusive || bound.Open) return true;
|
||||
@@ -175,6 +188,10 @@ internal static class HistoryBounds
|
||||
}
|
||||
|
||||
/// <summary>Whether a resumed read's next start is already past the data window (only the end bound remains).</summary>
|
||||
/// <param name="nextStartUtc">The resumed read's next start time.</param>
|
||||
/// <param name="bound">The read's end bound.</param>
|
||||
/// <param name="resolution">The backend's storage resolution in ticks.</param>
|
||||
/// <returns><see langword="true"/> when only the end bound remains to be returned.</returns>
|
||||
public static bool DataWindowDrained(DateTime nextStartUtc, HistoryEndBound bound, long resolution)
|
||||
{
|
||||
if (!bound.Inclusive || bound.Open) return false;
|
||||
@@ -184,6 +201,13 @@ internal static class HistoryBounds
|
||||
}
|
||||
|
||||
/// <summary>The latest stored sample at or before <paramref name="atUtc"/>, or null.</summary>
|
||||
/// <param name="source">The backend.</param>
|
||||
/// <param name="tagname">Historian tag.</param>
|
||||
/// <param name="atUtc">The time to search back from.</param>
|
||||
/// <param name="maxTieCluster">The walk-back's row budget (forward-only backends).</param>
|
||||
/// <param name="nowUtc">Now (the walk-back's look-ahead clamp).</param>
|
||||
/// <param name="ct">Cancellation.</param>
|
||||
/// <returns>The latest stored sample at or before <paramref name="atUtc"/>, or null.</returns>
|
||||
internal static async Task<DataValueSnapshot?> LatestAtOrBeforeAsync(
|
||||
IHistorianDataSource source, string tagname, DateTime atUtc, int maxTieCluster, DateTime nowUtc, CancellationToken ct)
|
||||
{
|
||||
@@ -201,8 +225,14 @@ internal static class HistoryBounds
|
||||
}
|
||||
|
||||
/// <summary>The earliest stored sample at or after <paramref name="atUtc"/>, or null.</summary>
|
||||
/// <param name="source">The backend.</param>
|
||||
/// <param name="tagname">Historian tag.</param>
|
||||
/// <param name="atUtc">The time to search forward from.</param>
|
||||
/// <param name="nowUtc">Now (the forward-only look-ahead clamp).</param>
|
||||
/// <param name="ct">Cancellation.</param>
|
||||
/// <returns>The earliest stored sample at or after <paramref name="atUtc"/>, or null.</returns>
|
||||
/// <remarks>
|
||||
/// #691 review: on a backend that reads forward only (the Gateway) the read's end is clamped to now +
|
||||
/// On a backend that reads forward only (the Gateway) the read's end is clamped to now +
|
||||
/// <see cref="ReverseRawReader.LookAhead"/> — the same horizon the walk-back uses — and a time beyond it is "not found"
|
||||
/// without a read. The HistorianGateway only checks <c>start <= end</c>, so a <c>DateTime.MaxValue</c> end would
|
||||
/// reach AVEVA as a year-9999 query end (unverified: a rejection, or a fabricated row). The clamp is NOT applied to a
|
||||
|
||||
@@ -32,7 +32,7 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
/// </param>
|
||||
/// <param name="NumValuesPerNode">The client's per-page cap; re-applied to every resumed page.</param>
|
||||
/// <param name="Kind">
|
||||
/// Which read issued the cursor (Gitea #669 / #668). A cursor is only ever resumed by the read kind that issued it, so a
|
||||
/// Which read issued the cursor. A cursor is only ever resumed by the read kind that issued it, so a
|
||||
/// raw point presented to an events or modified read (or any other mismatch) is a <c>BadContinuationPointInvalid</c>
|
||||
/// miss. <see cref="HistoryCursorKind.Events"/>: <paramref name="Tagname"/> carries the registered historian
|
||||
/// event-source name and the boundary fields are over <c>EventTimeUtc</c>.
|
||||
@@ -47,27 +47,27 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
/// window and direction (the modified read resumes by key, not by moving the window start).
|
||||
/// </param>
|
||||
/// <param name="Reverse">
|
||||
/// <see cref="HistoryCursorKind.Events"/> only (Gitea #675): the read is newest-first over
|
||||
/// <see cref="HistoryCursorKind.Events"/> only: the read is newest-first over
|
||||
/// <c>(EndUtc, NextStartUtc]</c>. A resumed events read takes its direction from here, never from the resume
|
||||
/// request's details. Raw keeps deriving it from the window (<c>NextStartUtc > EndUtc</c>).
|
||||
/// </param>
|
||||
/// <param name="SegmentWidthTicks">
|
||||
/// <see cref="HistoryCursorKind.Raw"/> reverse reads on a forward-only backend only (Gitea #686): the segment width the
|
||||
/// <see cref="HistoryCursorKind.Raw"/> reverse reads on a forward-only backend only: the segment width the
|
||||
/// walk-back ended on, so the next page starts at the observed density instead of re-discovering it. 0 = none.
|
||||
/// </param>
|
||||
/// <param name="EmptyFromTicks">
|
||||
/// Walked reverse reads only (Gitea #691): with <paramref name="EmptyToTicks"/>, a stretch <c>(from, to)</c> the previous
|
||||
/// Walked reverse reads only: with <paramref name="EmptyToTicks"/>, a stretch <c>(from, to)</c> the previous
|
||||
/// page's walk proved holds no row, so the next page steps over it rather than re-walking it (to the 1970 floor, ~14
|
||||
/// reads, at the end of a deep page-through). 0/0 = none. The cursor lives in the session's in-memory store (never
|
||||
/// serialized), so a new field with a "none" default is all the versioning it needs.
|
||||
/// </param>
|
||||
/// <param name="EmptyToTicks">The exclusive upper end of that stretch.</param>
|
||||
/// <param name="PendingEndBound">
|
||||
/// <see cref="HistoryCursorKind.Raw"/> with <c>ReturnBounds = true</c> only (Gitea #691): the end bound still owed —
|
||||
/// <see cref="HistoryCursorKind.Raw"/> with <c>ReturnBounds = true</c> only: the end bound still owed —
|
||||
/// Part 11 returns it with the last page. <c>null</c> = bounds not requested.
|
||||
/// </param>
|
||||
/// <param name="WithinTieCluster">
|
||||
/// <see cref="HistoryCursorKind.Raw"/> only (Gitea #692): the previous page was a slice of an oversized tie cluster at
|
||||
/// <see cref="HistoryCursorKind.Raw"/> only: the previous page was a slice of an oversized tie cluster at
|
||||
/// <paramref name="NextStartUtc"/> that still has un-emitted ties — the next page over-fetches the cluster directly
|
||||
/// instead of first re-reading <c>N + skip + 1</c> rows that are known to be ties.
|
||||
/// </param>
|
||||
@@ -87,7 +87,7 @@ internal sealed record HistoryContinuationState(
|
||||
HistoryEndBound? PendingEndBound = null,
|
||||
bool WithinTieCluster = false)
|
||||
{
|
||||
/// <summary>The walk-back hint this cursor carries (#686/#689/#691).</summary>
|
||||
/// <summary>The walk-back hint this cursor carries.</summary>
|
||||
public ReverseWalkHint WalkHint => new(SegmentWidthTicks, EmptyFromTicks, EmptyToTicks);
|
||||
}
|
||||
|
||||
@@ -97,10 +97,10 @@ internal enum HistoryCursorKind
|
||||
/// <summary>HistoryReadRaw (values).</summary>
|
||||
Raw,
|
||||
|
||||
/// <summary>HistoryReadEvents (#669).</summary>
|
||||
/// <summary>HistoryReadEvents.</summary>
|
||||
Events,
|
||||
|
||||
/// <summary>HistoryReadRaw with <c>IsReadModified = true</c> — the Part 11 modification trail (#668).</summary>
|
||||
/// <summary>HistoryReadRaw with <c>IsReadModified = true</c> — the Part 11 modification trail.</summary>
|
||||
Modified,
|
||||
}
|
||||
|
||||
@@ -260,9 +260,9 @@ internal static class HistoryPaging
|
||||
/// <param name="endUtc">The (inclusive) upper bound of the read window; unchanged across pages.
|
||||
/// The forward window is end-EXCLUSIVE (Part 11; the <c>IHistorianDataSource.ReadRawAsync</c> contract), so a drained
|
||||
/// cluster continues only while <c>next</c> is strictly before <paramref name="endUtc"/> truncated to
|
||||
/// <paramref name="resolutionTicks"/>. (OpcUaServer-003 had kept <c>next <= endUtc</c> as "the conservative side",
|
||||
/// <paramref name="resolutionTicks"/>. (An earlier version kept <c>next <= endUtc</c> as "the conservative side",
|
||||
/// but it was not: resuming AT EndTime makes the next read a <c>start == end</c> point read, which returns the samples
|
||||
/// at EndTime as data — #692 review F1; on a microsecond backend EndTime is truncated first.)</param>
|
||||
/// at EndTime as data; on a microsecond backend EndTime is truncated first.)</param>
|
||||
/// <param name="sliceStart">The index into the cluster the emitted slice starts at (clamped to the count).</param>
|
||||
/// <param name="sliceCount">How many ties the emitted slice contains (may be <c>0</c> on a stale-skip self-heal).</param>
|
||||
/// <param name="nextStartUtc">The next page's inclusive start: <paramref name="boundaryT"/> while the
|
||||
@@ -306,7 +306,7 @@ internal static class HistoryPaging
|
||||
{
|
||||
var next = boundaryT.AddTicks(resolutionTicks);
|
||||
// The forward window is end-EXCLUSIVE, at the backend's resolution: resuming AT EndTime would turn the next read
|
||||
// into a start == end point read, which returns the samples at EndTime as data (#692 review F1).
|
||||
// into a start == end point read, which returns the samples at EndTime as data.
|
||||
if (next.Ticks < endUtc.Ticks - endUtc.Ticks % resolutionTicks) { nextStartUtc = next; nextSkip = 0; }
|
||||
else { nextStartUtc = null; nextSkip = 0; }
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ public sealed class OpcUaApplicationHostOptions
|
||||
public string PkiStoreRoot { get; set; } = "pki";
|
||||
|
||||
/// <summary>
|
||||
/// Whether anonymous sessions may connect at all (#520). When <see langword="false"/> the
|
||||
/// Whether anonymous sessions may connect at all. When <see langword="false"/> the
|
||||
/// Anonymous token policy is not advertised AND anonymous activation is rejected, so a client
|
||||
/// must present an API key.
|
||||
/// </summary>
|
||||
@@ -272,13 +272,14 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
/// (<c>GetAwaiter().GetResult()</c> under <see cref="CancellationToken.None"/>) is irreducible: the
|
||||
/// callback must complete inline. Because the SDK gives no timeout, the hard wall-clock bound + bounded
|
||||
/// concurrency + directory-outage circuit that keep a slow/hung directory from wedging the SDK live
|
||||
/// inside the authenticator — NOT here. (#520 replaced the LDAP authenticator with
|
||||
/// inside the authenticator — NOT here. (The LDAP authenticator was replaced with
|
||||
/// <c>ApiKeyOpcUaUserAuthenticator</c>, whose verification does no network I/O at all, so the
|
||||
/// bound/concurrency/circuit machinery that used to live there is gone rather than relocated.)
|
||||
/// </remarks>
|
||||
/// <param name="authenticator">The user authenticator to validate credentials.</param>
|
||||
/// <param name="args">The impersonation event arguments to process.</param>
|
||||
/// <param name="logger">The logger for diagnostic output.</param>
|
||||
/// <param name="allowAnonymous">Whether anonymous sessions may connect.</param>
|
||||
internal static void HandleImpersonation(
|
||||
IOpcUaUserAuthenticator authenticator,
|
||||
ImpersonateEventArgs args,
|
||||
@@ -344,10 +345,10 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
|
||||
/// <summary>
|
||||
/// Admits or rejects an anonymous session, and — when admitted — attaches the roles the
|
||||
/// <c>anonymous</c> principal has been granted (#520).
|
||||
/// <c>anonymous</c> principal has been granted.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Before #520 this path simply returned, leaving the SDK's default identity in place: anonymous
|
||||
/// Previously this path simply returned, leaving the SDK's default identity in place: anonymous
|
||||
/// always connected and carried no roles. Two things change. Anonymous can now be refused
|
||||
/// outright, and when admitted it is a normal principal whose grants come from ACL rows, so it
|
||||
/// can be given read or write like any key. The identity attached is the same
|
||||
@@ -380,7 +381,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Fail closed. An anonymous session whose grants could not be resolved must not fall through
|
||||
// to the SDK default identity, because that would silently ADMIT it — the pre-#520
|
||||
// to the SDK default identity, because that would silently ADMIT it — the previous
|
||||
// behaviour — at exactly the moment we could not determine what it is allowed to do.
|
||||
logger.LogError(ex, "OpcUaApplicationHost: anonymous authenticator threw");
|
||||
args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected,
|
||||
@@ -534,6 +535,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
/// project supplies the LDAP adapter, and <see cref="NullOpcUaUserAuthenticator"/> denies all
|
||||
/// UserName logins when none is registered.
|
||||
/// </summary>
|
||||
/// <param name="allowAnonymous">Whether the Anonymous policy is advertised.</param>
|
||||
/// <returns>The Anonymous and UserName token policies.</returns>
|
||||
internal static IEnumerable<UserTokenPolicy> BuildUserTokenPolicies(bool allowAnonymous = true)
|
||||
{
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -68,7 +68,7 @@ public sealed class OtOpcUaSdkServer : StandardServer
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #597 — wire the redundancy verdict that decides whether this node accepts an OPC UA Acknowledge
|
||||
/// Wire the redundancy verdict that decides whether this node accepts an OPC UA Acknowledge
|
||||
/// of a NATIVE condition (<see cref="OtOpcUaNodeManager.NativeAlarmAckPrimaryGate"/>); on any other node
|
||||
/// the ack is refused with <c>BadInvalidState</c> instead of being applied locally and reverted by the
|
||||
/// driver's next transition. Passing <c>null</c> clears it (allow). No-op (returns <c>false</c>) when the
|
||||
@@ -84,7 +84,7 @@ public sealed class OtOpcUaSdkServer : StandardServer
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #674 — wire the redundancy verdict that decides whether this node serves an OPC UA HistoryUpdate
|
||||
/// Wire the redundancy verdict that decides whether this node serves an OPC UA HistoryUpdate
|
||||
/// (<see cref="OtOpcUaNodeManager.HistoryUpdatePrimaryGate"/>); on any other node every authorized node of a
|
||||
/// HistoryUpdate is refused <c>BadInvalidState</c>. Passing <c>null</c> clears it (allow). No-op (returns
|
||||
/// <c>false</c>) when the node manager has not been created yet (mirrors <see cref="SetNativeAlarmAckPrimaryGate"/>).
|
||||
@@ -163,7 +163,7 @@ public sealed class OtOpcUaSdkServer : StandardServer
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wire the node's data-plane authorization inputs (#520 stage 4) onto the created
|
||||
/// Wire the node's data-plane authorization inputs onto the created
|
||||
/// <see cref="OtOpcUaNodeManager"/> — the cluster it serves, the current auth generation, and the
|
||||
/// ACL grants. The host calls this after start with the DI-resolved
|
||||
/// <see cref="IDataPlaneAuthoritySource"/>, which the artifact-apply path refreshes on every
|
||||
@@ -185,7 +185,7 @@ public sealed class OtOpcUaSdkServer : StandardServer
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wire the node-scoped ACL gate (#520 stage 5) onto the created <see cref="OtOpcUaNodeManager"/>.
|
||||
/// Wire the node-scoped ACL gate onto the created <see cref="OtOpcUaNodeManager"/>.
|
||||
/// The host calls this after start; passing <c>null</c> detaches it, which returns the write and
|
||||
/// alarm seams to their server-wide role checks and leaves HistoryRead ungated. No-op (returns
|
||||
/// <c>false</c>) when the node manager has not been created yet, mirroring
|
||||
@@ -200,13 +200,11 @@ public sealed class OtOpcUaSdkServer : StandardServer
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #682 — keep the SDK's full set of calculator factories (live-data <c>AggregateFilter</c> subscriptions
|
||||
/// need them, and <c>ServerCapabilities/AggregateFunctions</c> keeps listing all of them), then trim
|
||||
/// <c>HistoryServerCapabilities/AggregateFunctions</c> to the node manager's current historian source. At this
|
||||
/// point that is the Null default (no history aggregates); the Host's <see cref="SetHistorianDataSource"/>
|
||||
/// re-syncs to the configured backend's set right after start.
|
||||
/// </summary>
|
||||
// Keep the SDK's full set of calculator factories (live-data AggregateFilter subscriptions need
|
||||
// them, and ServerCapabilities/AggregateFunctions keeps listing all of them), then trim
|
||||
// HistoryServerCapabilities/AggregateFunctions to the node manager's current historian source. At
|
||||
// this point that is the Null default (no history aggregates); the Host's SetHistorianDataSource
|
||||
// re-syncs to the configured backend's set right after start.
|
||||
/// <inheritdoc />
|
||||
protected override AggregateManager CreateAggregateManager(
|
||||
IServerInternal server, ApplicationConfiguration configuration)
|
||||
|
||||
@@ -10,11 +10,11 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
internal readonly record struct RawPagePlan(int Limit, uint PageCap, uint RequestCap, bool NoRoom);
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #694: sizes a HistoryRead-Raw page to what the backend can return in one read
|
||||
/// sizes a HistoryRead-Raw page to what the backend can return in one read
|
||||
/// (<see cref="IHistorianDataSource.MaxRawValuesPerRead"/> = L — a strict HistorianGateway's learned row ceiling, less
|
||||
/// its boundary-row allowance). With a limit the read — page P + boundary ties k + the #692 lookahead row — is at most L,
|
||||
/// its boundary-row allowance). With a limit the read — page P + boundary ties k + the lookahead row — is at most L,
|
||||
/// so P = min(want, L − k − 1) (all values: P = L − k − 1); a full page carries a continuation point like any other, as
|
||||
/// Part 11 allows fewer values than <c>NumValuesPerNode</c> with one. Before #694 a cap above a strict gateway's ceiling
|
||||
/// Part 11 allows fewer values than <c>NumValuesPerNode</c> with one. Previously a cap above a strict gateway's ceiling
|
||||
/// (or an uncapped read of a window holding more) failed <c>BadTooManyOperations</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
internal readonly record struct ReverseEventsPage(List<HistoricalEvent> Events, ReverseWalkHint Hint);
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #691.4: serves a Part 11 reverse events read — <c>(lo, hi]</c> newest-first — on a backend that reads events
|
||||
/// Serves a Part 11 reverse events read — <c>(lo, hi]</c> newest-first — on a backend that reads events
|
||||
/// forward only (<see cref="IHistorianDataSource.SupportsReverseEvents"/> = <c>false</c>: the Gateway) with the
|
||||
/// <see cref="ReverseWalk"/>. A segment read is cut short when the source flags truncation (its continuation point); ties
|
||||
/// are ordered by <see cref="EventIdTieOrder"/> because the gateway orders by event time only.
|
||||
|
||||
@@ -12,9 +12,9 @@ internal readonly record struct ReverseRawPage(IReadOnlyList<DataValueSnapshot>
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #686: serves a Part 11 reverse raw read — <c>(lo, hi]</c> newest-first, capped — on a historian backend that can
|
||||
/// Serves a Part 11 reverse raw read — <c>(lo, hi]</c> newest-first, capped — on a historian backend that can
|
||||
/// only read forward (<see cref="IHistorianDataSource.SupportsReverseRaw"/> = <c>false</c>: the Gateway, whose
|
||||
/// <c>ReadRaw</c> streams ascending and rejects <c>start > end</c>). The walk itself — segment sizing (#689), the
|
||||
/// <c>ReadRaw</c> streams ascending and rejects <c>start > end</c>). The walk itself — segment sizing, the
|
||||
/// truncation rules, edges, ties and bounds — is <see cref="ReverseWalk"/>; this adapter supplies the raw read (a read is
|
||||
/// cut short when it returned its full cap) and keeps the backend's tie order.
|
||||
/// </summary>
|
||||
@@ -59,7 +59,7 @@ internal static class ReverseRawReader
|
||||
/// <returns>The page, newest first, and the hint for the next page.</returns>
|
||||
/// <exception cref="HistoryReadLimitExceededException">One timestamp holds more than the tie budget.</exception>
|
||||
/// <remarks>
|
||||
/// Gitea #694: every segment read fits the backend's <see cref="IHistorianDataSource.MaxRawValuesPerRead"/> (a strict
|
||||
/// Every segment read fits the backend's <see cref="IHistorianDataSource.MaxRawValuesPerRead"/> (a strict
|
||||
/// gateway's learned ceiling) — the walk's tie budget is bounded by it so its segments are sized for it, and a
|
||||
/// crowded re-read asking more is cut to it (and counts as truncated, never as complete). A walk that TEACHES the
|
||||
/// backend its limit (the first read after start-up) and is refused for it is re-run once with segments sized for the
|
||||
@@ -109,7 +109,7 @@ internal static class ReverseRawReader
|
||||
}
|
||||
catch (HistoryReadLimitExceededException ex) when (asked > 0 && ex.PreWindowRows > 0 && attempt < 2)
|
||||
{
|
||||
// #695: the discovery request may be smaller than the pre-window cluster. Size against
|
||||
// The discovery request may be smaller than the pre-window cluster. Size against
|
||||
// the ceiling, not that request; a second pass can discover more crowding rows.
|
||||
var ceilingRoom = source.MaxRawValuesPerRead is var learned and > 0 ? learned : asked;
|
||||
var smaller = Math.Min(asked - 1, ceilingRoom - ex.PreWindowRows - 1);
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
/// <param name="WidthTicks">The segment width the walk ended on, aimed at the page's density — the next page's first width (0 = none).</param>
|
||||
/// <param name="EmptyFromTicks">
|
||||
/// With <paramref name="EmptyToTicks"/>: a stretch <c>(EmptyFrom, EmptyTo)</c> (both exclusive) a complete segment read
|
||||
/// proved holds no row (#691) — typically from the oldest row the page saw down to the walk's floor. A resumed page
|
||||
/// proved holds no row — typically from the oldest row the page saw down to the walk's floor. A resumed page
|
||||
/// steps straight over it instead of re-walking it. Both 0 = none.
|
||||
/// </param>
|
||||
/// <param name="EmptyToTicks">The exclusive upper end of the empty stretch.</param>
|
||||
@@ -23,7 +23,7 @@ internal readonly record struct ReverseWalkHint(long WidthTicks, long EmptyFromT
|
||||
internal readonly record struct ForwardSegmentRead<T>(IReadOnlyList<T> Rows, bool Truncated);
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #686/#689: the walk-back that serves a Part 11 reverse read — <c>(lo, hi]</c> newest-first, capped — from a
|
||||
/// The walk-back that serves a Part 11 reverse read — <c>(lo, hi]</c> newest-first, capped — from a
|
||||
/// backend that can only read FORWARD. Shared by raw values (<see cref="ReverseRawReader"/>) and events; the row type
|
||||
/// only has to expose a timestamp.
|
||||
/// </summary>
|
||||
@@ -36,7 +36,7 @@ internal readonly record struct ForwardSegmentRead<T>(IReadOnlyList<T> Rows, boo
|
||||
/// it newest-first, and step to the next older segment until the cap is met or <c>lo</c> is reached. The first
|
||||
/// segment is <see cref="InitialSegment"/> wide, or the width the previous page of the same read ended on (carried in
|
||||
/// the continuation point). After each read the next width is re-aimed from the density just observed at about half
|
||||
/// the PAGE budget <c>2N + 16</c> (≈ N + 8 rows), growing at most ×4 per step (#689 — it used to aim at half the tie
|
||||
/// the PAGE budget <c>2N + 16</c> (≈ N + 8 rows), growing at most ×4 per step (it used to aim at half the tie
|
||||
/// budget, ~32k rows, whatever N was) — so a live tag's "latest N" is usually one read of about N rows, and sparse
|
||||
/// history is found in a few dozen tiny reads.
|
||||
/// </para>
|
||||
@@ -59,7 +59,7 @@ internal readonly record struct ForwardSegmentRead<T>(IReadOnlyList<T> Rows, boo
|
||||
/// requests are the same request, and the Gateway stores milliseconds).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Proving the rest empty (#691).</b> Walking down to the floor through empty history used to take one read per ×4
|
||||
/// <b>Proving the rest empty.</b> Walking down to the floor through empty history used to take one read per ×4
|
||||
/// growth step (~14 reads from a minute to the epoch) — on every page that ran out of rows. After two consecutive empty
|
||||
/// segments the walk asks once per page for the WHOLE remainder <c>(lo, segHi]</c> at the page budget: a complete
|
||||
/// answer ends the walk in one read; a truncated one (deep history) is discarded and the walk carries on as before. And
|
||||
@@ -71,7 +71,7 @@ internal readonly record struct ForwardSegmentRead<T>(IReadOnlyList<T> Rows, boo
|
||||
/// <b>Edges.</b> A segment is requested as <c>[⌊segLo⌋, ⌈segHi + 1 tick⌉]</c>, both rounded outward to the millisecond
|
||||
/// (the gateway's / AVEVA's resolution), and filtered to <c>(segLo, segHi]</c> on the rows' own timestamps — exact
|
||||
/// whether the backend's forward end is exclusive or inclusive and whether it truncates its bounds; a row fabricated at
|
||||
/// the (millisecond) query start can never land inside the segment (#687). Adjacent segments share no timestamp.
|
||||
/// the (millisecond) query start can never land inside the segment. Adjacent segments share no timestamp.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Ties</b> come back in the backend's forward order reversed, or — with a tie order given (events, whose gateway
|
||||
@@ -94,10 +94,10 @@ internal static class ReverseWalk
|
||||
/// <summary>How far past "now" the walk starts at most.</summary>
|
||||
internal static readonly TimeSpan LookAhead = TimeSpan.FromDays(1);
|
||||
|
||||
/// <summary>Slack added to a page's row budget (#689): room for density variation between segments.</summary>
|
||||
/// <summary>Slack added to a page's row budget: room for density variation between segments.</summary>
|
||||
internal const int PageSlack = 16;
|
||||
|
||||
/// <summary>The most rows a discovery segment (see the remarks) may read before it counts as truncated (#689).</summary>
|
||||
/// <summary>The most rows a discovery segment (see the remarks) may read before it counts as truncated.</summary>
|
||||
internal const int DiscoveryRows = 1024;
|
||||
|
||||
/// <summary>Request bounds are rounded outward to this (the gateway / AVEVA timestamp resolution).</summary>
|
||||
@@ -123,7 +123,7 @@ internal static class ReverseWalk
|
||||
/// <param name="hint">What the previous page of the same read carried (default = none).</param>
|
||||
/// <param name="tooManyAtOneTimestamp">Builds the exception for a timestamp holding more than M rows.</param>
|
||||
/// <param name="ct">Cancellation.</param>
|
||||
/// <param name="useDiscoveryBudgetForGaps">Use ceiling-sized discovery and retain truncated remainder rows (#696, strict raw backends only).</param>
|
||||
/// <param name="useDiscoveryBudgetForGaps">Use ceiling-sized discovery and retain truncated remainder rows (strict raw backends only).</param>
|
||||
/// <returns>At most <paramref name="want"/> rows, newest first, and the hint for the next page.</returns>
|
||||
public static async Task<(List<T> Rows, ReverseWalkHint Next)> WalkAsync<T>(
|
||||
Func<DateTime, DateTime, int, CancellationToken, Task<ForwardSegmentRead<T>>> read,
|
||||
@@ -164,8 +164,8 @@ internal static class ReverseWalk
|
||||
// (known + lowerBy, segHi] with lowerBy growing ×4, `known` following what each truncated read saw — until a read
|
||||
// comes back empty (the top is now bracketed below its segLo), then BISECT that bracket. Any complete read holding
|
||||
// rows ends the search. A burst that ended long before segHi is found in a few reads instead of bisecting the whole
|
||||
// gap down to the millisecond, and a long dense stretch is crossed geometrically, not a budget at a time (#691.4
|
||||
// review). `noGallop` falls back to plain top-anchored halving if a galloping read is crowded by rows before it.
|
||||
// gap down to the millisecond, and a long dense stretch is crossed geometrically, not a budget at a time
|
||||
// `noGallop` falls back to plain top-anchored halving if a galloping read is crowded by rows before it.
|
||||
var known = 0L;
|
||||
var knownWidth = 0L;
|
||||
var lowerBy = 0L;
|
||||
@@ -173,7 +173,7 @@ internal static class ReverseWalk
|
||||
var noGallop = false;
|
||||
var segHi = hiTicks;
|
||||
|
||||
// #691: the remainder probe (once per page, after two empty segments) and the empty stretches.
|
||||
// The remainder probe (once per page, after two empty segments) and the empty stretches.
|
||||
var emptyRun = 0;
|
||||
var probed = false;
|
||||
var carried = hint.HasEmptyStretch ? (hint.EmptyFromTicks, hint.EmptyToTicks) : (0L, 0L);
|
||||
@@ -198,7 +198,7 @@ internal static class ReverseWalk
|
||||
bisected = !lowered && (bisecting || lowerBy > 0) && gap > 2 * Math.Max(granularity, knownWidth);
|
||||
// Never narrower than the request granularity: requests are rounded outward to it, so a narrower segment
|
||||
// re-sends the same request — and a width left at one tick (a tie cluster's) stepped segHi down a tick per
|
||||
// read, ~10,000 identical reads (#691.4 re-review). Keep the Min: a truncated read crowded by rows before
|
||||
// read, ~10,000 identical reads. Keep the Min: a truncated read crowded by rows before
|
||||
// the segment must still shrink the segment.
|
||||
w = lowered ? gap - lowerBy
|
||||
: bisected ? gap / 2
|
||||
@@ -222,7 +222,7 @@ internal static class ReverseWalk
|
||||
|
||||
if (probe)
|
||||
{
|
||||
// #696: strict-ceiling discovery retains the rows/density instead of discarding the remainder probe.
|
||||
// Strict-ceiling discovery retains the rows/density instead of discarding the remainder probe.
|
||||
probed = true;
|
||||
if (!useDiscoveryBudgetForGaps)
|
||||
{
|
||||
@@ -260,8 +260,8 @@ internal static class ReverseWalk
|
||||
{
|
||||
// A segment no wider than the request granularity (1 ms — narrower requests are the same request) still
|
||||
// over its budget: a tie cluster, or a burst inside one millisecond. Escalate to the tie budget M before
|
||||
// refusing — up to M rows within one millisecond are served (#686); more are refused, per millisecond
|
||||
// (#691.4 re-review — exact for the millisecond-resolution Gateway).
|
||||
// refusing — up to M rows within one millisecond are served; more are refused, per millisecond
|
||||
// (exact for the millisecond-resolution Gateway).
|
||||
if (segBudget < budget)
|
||||
{
|
||||
segBudget = budget;
|
||||
@@ -442,13 +442,16 @@ internal static class ReverseWalk
|
||||
|
||||
/// <summary>
|
||||
/// Orders events within one timestamp by <see cref="HistoricalEvent.EventId"/> (ordinal) — the tie order a walked-back
|
||||
/// events read (#691.4) and its oversized-cluster over-fetch share, because the Gateway orders by event time only.
|
||||
/// events read and its oversized-cluster over-fetch share, because the Gateway orders by event time only.
|
||||
/// </summary>
|
||||
internal sealed class EventIdTieOrder : IComparer<HistoricalEvent>
|
||||
{
|
||||
/// <summary>The shared instance.</summary>
|
||||
public static readonly EventIdTieOrder Instance = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Orders events by ordinal comparison of their event ids.</summary>
|
||||
/// <param name="x">The first event.</param>
|
||||
/// <param name="y">The second event.</param>
|
||||
/// <returns>A signed integer giving the relative order of the two event ids.</returns>
|
||||
public int Compare(HistoricalEvent? x, HistoricalEvent? y) => string.CompareOrdinal(x?.EventId, y?.EventId);
|
||||
}
|
||||
|
||||
@@ -25,16 +25,16 @@ public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgicalAddre
|
||||
=> _nodeManager.WriteValue(nodeId, value, statusCode, sourceTimestampUtc, realm);
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>Overrides the interface's default implementation on purpose: that default loops
|
||||
/// <see cref="WriteValue"/>, which would re-take the node manager's SDK-wide <c>Lock</c> once per value.
|
||||
/// Forwarding to the manager's own batched entry point takes it ONCE for the whole batch.</remarks>
|
||||
// Overrides the interface's default implementation on purpose: that default loops
|
||||
// WriteValue, which would re-take the node manager's SDK-wide Lock once per value.
|
||||
// Forwarding to the manager's own batched entry point takes it ONCE for the whole batch.
|
||||
public void WriteValues(IReadOnlyList<ValueWrite> values)
|
||||
=> _nodeManager.WriteValues(values);
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>Overrides the interface's default on purpose, and here the default is not merely slower but
|
||||
/// <b>wrong</b>: it writes a null value (a value-less sink has nothing else to write), which would erase
|
||||
/// the last known value this annotation exists to preserve. The manager's own entry point retains it.</remarks>
|
||||
// Overrides the interface's default on purpose, and here the default is not merely slower but
|
||||
// wrong: it writes a null value (a value-less sink has nothing else to write), which would erase
|
||||
// the last known value this annotation exists to preserve. The manager's own entry point retains it.
|
||||
public void WriteStatuses(IReadOnlyList<StatusWrite> statuses)
|
||||
=> _nodeManager.WriteStatuses(statuses);
|
||||
|
||||
@@ -42,6 +42,7 @@ public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgicalAddre
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
|
||||
=> _nodeManager.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc, realm);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void WriteAlarmQuality(string alarmNodeId, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
|
||||
=> _nodeManager.WriteAlarmQuality(alarmNodeId, statusCode, sourceTimestampUtc, realm);
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Turns an inbound OPC UA request into a node-scoped ACL decision (#520, stage 5). The one place
|
||||
/// Turns an inbound OPC UA request into a node-scoped ACL decision. The one place
|
||||
/// the address space's <see cref="NodeId"/> meets <see cref="IPermissionEvaluator"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
@@ -118,7 +118,7 @@ public sealed class DataPlaneAuthorizer
|
||||
public TimeProvider Clock => _time;
|
||||
|
||||
/// <summary>
|
||||
/// Memoized grant walks for the request-rate seams (#520 stage 6). See
|
||||
/// Memoized grant walks for the request-rate seams. See
|
||||
/// <see cref="SessionAclDecisionCache"/> for what it is and is not allowed to cache.
|
||||
/// </summary>
|
||||
public SessionAclDecisionCache Decisions { get; } = new();
|
||||
@@ -204,7 +204,7 @@ public sealed class DataPlaneAuthorizer
|
||||
/// <summary>
|
||||
/// Whether the API key behind <paramref name="identity"/> is still usable — consulted by the two
|
||||
/// server-wide role gates (<c>WriteOperate</c>, <c>AlarmAck</c>) <b>regardless of
|
||||
/// <see cref="Enabled"/></b> (Gitea #563).
|
||||
/// <see cref="Enabled"/></b>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
|
||||
@@ -26,7 +26,7 @@ public interface IOpcUaUserAuthenticator
|
||||
Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves what an <b>anonymous</b> session is allowed to do (#520). Anonymous is a normal
|
||||
/// Resolves what an <b>anonymous</b> session is allowed to do. Anonymous is a normal
|
||||
/// principal — <see cref="OpcUaPrincipals.Anonymous"/> — not a special case, so it can be granted
|
||||
/// read or write through ordinary ACL rows.
|
||||
/// </summary>
|
||||
@@ -38,7 +38,7 @@ public interface IOpcUaUserAuthenticator
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The default implementation admits anonymous with <b>no roles</b>, which is exactly the
|
||||
/// behaviour before #520: an anonymous session connects, and every role-gated operation
|
||||
/// behaviour before per-key ACLs: an anonymous session connects, and every role-gated operation
|
||||
/// (write, alarm ack) denies. Implementors that can resolve grants override it; the rest
|
||||
/// keep the safe status quo rather than being forced to reimplement it.
|
||||
/// </para>
|
||||
@@ -53,8 +53,8 @@ public interface IOpcUaUserAuthenticator
|
||||
/// <summary>Outcome of a UserName authentication attempt. <see cref="Roles"/> populates the session identity's role set.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ⚠️ <b><see cref="DisplayName"/> is not the principal, and assuming it was is a live trap</b>
|
||||
/// (#520 stage 5). The authenticator passes the principal as the display name, but
|
||||
/// ⚠️ <b><see cref="DisplayName"/> is not the principal, and assuming it was is a live trap</b>.
|
||||
/// The authenticator passes the principal as the display name, but
|
||||
/// <c>RoleCarryingUserIdentity</c> derives <c>UserIdentity.DisplayName</c> from the presented
|
||||
/// <b>token</b> — which for a key-bearing client is the static username <c>apikey</c>, the same
|
||||
/// for every key in the fleet. So the principal was being dropped on the floor at the identity
|
||||
|
||||
@@ -27,7 +27,7 @@ public sealed class RoleCarryingUserIdentity : UserIdentity
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance carrying the ACL principal and the authenticating key id alongside
|
||||
/// the roles (#520 stage 5).
|
||||
/// the roles.
|
||||
/// </summary>
|
||||
/// <param name="token">The user identity token the SDK handed to the impersonation handler.</param>
|
||||
/// <param name="roles">The roles resolved by the authenticator.</param>
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Memoizes the <b>grant-walk</b> half of an ACL decision per <c>(session, node, operation)</c> for one
|
||||
/// authorization generation (#520 stage 6).
|
||||
/// authorization generation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
|
||||
@@ -3,7 +3,7 @@ using Opc.Ua;
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
|
||||
/// <summary>
|
||||
/// The Part 13 <see cref="AggregateConfiguration"/> processed reads are evaluated under (#677), and the rule for a
|
||||
/// The Part 13 <see cref="AggregateConfiguration"/> processed reads are evaluated under, and the rule for a
|
||||
/// client-supplied one in <c>ReadProcessedDetails</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
@@ -11,7 +11,7 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
/// The aggregate evaluators (<c>AggregateEvaluator</c> / <c>TimeWeightedAggregator</c> in
|
||||
/// <c>Driver.Historian.Sql.Common</c>) are written against fixed settings, not parameterised: Uncertain time counts as
|
||||
/// Good (<see cref="TreatUncertainAsBad"/> = false) in the time-weighted family; the per-sample Average / Minimum /
|
||||
/// Maximum / Range / *ActualTime / Count are defined by Part 13 over Good values whatever this setting (#684), as the
|
||||
/// Maximum / Range / *ActualTime / Count are defined by Part 13 over Good values whatever this setting, as the
|
||||
/// SDK computes them under it; an interval is Good only when all of
|
||||
/// it is (<see cref="PercentDataGood"/> = 100) and Bad only when none of it is non-Bad
|
||||
/// (<see cref="PercentDataBad"/> = 100), otherwise <c>UncertainDataSubNormal</c>; the value past the last sample
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user