Merge branch 'master' into remed/lane-G2
This commit is contained in:
@@ -35,12 +35,39 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
/// DateTime — and only with a declared <c>arrayLength</c>: an undeclared length materialises an
|
||||
/// unbounded node, and a short whole-array write to it would RESIZE the Galaxy attribute (the
|
||||
/// mxaccessgw <c>gateway.md</c> contract), which is never what a client meant.</item>
|
||||
/// <item>Everything else (AbCip, AbLegacy, TwinCAT, FOCAS, MQTT, MTConnect, Sql, Calculation) — scalar
|
||||
/// write encoders only; array nodes stay read-only.</item>
|
||||
/// <item><b>AbCip</b> — <c>dataType</c> SInt / USInt / Int / UInt / DInt / UDInt / Real / Dt / LInt / ULInt /
|
||||
/// LReal (<c>AbCipArrayCodec</c>, both transports), and not a <c>safetyTag</c>. BOOL arrays (bit-packed
|
||||
/// DWORDs), STRING and structure arrays have no array encoder.</item>
|
||||
/// <item><b>AbLegacy</b> — <c>dataType</c> Int / AnalogInt on an N / B / A file, Long on an L file, Float
|
||||
/// on an F file (the encode width is the DATA FILE's; a type that does not match its file is refused),
|
||||
/// on a plain <c><file><number>:<word></c> address of at most 256 elements (the PCCC
|
||||
/// file ceiling the factory clamps to). Refused and so left out: Bit arrays (bit-packed words), String /
|
||||
/// Timer / Counter / Control arrays (structured; the factory rejects them), <c>/bit</c> and <c>.sub</c>
|
||||
/// addresses, a PLC-5 device on the native transport (every PLC-5 write is refused before the wire), and
|
||||
/// the <c>S:</c> / <c>I:</c> / <c>O:</c> files (libplctag refuses their translated names). Indirect
|
||||
/// addresses (<c>N7:[N7:0]</c>) are kept read-only deliberately: the element moves with the index word
|
||||
/// and the index read is not atomic with the write.</item>
|
||||
/// <item><b>TwinCAT</b> — every element type <c>TwinCATArrayCodec</c> lays out (all but Structure, which
|
||||
/// the factory refuses), STRING / WSTRING included, except a BOOL bit-in-word path
|
||||
/// (<c>MAIN.Word.3</c>), which is a scalar bit write. A multi-dimensional PLC array is written row-major
|
||||
/// from the flat 1-D value. The PLC-side shape (element width, declared length ≤ the PLC's) is checked
|
||||
/// against the resolved symbol at write time, which no compose-time table can know.</item>
|
||||
/// <item>Everything else (FOCAS, MQTT, MTConnect, Sql, Calculation) — scalar write encoders only; array
|
||||
/// nodes stay read-only.</item>
|
||||
/// </list>
|
||||
/// <para>Modbus, S7 and Galaxy require a declared length — <c>isArray: true</c> AND an integer
|
||||
/// <c>arrayLength >= 1</c>. The Modbus and S7 tag factories treat anything else as a SCALAR tag (whose
|
||||
/// <para>Modbus, S7, Galaxy, AbCip, AbLegacy and TwinCAT require a declared length — <c>isArray: true</c> AND
|
||||
/// an integer <c>arrayLength >= 1</c>. Their tag factories treat anything else as a SCALAR tag (whose
|
||||
/// scalar encoder cannot take an array value), though the composer still materialises an array node.</para>
|
||||
/// <para><b>Elements convert; they are not exact-matched.</b> ScadaBridge writes CLR arrays such as
|
||||
/// <c>int[]</c> / <c>List<int></c> to Int16 nodes; the node manager converts a numeric array to the
|
||||
/// node's element type, and the AbCip, AbLegacy and TwinCAT encoders then <c>Convert</c> each element to the
|
||||
/// controller's type (an element that does not fit is <c>BadOutOfRange</c>). So those rows do not key on an
|
||||
/// exact node <c>DataType</c>; they leave out only the node types whose elements NEVER convert: a
|
||||
/// <c>DateTime</c> or <c>String</c> node over a numeric element (AbCip, AbLegacy, TwinCAT — TwinCAT keeps
|
||||
/// DATE / DT and WSTRING under a DateTime node, and STRING / WSTRING under a String node; a DateTime's text
|
||||
/// form is culture-dependent, so a STRING under a DateTime node is left out), and a <c>Boolean</c> node on
|
||||
/// AbLegacy, whose encoder refuses bool elements. An unknown DataType name (served as BaseDataType) hands the
|
||||
/// driver whatever the client sent and is not refused.</para>
|
||||
/// <para>Multi-dimensional arrays (<c>ValueRank > 1</c>) are out of scope altogether — every array node
|
||||
/// is materialised 1-D (<c>ValueRank = OneDimension</c>), so nothing here considers more than one rank.</para>
|
||||
/// </summary>
|
||||
@@ -72,6 +99,34 @@ public static class DriverArrayWriteSupport
|
||||
"Boolean", "Int32", "Int64", "Float", "Double", "String", "DateTime",
|
||||
};
|
||||
|
||||
// AbCipArrayCodec.ElementWidth: the atomic element types with a fixed wire width.
|
||||
private static readonly HashSet<string> AbCipArrayTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"SInt", "USInt", "Int", "UInt", "DInt", "UDInt", "Real", "Dt", "LInt", "ULInt", "LReal",
|
||||
};
|
||||
|
||||
/// <summary>The PCCC data-file element count ceiling (<c>AbLegacyArray.MaxElements</c>): the AbLegacy tag
|
||||
/// factory CLAMPS a longer <c>arrayLength</c> to it, so a node of the authored (longer) length never matches.</summary>
|
||||
public const int AbLegacyMaxArrayElements = 256;
|
||||
|
||||
// AbLegacyTagDefinitionFactory.ArrayFileElementType for the files the table admits, keyed by file letter, to the
|
||||
// authored element types that match that file's width (ArrayFileMismatch). I / O / S are deliberately absent:
|
||||
// libplctag refuses a write to their translated names, and the table does not widen output-image / status-file
|
||||
// writes on the native transport alone.
|
||||
private static readonly Dictionary<string, string[]> AbLegacyFileElementTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["N"] = ["Int", "AnalogInt"], ["B"] = ["Int", "AnalogInt"], ["A"] = ["Int", "AnalogInt"],
|
||||
["L"] = ["Long"],
|
||||
["F"] = ["Float"],
|
||||
};
|
||||
|
||||
// TwinCATArrayCodec.Encode: every TwinCATDataType but Structure (which the factory refuses outright).
|
||||
private static readonly HashSet<string> TwinCATArrayTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"Bool", "SInt", "USInt", "Int", "UInt", "DInt", "UDInt", "LInt", "ULInt", "Real", "LReal",
|
||||
"String", "WString", "Time", "Date", "DateTime", "TimeOfDay",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Whether a driver of <paramref name="driverType"/> can write a 1-D array tag of the given shape.
|
||||
/// </summary>
|
||||
@@ -119,11 +174,150 @@ public static class DriverArrayWriteSupport
|
||||
var native = ReadEnumName(root, "dataType") ?? "Int16";
|
||||
return S7ArrayTypes.Contains(native);
|
||||
}
|
||||
case DriverTypeNames.AbCip:
|
||||
return AbCipSupports(dataType, tagConfig);
|
||||
case DriverTypeNames.AbLegacy:
|
||||
return AbLegacySupports(dataType, tagConfig, driverConfigs);
|
||||
case DriverTypeNames.TwinCAT:
|
||||
return TwinCATSupports(dataType, tagConfig);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// AbCipTagDefinitionFactory + AbCipDriver.WriteAsync: a non-blank string tagPath, an atomic element type the codec
|
||||
// has a width for (BOOL arrays are bit-packed DWORDs, STRING / structure elements are structures), a declared
|
||||
// length, and not a safety tag (refused BadNotWritable). Each element is CONVERTED, so any numeric / Boolean node
|
||||
// type works; DateTime and (non-numeric) String elements never convert to a Logix integer or REAL.
|
||||
private static bool AbCipSupports(string? dataType, string? tagConfig)
|
||||
{
|
||||
if (!TryParseObject(tagConfig, out var root) || !HasNonBlankString(root, "tagPath")
|
||||
|| ReadArrayLength(root) is null
|
||||
|| (root.TryGetProperty("safetyTag", out var safety) && safety.ValueKind == JsonValueKind.True))
|
||||
return false;
|
||||
var native = ReadEnumName(root, "dataType")?.Trim() ?? "DInt";
|
||||
return AbCipArrayTypes.Contains(native) && dataType is not ("String" or "DateTime");
|
||||
}
|
||||
|
||||
// AbLegacyTagDefinitionFactory + AbLegacyDriver.WriteAsync: a flat Int / AnalogInt / Long / Float array on a plain
|
||||
// `<file><number>:<word>` address whose data file's element width matches (ArrayFileMismatch); no /bit, no .sub,
|
||||
// no indirect [..] address; at most 256 elements (the factory clamps a longer length, so the node never matches);
|
||||
// and not a PLC-5 on the native transport (refused before the wire). Elements CONVERT from any numeric node type;
|
||||
// bool, string and DateTime elements are refused.
|
||||
private static bool AbLegacySupports(string? dataType, string? tagConfig, string?[]? driverConfigs)
|
||||
{
|
||||
if (!TryParseObject(tagConfig, out var root)
|
||||
|| !root.TryGetProperty("address", out var addressEl) || addressEl.ValueKind != JsonValueKind.String
|
||||
|| ReadArrayLength(root) is not { } count || count > AbLegacyMaxArrayElements
|
||||
|| dataType is "Boolean" or "String" or "DateTime")
|
||||
return false;
|
||||
var native = ReadEnumName(root, "dataType")?.Trim() ?? "Int";
|
||||
if (!TryParsePlainPcccAddress(addressEl.GetString(), out var fileLetter)
|
||||
|| !AbLegacyFileElementTypes.TryGetValue(fileLetter, out var matching)
|
||||
|| !matching.Contains(native, StringComparer.OrdinalIgnoreCase))
|
||||
return false;
|
||||
return !AbLegacyIsNativePlc5(driverConfigs);
|
||||
}
|
||||
|
||||
// `<letters><digits>:<digits>` and nothing else (trimmed) — what AbLegacyAddress.TryParse yields with no bit
|
||||
// index, no sub-element and no indirection, for the file letters that carry a file number.
|
||||
private static bool TryParsePlainPcccAddress(string? address, out string fileLetter)
|
||||
{
|
||||
fileLetter = "";
|
||||
if (string.IsNullOrWhiteSpace(address)) return false;
|
||||
var src = address.Trim();
|
||||
var colon = src.IndexOf(':');
|
||||
if (colon <= 0) return false;
|
||||
var letterEnd = 0;
|
||||
while (letterEnd < colon && char.IsAsciiLetter(src[letterEnd])) letterEnd++;
|
||||
if (letterEnd == 0 || letterEnd == colon) return false; // no letter, or no file number
|
||||
var fileNumber = src[letterEnd..colon];
|
||||
var word = src[(colon + 1)..];
|
||||
if (!fileNumber.All(char.IsAsciiDigit) || !int.TryParse(fileNumber, out _)
|
||||
|| word.Length == 0 || !word.All(char.IsAsciiDigit) || !int.TryParse(word, out _))
|
||||
return false;
|
||||
fileLetter = src[..letterEnd];
|
||||
return true;
|
||||
}
|
||||
|
||||
// Conservative: ANY config naming the native transport AND ANY config naming a PLC-5 (a root `plcFamily`, as a
|
||||
// DeviceConfig carries it, or one in a `devices` array, as a DriverConfig carries it). Keys and values are matched
|
||||
// case-insensitively, like the driver's DTO binding and enum parse. An absent transport is libplctag for every
|
||||
// family (AbLegacyTransportResolver), which writes PLC-5.
|
||||
private static bool AbLegacyIsNativePlc5(string?[]? driverConfigs)
|
||||
{
|
||||
var native = false;
|
||||
var plc5 = false;
|
||||
foreach (var config in driverConfigs ?? [])
|
||||
{
|
||||
if (!TryParseObject(config, out var root)) continue;
|
||||
foreach (var prop in root.EnumerateObject())
|
||||
{
|
||||
if (IsNamed(prop, "transport") && IsStringValue(prop.Value, "Native")) native = true;
|
||||
else if (IsNamed(prop, "plcFamily") && IsStringValue(prop.Value, "Plc5")) plc5 = true;
|
||||
else if (IsNamed(prop, "devices") && prop.Value.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var device in prop.Value.EnumerateArray())
|
||||
{
|
||||
if (device.ValueKind != JsonValueKind.Object) continue;
|
||||
foreach (var dp in device.EnumerateObject())
|
||||
if (IsNamed(dp, "plcFamily") && IsStringValue(dp.Value, "Plc5")) plc5 = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return native && plc5;
|
||||
}
|
||||
|
||||
// TwinCATTagDefinitionFactory + TwinCATDriver.WriteAsync: a non-blank string symbolPath, a declared length, any
|
||||
// element type the codec lays out (not Structure, which the factory refuses), and not a BOOL bit-in-word path
|
||||
// (`MAIN.Word.3`: a scalar bit write, which an array value cannot feed). Elements CONVERT. A String node only feeds
|
||||
// a STRING / WSTRING element (anything else would depend on the text happening to parse). A DateTime node only
|
||||
// feeds a DATE / DT element or a WSTRING: its text form is CULTURE-dependent and may carry characters a STRING's
|
||||
// strict Windows-1252 encoding refuses (ICU's en-US "12:00:00 PM").
|
||||
private static bool TwinCATSupports(string? dataType, string? tagConfig)
|
||||
{
|
||||
if (!TryParseObject(tagConfig, out var root) || !HasNonBlankString(root, "symbolPath")
|
||||
|| ReadArrayLength(root) is null)
|
||||
return false;
|
||||
var native = ReadEnumName(root, "dataType")?.Trim() ?? "DInt";
|
||||
if (!TwinCATArrayTypes.Contains(native)) return false;
|
||||
var isWString = native.Equals("WString", StringComparison.OrdinalIgnoreCase);
|
||||
var isString = isWString || native.Equals("String", StringComparison.OrdinalIgnoreCase);
|
||||
var isDate = native.Equals("Date", StringComparison.OrdinalIgnoreCase) || native.Equals("DateTime", StringComparison.OrdinalIgnoreCase);
|
||||
if (dataType == "String" && !isString) return false;
|
||||
if (dataType == "DateTime" && !isWString && !isDate) return false;
|
||||
return !(native.Equals("Bool", StringComparison.OrdinalIgnoreCase)
|
||||
&& HasTwinCATBitSuffix(root.GetProperty("symbolPath").GetString()!));
|
||||
}
|
||||
|
||||
// TwinCATSymbolPath.TryParse's bit-index rule: a last top-level `.` segment of 0..31 with no `[`, behind at least
|
||||
// one other segment (a TwinCAT 2 global's leading dot belongs to its root segment).
|
||||
private static bool HasTwinCATBitSuffix(string symbolPath)
|
||||
{
|
||||
var src = symbolPath.Trim();
|
||||
if (src.StartsWith('.')) src = src[1..];
|
||||
var depth = 0;
|
||||
var lastDot = -1;
|
||||
for (var i = 0; i < src.Length; i++)
|
||||
{
|
||||
if (src[i] == '[') depth++;
|
||||
else if (src[i] == ']') depth--;
|
||||
else if (src[i] == '.' && depth == 0) lastDot = i;
|
||||
}
|
||||
if (lastDot < 0) return false;
|
||||
var last = src[(lastDot + 1)..];
|
||||
return int.TryParse(last, out var bit) && bit is >= 0 and <= 31 && !last.Contains('[');
|
||||
}
|
||||
|
||||
private static bool HasNonBlankString(JsonElement root, string name) =>
|
||||
root.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(el.GetString());
|
||||
|
||||
private static bool IsNamed(JsonProperty prop, string name) => string.Equals(prop.Name, name, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static bool IsStringValue(JsonElement el, string value) =>
|
||||
el.ValueKind == JsonValueKind.String && string.Equals(el.GetString()?.Trim(), value, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Whether a driver of <paramref name="driverType"/> is known to REFUSE an
|
||||
/// array value written to one of its SCALAR tags, so the node manager may refuse such a write inline
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
/// <para>
|
||||
/// The mechanism mirrors how a <c>VirtualTagActor</c> registers interest on the
|
||||
/// <c>DependencyMuxActor</c>: the mux already receives every driver's
|
||||
/// <c>AttributeValuePublished</c> keyed by wire-ref (under v3, RawPath), so a driver that
|
||||
/// published values (batched, one item per value) keyed by wire-ref (under v3, RawPath), so a driver that
|
||||
/// implements this interface simply piggy-backs on that fan-out. A calc tag's computed output
|
||||
/// re-enters the mux via the ordinary driver publish path, so calc-of-calc chains work with no
|
||||
/// extra machinery — which is exactly why the deploy-time cycle gate is mandatory.
|
||||
|
||||
@@ -80,6 +80,50 @@ public abstract class ScriptContext
|
||||
/// </summary>
|
||||
public abstract ILogger Logger { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The current run's cancellation token. The run guard cancels it when the run exceeds its wall-clock
|
||||
/// budget, and it is linked to the caller's token, so it is also cancelled when the caller cancels (host
|
||||
/// shutdown, an actor stopping mid-run). A long-running script can poll <c>ctx.CancellationToken.IsCancellationRequested</c> (or call
|
||||
/// <c>ThrowIfCancellationRequested()</c>) and end its own abandoned run instead of holding a pool thread
|
||||
/// until it returns. Cooperative only: a script that never looks at it still runs to completion as an
|
||||
/// orphan. <see cref="CancellationToken.None"/> outside a run.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Scoped per run through an <see cref="AsyncLocal{T}"/>, not stored on the context, because a context
|
||||
/// object can be reused across runs (the scripted-alarm engine keeps one per alarm). An abandoned orphan
|
||||
/// therefore keeps seeing its own cancelled token even after a newer run starts on the same context.
|
||||
/// </remarks>
|
||||
public CancellationToken CancellationToken => s_runToken.Value;
|
||||
|
||||
private static readonly AsyncLocal<CancellationToken> s_runToken = new();
|
||||
|
||||
/// <summary>
|
||||
/// Makes <paramref name="token"/> the script-visible <see cref="CancellationToken"/> on the calling
|
||||
/// execution context until the returned scope is disposed. Called by <c>ScriptEvaluator.RunAsync</c>
|
||||
/// around the compiled delegate.
|
||||
/// </summary>
|
||||
/// <param name="token">The run token.</param>
|
||||
/// <returns>A scope that restores the previous token.</returns>
|
||||
internal static RunTokenScope EnterRun(CancellationToken token)
|
||||
{
|
||||
var previous = s_runToken.Value;
|
||||
s_runToken.Value = token;
|
||||
return new RunTokenScope(previous);
|
||||
}
|
||||
|
||||
/// <summary>Restores the token that was current before <see cref="EnterRun"/>.</summary>
|
||||
internal readonly struct RunTokenScope : IDisposable
|
||||
{
|
||||
private readonly CancellationToken _previous;
|
||||
|
||||
/// <summary>Captures the token to restore.</summary>
|
||||
/// <param name="previous">The token current before the run.</param>
|
||||
public RunTokenScope(CancellationToken previous) => _previous = previous;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose() => s_runToken.Value = _previous;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deadband helper — returns <c>true</c> when <paramref name="current"/> differs
|
||||
/// from <paramref name="previous"/> by more than <paramref name="tolerance"/>.
|
||||
|
||||
+6
@@ -16,4 +16,10 @@
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- ScriptEvaluator scopes the script-visible run token (ScriptContext.EnterRun). Internal so a script
|
||||
cannot replace its own token. -->
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Core.Scripting"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -185,7 +185,13 @@ public sealed class ScriptEvaluator<TContext, TResult> : IDisposable
|
||||
// TResult directly). We surface a Task<TResult> only to keep the existing
|
||||
// RunAsync contract consumers depend on. TimedScriptEvaluator wraps this in
|
||||
// Task.Run so a long-running script still honours its wall-clock budget.
|
||||
var result = _func(globals);
|
||||
// The run token is the script-visible ctx.CancellationToken for exactly this call. ScriptRunGuard passes
|
||||
// the token it cancels on timeout, so a polling script can end its own abandoned run.
|
||||
TResult result;
|
||||
using (ScriptContext.EnterRun(ct))
|
||||
{
|
||||
result = _func(globals);
|
||||
}
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
|
||||
@@ -96,9 +96,11 @@ public sealed class ScriptRunGuard
|
||||
/// <param name="logger">Where the quarantine Error line goes; null ⇒ Serilog's global logger.</param>
|
||||
public ScriptRunGuard(ScriptingOptions? options = null, ILogger? logger = null)
|
||||
{
|
||||
options ??= new ScriptingOptions();
|
||||
// Clamped here as well as at the bind site, because options also arrive from a Calculation driver's own
|
||||
// config and from tests. An unclamped factor made the refusal path's timeout * factor overflow.
|
||||
options = (options ?? new ScriptingOptions()).Sanitized(out _);
|
||||
_maxAllocBytesPerRun = options.MaxAllocBytesPerRun;
|
||||
_quarantineThreshold = Math.Max(1, options.QuarantineAfterConsecutiveTimeouts);
|
||||
_quarantineThreshold = options.QuarantineAfterConsecutiveTimeouts;
|
||||
_stuckFactor = options.StuckOrphanQuarantineFactor;
|
||||
_logger = logger ?? Log.Logger;
|
||||
}
|
||||
@@ -197,7 +199,11 @@ public sealed class ScriptRunGuard
|
||||
if (_stuckFactor > 0 && Volatile.Read(ref state.RunStartedTimestamp) is var startedAt and not 0)
|
||||
{
|
||||
var running = Stopwatch.GetElapsedTime(startedAt);
|
||||
var threshold = timeout * _stuckFactor;
|
||||
// Saturating: even the clamped maximum factor times a huge timeout must not overflow TimeSpan.
|
||||
var thresholdTicks = timeout.Ticks * _stuckFactor;
|
||||
var threshold = thresholdTicks >= TimeSpan.MaxValue.Ticks
|
||||
? TimeSpan.MaxValue
|
||||
: TimeSpan.FromTicks((long)thresholdTicks);
|
||||
if (running > threshold)
|
||||
{
|
||||
Quarantine(key, state,
|
||||
|
||||
@@ -53,4 +53,85 @@ public sealed class ScriptingOptions
|
||||
/// refusal. Zero or negative disables the rule. Default 10.
|
||||
/// </summary>
|
||||
public double StuckOrphanQuarantineFactor { get; set; } = DefaultStuckOrphanQuarantineFactor;
|
||||
|
||||
/// <summary>Upper bound <see cref="Sanitized"/> applies to <see cref="StuckOrphanQuarantineFactor"/>.</summary>
|
||||
public const double MaxStuckOrphanQuarantineFactor = 1000;
|
||||
|
||||
/// <summary>Upper bound <see cref="Sanitized"/> applies to <see cref="QuarantineAfterConsecutiveTimeouts"/>.</summary>
|
||||
public const int MaxQuarantineAfterConsecutiveTimeouts = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// Lower bound <see cref="Sanitized"/> applies to a positive <see cref="MaxAllocBytesPerRun"/>: 1 MB. A
|
||||
/// smaller budget would quarantine practically every script on its first run.
|
||||
/// </summary>
|
||||
public const long MinMaxAllocBytesPerRun = 1024L * 1024;
|
||||
|
||||
/// <summary>
|
||||
/// Returns a copy with every numeric knob pulled into a sane range. The bind site calls it and logs
|
||||
/// <paramref name="adjustments"/>. <see cref="ScriptRunGuard"/> calls it again, so options that reach the
|
||||
/// guard by another route (a Calculation driver's own config, a test) are clamped too.
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="StuckOrphanQuarantineFactor"/>: NaN becomes the default, a negative value becomes 0
|
||||
/// (disabled), and a value above <see cref="MaxStuckOrphanQuarantineFactor"/> becomes that maximum.
|
||||
/// Unclamped, an absurd factor made <c>timeout * factor</c> throw <see cref="OverflowException"/> on
|
||||
/// the first refused run.</item>
|
||||
/// <item><see cref="QuarantineAfterConsecutiveTimeouts"/>: into [1, <see cref="MaxQuarantineAfterConsecutiveTimeouts"/>].</item>
|
||||
/// <item><see cref="MaxAllocBytesPerRun"/>: a negative value becomes 0 (disabled), and a positive value
|
||||
/// below <see cref="MinMaxAllocBytesPerRun"/> becomes that minimum.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
/// <param name="adjustments">One line per value that was out of range, naming its key. A negative value that
|
||||
/// already meant "disabled" is normalised without a line.</param>
|
||||
/// <returns>A new, clamped instance. This instance is not modified.</returns>
|
||||
public ScriptingOptions Sanitized(out IReadOnlyList<string> adjustments)
|
||||
{
|
||||
var notes = new List<string>();
|
||||
|
||||
var factor = StuckOrphanQuarantineFactor;
|
||||
if (double.IsNaN(factor))
|
||||
{
|
||||
notes.Add($"{SectionName}:{nameof(StuckOrphanQuarantineFactor)} is NaN; using the default {DefaultStuckOrphanQuarantineFactor}.");
|
||||
factor = DefaultStuckOrphanQuarantineFactor;
|
||||
}
|
||||
else if (factor < 0)
|
||||
{
|
||||
factor = 0;
|
||||
}
|
||||
else if (factor > MaxStuckOrphanQuarantineFactor)
|
||||
{
|
||||
notes.Add($"{SectionName}:{nameof(StuckOrphanQuarantineFactor)} {factor} is above the maximum; clamped to {MaxStuckOrphanQuarantineFactor}.");
|
||||
factor = MaxStuckOrphanQuarantineFactor;
|
||||
}
|
||||
|
||||
var threshold = QuarantineAfterConsecutiveTimeouts;
|
||||
if (threshold < 1)
|
||||
{
|
||||
notes.Add($"{SectionName}:{nameof(QuarantineAfterConsecutiveTimeouts)} {threshold} is below 1; clamped to 1.");
|
||||
threshold = 1;
|
||||
}
|
||||
else if (threshold > MaxQuarantineAfterConsecutiveTimeouts)
|
||||
{
|
||||
notes.Add($"{SectionName}:{nameof(QuarantineAfterConsecutiveTimeouts)} {threshold} is above the maximum; clamped to {MaxQuarantineAfterConsecutiveTimeouts}.");
|
||||
threshold = MaxQuarantineAfterConsecutiveTimeouts;
|
||||
}
|
||||
|
||||
var alloc = MaxAllocBytesPerRun;
|
||||
if (alloc < 0)
|
||||
{
|
||||
alloc = 0;
|
||||
}
|
||||
else if (alloc > 0 && alloc < MinMaxAllocBytesPerRun)
|
||||
{
|
||||
notes.Add($"{SectionName}:{nameof(MaxAllocBytesPerRun)} {alloc} is below the minimum; clamped to {MinMaxAllocBytesPerRun}.");
|
||||
alloc = MinMaxAllocBytesPerRun;
|
||||
}
|
||||
|
||||
adjustments = notes;
|
||||
return new ScriptingOptions
|
||||
{
|
||||
MaxAllocBytesPerRun = alloc,
|
||||
QuarantineAfterConsecutiveTimeouts = threshold,
|
||||
StuckOrphanQuarantineFactor = factor,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,27 @@ public abstract class AbLegacyCommandBase : DriverCommandBase
|
||||
"LogixPccc unverified).")]
|
||||
public AbLegacyCliTransport Transport { get; init; } = AbLegacyCliTransport.Default;
|
||||
|
||||
/// <summary>
|
||||
/// Runs a one-shot command's driver work so a Ctrl-C (cancellation of <paramref name="ct"/>) prints
|
||||
/// <c>Cancelled.</c> instead of an unhandled <see cref="OperationCanceledException"/> stack trace (review 07 U7).
|
||||
/// Any other exception — and an OCE the operator did not ask for — still propagates.
|
||||
/// </summary>
|
||||
/// <param name="console">The CLI console.</param>
|
||||
/// <param name="ct">The console's Ctrl-C token.</param>
|
||||
/// <param name="body">The command body.</param>
|
||||
/// <returns>A task that completes when the body has finished or was cancelled.</returns>
|
||||
internal static async ValueTask RunCancellableAsync(CliFx.Infrastructure.IConsole console, CancellationToken ct, Func<Task> body)
|
||||
{
|
||||
try
|
||||
{
|
||||
await body();
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
await console.Error.WriteLineAsync("Cancelled.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Gets the per-operation timeout in milliseconds.</summary>
|
||||
[CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 5000).")]
|
||||
public int TimeoutMs { get; init; } = 5000;
|
||||
|
||||
@@ -43,17 +43,20 @@ public sealed class ProbeCommand : AbLegacyCommandBase
|
||||
var driver = new AbLegacyDriver(options, DriverInstanceId);
|
||||
try
|
||||
{
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
var snapshot = await driver.ReadAsync(["__probe"], ct);
|
||||
var health = driver.GetHealth();
|
||||
await RunCancellableAsync(console, ct, async () =>
|
||||
{
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
var snapshot = await driver.ReadAsync(["__probe"], ct);
|
||||
var health = driver.GetHealth();
|
||||
|
||||
await console.Output.WriteLineAsync($"Gateway: {Gateway}");
|
||||
await console.Output.WriteLineAsync($"PLC type: {PlcType}");
|
||||
await console.Output.WriteLineAsync($"Health: {health.State}");
|
||||
if (health.LastError is { } err)
|
||||
await console.Output.WriteLineAsync($"Last error: {err}");
|
||||
await console.Output.WriteLineAsync();
|
||||
await console.Output.WriteLineAsync(SnapshotFormatter.Format(Address, snapshot[0]));
|
||||
await console.Output.WriteLineAsync($"Gateway: {Gateway}");
|
||||
await console.Output.WriteLineAsync($"PLC type: {PlcType}");
|
||||
await console.Output.WriteLineAsync($"Health: {health.State}");
|
||||
if (health.LastError is { } err)
|
||||
await console.Output.WriteLineAsync($"Last error: {err}");
|
||||
await console.Output.WriteLineAsync();
|
||||
await console.Output.WriteLineAsync(SnapshotFormatter.Format(Address, snapshot[0]));
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -45,9 +45,12 @@ public sealed class ReadCommand : AbLegacyCommandBase
|
||||
var driver = new AbLegacyDriver(options, DriverInstanceId);
|
||||
try
|
||||
{
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
var snapshot = await driver.ReadAsync([tagName], ct);
|
||||
await console.Output.WriteLineAsync(SnapshotFormatter.Format(Address, snapshot[0]));
|
||||
await RunCancellableAsync(console, ct, async () =>
|
||||
{
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
var snapshot = await driver.ReadAsync([tagName], ct);
|
||||
await console.Output.WriteLineAsync(SnapshotFormatter.Format(Address, snapshot[0]));
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -55,9 +55,12 @@ public sealed class WriteCommand : AbLegacyCommandBase
|
||||
var driver = new AbLegacyDriver(options, DriverInstanceId);
|
||||
try
|
||||
{
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
var results = await driver.WriteAsync([new WriteRequest(tagName, parsed)], ct);
|
||||
await console.Output.WriteLineAsync(SnapshotFormatter.FormatWrite(Address, results[0]));
|
||||
await RunCancellableAsync(console, ct, async () =>
|
||||
{
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
var results = await driver.WriteAsync([new WriteRequest(tagName, parsed)], ct);
|
||||
await console.Output.WriteLineAsync(SnapshotFormatter.FormatWrite(Address, results[0]));
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -117,6 +117,12 @@ public sealed class AbCipDriverOptions
|
||||
/// device round trips (the whole point: a strictly sequential batch pays one full round
|
||||
/// trip per tag); lowering it to <c>1</c> restores strictly sequential reads. Values below
|
||||
/// <c>1</c> are clamped to <c>1</c> at the use site.
|
||||
/// <para>
|
||||
/// On the <see cref="AbCipTransport.Native"/> transport the effective window is at least 64: there the
|
||||
/// window caps how many services one Multiple Service Packet coalesces (the device's shared scheduler packs
|
||||
/// what is queued, bounded by the connection's request/reply byte budget), so a small value only multiplies
|
||||
/// round trips. A value above 64 is honoured; a value below it is raised.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public int MaxConcurrentReads { get; init; } = 8;
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// Encodes a whole-array write (review 07 U10): <c>count</c> elements at the element type's wire width,
|
||||
/// little-endian, exactly the layout a Logix atomic array holds and the read path decodes. Shared by both
|
||||
/// runtimes so the bytes on the wire are transport-independent.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Element values CONVERT, they are not exact-matched.</b> ScadaBridge (and any OPC UA client that
|
||||
/// round-trips what it read) writes CLR lists such as <c>int[]</c> to an INT array — the read path itself
|
||||
/// publishes INT/SINT elements as <c>int</c> — so each element goes through the same <see cref="Convert"/> the
|
||||
/// scalar path uses. A value that does not fit is an <see cref="OverflowException"/> (BadOutOfRange) and one that
|
||||
/// cannot convert a <see cref="FormatException"/> / <see cref="InvalidCastException"/> (BadTypeMismatch), all
|
||||
/// thrown before any byte is sent.</para>
|
||||
/// <para>BOOL arrays (bit-packed DWORDs — a byte-per-element encode would clobber neighbour bits), STRING arrays
|
||||
/// (structures) and structure arrays are not encodable here; the driver refuses them <c>BadNotSupported</c>
|
||||
/// before a runtime exists.</para>
|
||||
/// </remarks>
|
||||
internal static class AbCipArrayCodec
|
||||
{
|
||||
/// <summary>Wire width of one element of an encodable array type, or <c>null</c> when the type is not encodable.</summary>
|
||||
/// <param name="type">The element type.</param>
|
||||
/// <returns>The element width in bytes, or <c>null</c>.</returns>
|
||||
public static int? ElementWidth(AbCipDataType type) => type switch
|
||||
{
|
||||
AbCipDataType.SInt or AbCipDataType.USInt => 1,
|
||||
AbCipDataType.Int or AbCipDataType.UInt => 2,
|
||||
AbCipDataType.DInt or AbCipDataType.UDInt or AbCipDataType.Real or AbCipDataType.Dt => 4,
|
||||
AbCipDataType.LInt or AbCipDataType.ULInt or AbCipDataType.LReal => 8,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The elements of an array write value, or <c>null</c> when the value is not a list (a scalar, a string, null).
|
||||
/// Any non-string <see cref="IEnumerable"/> is accepted — <c>int[]</c>, <c>short[]</c>, <c>List<object></c> …
|
||||
/// </summary>
|
||||
/// <param name="value">The client's write value.</param>
|
||||
/// <returns>The elements, or <c>null</c>.</returns>
|
||||
public static object?[]? Elements(object? value) => value switch
|
||||
{
|
||||
null or string => null,
|
||||
IEnumerable list => [.. list.Cast<object?>()],
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <summary>Encodes <paramref name="values"/> as <paramref name="type"/> elements.</summary>
|
||||
/// <param name="type">The element type (must have an <see cref="ElementWidth"/>).</param>
|
||||
/// <param name="values">The element values; their count is the element count.</param>
|
||||
/// <returns>The little-endian wire bytes, <c>values.Count × ElementWidth(type)</c> long.</returns>
|
||||
/// <exception cref="NotSupportedException">The type is not encodable.</exception>
|
||||
public static byte[] Encode(AbCipDataType type, IReadOnlyList<object?> values)
|
||||
{
|
||||
var width = ElementWidth(type)
|
||||
?? throw new NotSupportedException($"Array writes of {type} elements are not supported.");
|
||||
var buffer = new byte[values.Count * width];
|
||||
for (var i = 0; i < values.Count; i++)
|
||||
{
|
||||
var v = values[i];
|
||||
var span = buffer.AsSpan(i * width, width);
|
||||
switch (type)
|
||||
{
|
||||
case AbCipDataType.SInt: span[0] = unchecked((byte)Convert.ToSByte(v)); break;
|
||||
case AbCipDataType.USInt: span[0] = Convert.ToByte(v); break;
|
||||
case AbCipDataType.Int: BinaryPrimitives.WriteInt16LittleEndian(span, Convert.ToInt16(v)); break;
|
||||
case AbCipDataType.UInt: BinaryPrimitives.WriteUInt16LittleEndian(span, Convert.ToUInt16(v)); break;
|
||||
case AbCipDataType.DInt:
|
||||
case AbCipDataType.Dt: BinaryPrimitives.WriteInt32LittleEndian(span, Convert.ToInt32(v)); break;
|
||||
case AbCipDataType.UDInt: BinaryPrimitives.WriteUInt32LittleEndian(span, Convert.ToUInt32(v)); break;
|
||||
case AbCipDataType.LInt: BinaryPrimitives.WriteInt64LittleEndian(span, Convert.ToInt64(v)); break;
|
||||
case AbCipDataType.ULInt: BinaryPrimitives.WriteUInt64LittleEndian(span, Convert.ToUInt64(v)); break;
|
||||
case AbCipDataType.Real: BinaryPrimitives.WriteSingleLittleEndian(span, Convert.ToSingle(v)); break;
|
||||
case AbCipDataType.LReal: BinaryPrimitives.WriteDoubleLittleEndian(span, Convert.ToDouble(v)); break;
|
||||
}
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,10 +18,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
/// non-CIP server) returns <c>Ok = false</c> with a "handshake failed" message instead of
|
||||
/// a false-positive green tick.
|
||||
/// <para>
|
||||
/// Status-code discrimination: <c>AbStatus.Ok</c> and <c>AbStatus.NotFound</c> mean the controller
|
||||
/// answered the CIP session (a non-existent probe tag name still proves CIP connectivity) and are
|
||||
/// treated as "reachable - Ok = true". Every other status (timeout, bad connection, bad reply,
|
||||
/// partial, abort, ...) is a transport or session failure: "handshake failed - Ok = false".
|
||||
/// Status-code discrimination: <c>AbStatus.Ok</c> and every device refusal (not found, out of bounds,
|
||||
/// not allowed, too large/small, type mismatch, unsupported) mean the controller answered the CIP session
|
||||
/// (a non-existent probe tag name still proves CIP connectivity) and are treated as "reachable - Ok = true".
|
||||
/// Every other status (timeout, bad connection, bad reply, partial, abort, ...) is a transport or session
|
||||
/// failure: "handshake failed - Ok = false".
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class AbCipDriverProbe : IDriverProbe
|
||||
@@ -147,7 +148,7 @@ public sealed class AbCipDriverProbe : IDriverProbe
|
||||
{
|
||||
var msg = statusCode == AbStatus.Ok
|
||||
? "CIP session OK"
|
||||
: "CIP session OK (controller reachable; probe tag not found)";
|
||||
: RefusedProbeMessage(statusCode);
|
||||
return new(true, msg, sw.Elapsed);
|
||||
}
|
||||
|
||||
@@ -164,7 +165,7 @@ public sealed class AbCipDriverProbe : IDriverProbe
|
||||
if (IsReachableException(ex))
|
||||
{
|
||||
sw.Stop();
|
||||
return new(true, "CIP session OK (controller reachable; probe tag not found)", sw.Elapsed);
|
||||
return new(true, RefusedProbeMessage(ex.Status), sw.Elapsed);
|
||||
}
|
||||
|
||||
return new(false, $"Reachable at {host}:{port} but CIP handshake failed: {ex.Message}", null);
|
||||
@@ -180,13 +181,20 @@ public sealed class AbCipDriverProbe : IDriverProbe
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns <c>true</c> for transport statuses that indicate the CIP controller answered
|
||||
/// but the probe tag name was not found or did not match — sufficient to confirm CIP
|
||||
/// reachability. Returns <c>true</c> for <c>Ok</c> (tag was found).
|
||||
/// Returns <c>true</c> for transport statuses that indicate the CIP controller answered — <c>Ok</c>, or any
|
||||
/// device refusal (<see cref="AbCipRefusals.IsDeviceRefusal(AbStatus)"/>: not found, out of bounds, not
|
||||
/// allowed, too large/small, type mismatch, unsupported) — sufficient to confirm CIP reachability.
|
||||
/// </summary>
|
||||
/// <param name="s">The transport status to classify.</param>
|
||||
/// <returns><see langword="true"/> if the status confirms CIP reachability.</returns>
|
||||
internal static bool IsReachableStatus(AbStatus s) => s is AbStatus.Ok or AbStatus.NotFound;
|
||||
internal static bool IsReachableStatus(AbStatus s) => s == AbStatus.Ok || AbCipRefusals.IsDeviceRefusal(s);
|
||||
|
||||
/// <summary>The Test-Connect message for a controller that answered but refused the probe tag.</summary>
|
||||
/// <param name="status">The refusal.</param>
|
||||
/// <returns>The message.</returns>
|
||||
private static string RefusedProbeMessage(AbStatus status) => status == AbStatus.NotFound
|
||||
? "CIP session OK (controller reachable; probe tag not found)"
|
||||
: $"CIP session OK (controller reachable; probe tag refused: {status})";
|
||||
|
||||
/// <summary>
|
||||
/// Decides whether an <see cref="AbTransportException"/> is a "controller answered CIP but the
|
||||
@@ -198,7 +206,8 @@ public sealed class AbCipDriverProbe : IDriverProbe
|
||||
internal static bool IsReachableException(AbTransportException ex)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ex);
|
||||
return IsReachableStatus(ex.Status);
|
||||
// A local refusal (the library / driver refused without any I/O) proves nothing about the controller.
|
||||
return ex is not AbCipLocalRefusalException && IsReachableStatus(ex.Status);
|
||||
}
|
||||
|
||||
private static (AbCipHostAddress? parsed, AbCipDeviceOptions? device) ExtractTarget(AbCipDriverOptions opts)
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// Thrown by <see cref="AbCipDriver.InitializeAsync"/> when a shutdown, dispose or newer initialization retired the
|
||||
/// driver's tables while this initialization was building, so it published nothing. Deliberately NOT an
|
||||
/// <see cref="OperationCanceledException"/>: nobody cancelled this call, and a caller that treats cancellation as
|
||||
/// "my own token fired" must not mistake a superseded attempt for one. The superseding operation owns the driver's
|
||||
/// state; the caller (normally the runtime, which has already abandoned this attempt) can ignore the result.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Resilience accounting (#737 m2): not being an OCE, it passes the Initialize pipeline's breaker
|
||||
/// <c>ShouldHandle</c> (<c>DriverResiliencePipelineBuilder</c>) and, if its generation still matched, the actor's
|
||||
/// <c>RecordFault</c>. That is practically unreachable — <c>DriverInstanceActor</c> runs initialisation single-flight
|
||||
/// and drops generation-mismatched results — and a superseded attempt is at worst one counted failure. Recognising
|
||||
/// it there is a runtime-side (Lane A) change if it is ever wanted.
|
||||
/// </remarks>
|
||||
/// <param name="message">The detail.</param>
|
||||
public sealed class AbCipInitializationSupersededException(string message) : InvalidOperationException(message);
|
||||
@@ -0,0 +1,15 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// A refusal raised LOCALLY — by the driver's own runtime or by libplctag (<c>ErrorNotImplemented</c>) — before or
|
||||
/// without any reply from the controller. It carries <see cref="AbStatus.Unsupported"/> (so it maps to
|
||||
/// <c>BadNotSupported</c>) but, unlike a device refusal, proves nothing about the device: it keeps the runtime and
|
||||
/// writes no health, does NOT advance <c>LastSuccessfulRead</c>, records nothing in the connect backoff, and is not
|
||||
/// "reachable" for the probe or Test Connect (review 07 / Gitea #737 I2).
|
||||
/// </summary>
|
||||
/// <param name="message">The refusal detail.</param>
|
||||
/// <param name="inner">The underlying exception, if any.</param>
|
||||
public sealed class AbCipLocalRefusalException(string message, Exception? inner = null)
|
||||
: AbTransportException(AbStatus.Unsupported, message, inner);
|
||||
@@ -0,0 +1,42 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// The ONE classification of a transport status into "the controller answered" vs "the wire failed", shared by
|
||||
/// the data path, the probe loop, the Test-Connect probe and the native runtime's initialisation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A device refusal is deterministic: the session works and the controller said no to this one tag (a renamed
|
||||
/// tag, <c>Arr[999]</c>, a read-protected tag, a type the authored config does not match). It is per-tag Bad,
|
||||
/// keeps the tag runtime (evicting re-creates it — and, for a sole lease holder, re-Forward-Opens — on every poll),
|
||||
/// never writes <c>Degraded</c>, never opens the device-wide connect backoff, and warns once per tag. Everything
|
||||
/// else (timeout, bad connection, a reply that failed to parse, partial, abort, …) is a comms fault.
|
||||
/// <para>
|
||||
/// The native transport currently folds CIP general statuses 0x1C, 0x20 and 0xFF/0x2104 into
|
||||
/// <see cref="AbStatus.BadReply"/> (<c>AbStatusCodes.FromCip</c>, owned by <c>Driver.Ab.Transport</c>), which
|
||||
/// this classification cannot tell from a garbled reply; until the transport maps them explicitly they stay
|
||||
/// comms faults.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class AbCipRefusals
|
||||
{
|
||||
/// <summary>True when <paramref name="status"/> proves the controller answered but refused this tag.</summary>
|
||||
/// <param name="status">The transport status.</param>
|
||||
/// <returns><see langword="true"/> for a deterministic device refusal.</returns>
|
||||
public static bool IsDeviceRefusal(AbStatus status) => status is AbStatus.NotFound or AbStatus.OutOfBounds
|
||||
or AbStatus.NotAllowed or AbStatus.TooLarge or AbStatus.TooSmall or AbStatus.TypeMismatch
|
||||
or AbStatus.Unsupported;
|
||||
|
||||
/// <summary>True when <paramref name="status"/> (an <see cref="AbStatus"/> value) is a device refusal.</summary>
|
||||
/// <param name="status">The runtime status word.</param>
|
||||
/// <returns><see langword="true"/> for a deterministic device refusal.</returns>
|
||||
public static bool IsDeviceRefusal(int status) => IsDeviceRefusal((AbStatus)status);
|
||||
|
||||
/// <summary>True when <paramref name="ex"/> is an <see cref="AbTransportException"/> carrying a device refusal.</summary>
|
||||
/// <param name="ex">The exception.</param>
|
||||
/// <returns><see langword="true"/> for a deterministic device refusal.</returns>
|
||||
/// <remarks>An <see cref="AbCipLocalRefusalException"/> is never a device refusal: nothing reached the device.</remarks>
|
||||
public static bool IsDeviceRefusal(Exception ex) =>
|
||||
ex is AbTransportException ate and not AbCipLocalRefusalException && IsDeviceRefusal(ate.Status);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ public static class AbCipStatusMapper
|
||||
public const uint GoodMoreData = 0x00A60000u;
|
||||
public const uint BadInternalError = 0x80020000u;
|
||||
public const uint BadNodeIdUnknown = 0x80340000u;
|
||||
public const uint BadNotReadable = 0x803A0000u;
|
||||
public const uint BadNotWritable = 0x803B0000u;
|
||||
public const uint BadOutOfRange = 0x803C0000u;
|
||||
public const uint BadNotSupported = 0x803D0000u;
|
||||
@@ -51,4 +52,14 @@ public static class AbCipStatusMapper
|
||||
/// <returns>The OPC UA status code.</returns>
|
||||
public static uint MapException(Exception ex) =>
|
||||
AbOpcUaStatusMap.MapException(ex);
|
||||
|
||||
/// <summary>
|
||||
/// Status for a READ the controller refused: as <see cref="MapStatus(AbStatus)"/>, except that a
|
||||
/// <see cref="AbStatus.NotAllowed"/> answer to a read is <see cref="BadNotReadable"/> (the shared table's
|
||||
/// <c>BadNotWritable</c> describes a refused write).
|
||||
/// </summary>
|
||||
/// <param name="status">The refusal status.</param>
|
||||
/// <returns>The OPC UA status code.</returns>
|
||||
public static uint MapReadStatus(AbStatus status) =>
|
||||
status == AbStatus.NotAllowed ? BadNotReadable : MapStatus(status);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,30 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
/// </remarks>
|
||||
public static class AbCipUdtMemberLayout
|
||||
{
|
||||
/// <summary>Layouts already computed, keyed by the parent definition's member list (reference identity).</summary>
|
||||
private static readonly System.Runtime.CompilerServices.ConditionalWeakTable<IReadOnlyList<AbCipStructureMember>, LayoutBox> Cache = new();
|
||||
|
||||
/// <summary>Holds a computed layout, including a "not groupable" (<c>null</c>) verdict.</summary>
|
||||
private sealed class LayoutBox(IReadOnlyDictionary<string, int>? offsets)
|
||||
{
|
||||
public IReadOnlyDictionary<string, int>? Offsets { get; } = offsets;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="TryBuild"/>, computed once per member list (review 07 P11: the read planner rebuilt the layout —
|
||||
/// a dictionary allocation and an O(members) walk — for every member reference on every poll, O(members²) per
|
||||
/// parent per tick). A parent definition's member list is immutable for the life of the tag table that holds
|
||||
/// it, so caching by list identity is exact; a re-init builds new lists and the old entries are collected with
|
||||
/// them (<see cref="System.Runtime.CompilerServices.ConditionalWeakTable{TKey,TValue}"/>).
|
||||
/// </summary>
|
||||
/// <param name="members">The parent definition's declared members.</param>
|
||||
/// <returns>The member offsets, or <c>null</c> when the members cannot be laid out by declaration.</returns>
|
||||
public static IReadOnlyDictionary<string, int>? GetOrBuild(IReadOnlyList<AbCipStructureMember> members)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(members);
|
||||
return Cache.GetValue(members, m => new LayoutBox(TryBuild(m))).Offsets;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Try to compute member offsets for the supplied declared members. Returns <c>null</c>
|
||||
/// if any member type is unsupported for declaration-only layout.
|
||||
|
||||
@@ -67,7 +67,7 @@ public static class AbCipUdtReadPlanner
|
||||
continue;
|
||||
}
|
||||
|
||||
var offsets = AbCipUdtMemberLayout.TryBuild(parent.Members);
|
||||
var offsets = AbCipUdtMemberLayout.GetOrBuild(parent.Members);
|
||||
if (offsets is null || !offsets.TryGetValue(memberName, out var offset))
|
||||
{
|
||||
fallback.Add(new AbCipUdtReadFallback(i, name));
|
||||
|
||||
@@ -85,6 +85,17 @@ public interface IAbCipTagRuntime : IDisposable
|
||||
/// <param name="bitIndex">Bit index for BOOL-within-DINT insertion, or null.</param>
|
||||
/// <param name="value">Value to encode.</param>
|
||||
void EncodeValue(AbCipDataType type, int? bitIndex, object? value);
|
||||
|
||||
/// <summary>
|
||||
/// Encode a WHOLE-array write of <paramref name="values"/> (<c>values.Count</c> elements of
|
||||
/// <paramref name="type"/>, at the element width — see <see cref="AbCipArrayCodec"/>) into the local buffer.
|
||||
/// Callers pair this with <see cref="WriteAsync"/>. A runtime that cannot write arrays throws
|
||||
/// <see cref="NotSupportedException"/>.
|
||||
/// </summary>
|
||||
/// <param name="type">The array element type.</param>
|
||||
/// <param name="values">The element values; already length-checked against the tag's element count.</param>
|
||||
void EncodeArray(AbCipDataType type, IReadOnlyList<object?> values) =>
|
||||
throw new NotSupportedException($"{GetType().Name} does not support array writes.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -14,11 +14,21 @@ internal static class LibplctagStatusTranslation
|
||||
Status.Ok => AbStatus.Ok,
|
||||
Status.Pending => AbStatus.Pending,
|
||||
Status.ErrorTimeout => AbStatus.Timeout,
|
||||
Status.ErrorNotFound or Status.ErrorNoMatch or Status.ErrorBadDevice => AbStatus.NotFound,
|
||||
// #737 I2: only ErrorNotFound is the controller saying "no such tag". ErrorNoMatch / ErrorBadDevice describe a
|
||||
// session or device-level failure (no proof the controller answered this tag), so they are comms-class: with
|
||||
// the widened refusal classification, folding them into NotFound made a broken session read as "reachable".
|
||||
Status.ErrorNotFound => AbStatus.NotFound,
|
||||
Status.ErrorNoMatch or Status.ErrorBadDevice => AbStatus.BadConnection,
|
||||
Status.ErrorNotAllowed => AbStatus.NotAllowed,
|
||||
Status.ErrorOutOfBounds => AbStatus.OutOfBounds,
|
||||
Status.ErrorTooLarge => AbStatus.TooLarge,
|
||||
Status.ErrorTooSmall => AbStatus.TooSmall,
|
||||
// ErrorNotImplemented is the LIBRARY refusing (no I/O); the exception paths raise it as an
|
||||
// AbCipLocalRefusalException so it never counts as "the device answered" (see FromException).
|
||||
// ASSUMPTION (#737 m3): libplctag reports a failed operation by THROWING, so a NotImplemented reaches the driver
|
||||
// only through FromException. AbStatus has no "local" member (it is Driver.Ab.Transport's, Lane K), so a
|
||||
// NotImplemented observed only as a STATUS WORD (GetStatus after a non-throwing op) would still classify as a
|
||||
// device Unsupported refusal; that combination is not produced by libplctag.NET 1.5.2.
|
||||
Status.ErrorUnsupported or Status.ErrorNotImplemented => AbStatus.Unsupported,
|
||||
Status.ErrorPartial => AbStatus.Partial,
|
||||
Status.ErrorAbort => AbStatus.Abort,
|
||||
@@ -70,9 +80,21 @@ internal static class LibplctagStatusTranslation
|
||||
public static async Task Wrap(Func<Task> op)
|
||||
{
|
||||
try { await op().ConfigureAwait(false); }
|
||||
catch (LibPlcTagException ex) { throw new AbTransportException(ToAbStatus(ex), ex.Message, ex); }
|
||||
catch (LibPlcTagException ex) { throw FromException(ex); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The transport exception for a libplctag failure: <c>ErrorNotImplemented</c> becomes an
|
||||
/// <see cref="AbCipLocalRefusalException"/> (a library refusal, no I/O); everything else an
|
||||
/// <see cref="AbTransportException"/> carrying the translated status.
|
||||
/// </summary>
|
||||
/// <param name="ex">The libplctag exception.</param>
|
||||
/// <returns>The exception to throw.</returns>
|
||||
public static AbTransportException FromException(LibPlcTagException ex) =>
|
||||
Enum.TryParse<Status>((ex.Message ?? string.Empty).Trim(), ignoreCase: true, out var s) && s == Status.ErrorNotImplemented
|
||||
? new AbCipLocalRefusalException($"libplctag does not implement this operation: {ex.Message}", ex)
|
||||
: new AbTransportException(ToAbStatus(ex), ex.Message ?? string.Empty, ex);
|
||||
|
||||
/// <summary>Runs <paramref name="op"/>, rethrowing any <see cref="LibPlcTagException"/> as <see cref="AbTransportException"/>.</summary>
|
||||
/// <typeparam name="T">The result type.</typeparam>
|
||||
/// <param name="op">The libplctag operation.</param>
|
||||
@@ -80,7 +102,7 @@ internal static class LibplctagStatusTranslation
|
||||
public static async Task<T> Wrap<T>(Func<Task<T>> op)
|
||||
{
|
||||
try { return await op().ConfigureAwait(false); }
|
||||
catch (LibPlcTagException ex) { throw new AbTransportException(ToAbStatus(ex), ex.Message, ex); }
|
||||
catch (LibPlcTagException ex) { throw FromException(ex); }
|
||||
}
|
||||
|
||||
/// <summary>Runs a synchronous libplctag call, rethrowing <see cref="LibPlcTagException"/> as <see cref="AbTransportException"/>.</summary>
|
||||
@@ -90,7 +112,7 @@ internal static class LibplctagStatusTranslation
|
||||
public static T WrapSync<T>(Func<T> op)
|
||||
{
|
||||
try { return op(); }
|
||||
catch (LibPlcTagException ex) { throw new AbTransportException(ToAbStatus(ex), ex.Message, ex); }
|
||||
catch (LibPlcTagException ex) { throw FromException(ex); }
|
||||
}
|
||||
|
||||
/// <summary>Runs a synchronous libplctag call, rethrowing <see cref="LibPlcTagException"/> as <see cref="AbTransportException"/>.</summary>
|
||||
@@ -98,6 +120,6 @@ internal static class LibplctagStatusTranslation
|
||||
public static void WrapSync(Action op)
|
||||
{
|
||||
try { op(); }
|
||||
catch (LibPlcTagException ex) { throw new AbTransportException(ToAbStatus(ex), ex.Message, ex); }
|
||||
catch (LibPlcTagException ex) { throw FromException(ex); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,8 +29,19 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
|
||||
public LibplctagTagRuntime(AbCipTagCreateParams p)
|
||||
{
|
||||
_tag = BuildTag(p);
|
||||
_boolArrayElement = !p.IsArray && p.AuthoredType == AbCipDataType.Bool
|
||||
&& NativeAbCipTagRuntime.TrySplitBoolArrayElement(p.TagName, out _, out _);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A scalar BOOL authored over a BOOL-array element (<c>Bits[n]</c>). libplctag reads it as whatever the
|
||||
/// controller returns for that element segment — a packed DWORD — and exposes no type code, so which DWORD
|
||||
/// came back (and so which bit is element n) cannot be checked here. Review 07 U11: such a read used to decode
|
||||
/// "low byte != 0" as Good; it is refused (<c>BadNotSupported</c>) when a DWORD came back. The native transport
|
||||
/// (the default) addresses the right DWORD and decodes the bit.
|
||||
/// </summary>
|
||||
private readonly bool _boolArrayElement;
|
||||
|
||||
/// <summary>Builds the (uninitialized) libplctag tag for <paramref name="p"/>.</summary>
|
||||
/// <param name="p">The tag creation parameters.</param>
|
||||
/// <returns>The new, uninitialised libplctag tag.</returns>
|
||||
@@ -120,7 +131,10 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
|
||||
{
|
||||
AbCipDataType.Bool => bitIndex is int bit
|
||||
? _tag.GetBit(bit)
|
||||
: _tag.GetInt8(offset) != 0,
|
||||
: _boolArrayElement && offset == 0 && _tag.GetSize() == 4
|
||||
? throw new AbTransportException(AbStatus.Unsupported,
|
||||
$"'{_tag.Name}' is a BOOL-array element read as a packed DWORD; libplctag cannot say which DWORD it is — use the native transport.")
|
||||
: _tag.GetInt8(offset) != 0,
|
||||
AbCipDataType.SInt => (int)(sbyte)_tag.GetInt8(offset),
|
||||
AbCipDataType.USInt => (int)_tag.GetUInt8(offset),
|
||||
AbCipDataType.Int => (int)_tag.GetInt16(offset),
|
||||
@@ -221,6 +235,23 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
|
||||
_ => 4,
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// The codec's bytes are copied into the buffer the last read sized (<c>elem_count</c> elements), so the write
|
||||
/// sends exactly the new array — never a stale tail. A buffer smaller than the encoded array is refused
|
||||
/// (<see cref="AbStatus.TooSmall"/>) before anything is changed.
|
||||
/// </remarks>
|
||||
public void EncodeArray(AbCipDataType type, IReadOnlyList<object?> values) =>
|
||||
LibplctagStatusTranslation.WrapSync(() =>
|
||||
{
|
||||
var bytes = AbCipArrayCodec.Encode(type, values);
|
||||
var size = _tag.GetSize();
|
||||
if (size < bytes.Length)
|
||||
throw new AbTransportException(AbStatus.TooSmall,
|
||||
$"Array write to '{_tag.Name}': {bytes.Length} byte(s) encoded but the tag buffer holds {size}.");
|
||||
for (var i = 0; i < bytes.Length; i++) _tag.SetUInt8(i, bytes[i]);
|
||||
});
|
||||
|
||||
/// <inheritdoc />
|
||||
public void EncodeValue(AbCipDataType type, int? bitIndex, object? value) =>
|
||||
LibplctagStatusTranslation.WrapSync(() => EncodeValueCore(type, bitIndex, value));
|
||||
|
||||
@@ -41,6 +41,34 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
|
||||
private int? _implicitBit;
|
||||
private byte[]? _path;
|
||||
|
||||
// U11: a scalar BOOL authored over a BOOL-array element "Bits[n]" — the bit (n % 32) of DWORD n / 32 when the
|
||||
// array is packed. Null for every other tag.
|
||||
private int? _boolElementBit;
|
||||
|
||||
// Both addressings of such a tag: "Bits[n / 32]" (packed: a Logix BOOL array is stored as 0xD3 DWORDs) and the
|
||||
// original "Bits[n]" (per-element: a family that answers 0xC1). _path is whichever _boolLayout selects.
|
||||
private byte[]? _dwordPath;
|
||||
private byte[]? _elementPath;
|
||||
|
||||
/// <summary>How a BOOL-array element tag's array is laid out, as the controller's REPLY TYPE last showed it.</summary>
|
||||
private enum BoolLayout
|
||||
{
|
||||
/// <summary>No reply type seen yet (or only refusals / comms faults): request DWORD-addressed, keep asking.</summary>
|
||||
Unsettled,
|
||||
|
||||
/// <summary>The controller answered 0xD3: address DWORD n / 32, decode bit n % 32.</summary>
|
||||
Packed,
|
||||
|
||||
/// <summary>The controller answered 0xC1: address element n, decode its byte.</summary>
|
||||
PerElement,
|
||||
}
|
||||
|
||||
// #737 fix round 2 (N1): ONLY a reply type settles (or re-settles) the layout — 0xD3 ⇒ Packed, 0xC1 ⇒ PerElement.
|
||||
// A refusal or comms fault says nothing about the layout and leaves it as it was, so a tag deployed before the
|
||||
// online edit that creates its array starts reading correctly once the array exists. A reply whose type disagrees
|
||||
// with the addressing it was asked under is DISCARDED (never decoded) and re-read under the right addressing.
|
||||
private BoolLayout _boolLayout;
|
||||
|
||||
private ConnectionLease? _lease;
|
||||
private RequestScheduler? _privateScheduler; // ForTest only — production always uses the lease's
|
||||
private LogixTagClient? _client;
|
||||
@@ -51,6 +79,7 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
|
||||
private AbStatus _status = AbStatus.Pending;
|
||||
|
||||
private byte[]? _writeBuffer;
|
||||
private ushort _writeElements = 1;
|
||||
private string? _pendingString;
|
||||
private bool _disposed;
|
||||
|
||||
@@ -94,16 +123,15 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
|
||||
/// <summary>The trailing bit selector split off the tag name (<c>Tag.5</c>), if any.</summary>
|
||||
internal int? ImplicitBit => _implicitBit;
|
||||
|
||||
// Performs one read to prove the tag exists: libplctag's InitializeAsync errors for an unknown
|
||||
// tag, and the driver's backoff classification depends on create throwing AbStatus.NotFound.
|
||||
// Performs one read to prove the session works and learn the tag's type.
|
||||
//
|
||||
// A name the encoder refuses throws AbCipTagConfigurationException before any lease: it is this
|
||||
// tag's configuration, not the device, and must not black out the device's other tags. A read the
|
||||
// controller answers with a deterministic refusal (AbStatus.TypeMismatch / AbStatus.Unsupported)
|
||||
// proves the tag exists, so it completes the initialization with that status rather than throwing:
|
||||
// a throw here makes the driver dispose the runtime and re-create it - and, for a sole lease
|
||||
// holder, re-Forward-Open - on every poll (mistype churn). The driver's own read reports the
|
||||
// refusal.
|
||||
// controller answers with a deterministic refusal (AbCipRefusals.IsDeviceRefusal: NotFound, OutOfBounds,
|
||||
// NotAllowed, TooLarge/TooSmall, TypeMismatch, Unsupported) proves the session works, so it completes the
|
||||
// initialization with that status rather than throwing: a throw here makes the driver dispose the runtime
|
||||
// and re-create it - and, for a sole lease holder, re-Forward-Open - on every poll (a renamed tag or a
|
||||
// mistype churned the lease). The driver's own read reports the refusal, per tag, on the kept runtime.
|
||||
/// <inheritdoc />
|
||||
public async Task InitializeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -116,7 +144,20 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
|
||||
try
|
||||
{
|
||||
split = CipPath.SplitBitSelector(_p.TagName);
|
||||
path = CipPath.EncodeSymbolic(split.Name);
|
||||
var name = split.Name;
|
||||
if (split.Bit is null && !_p.IsArray && _p.AuthoredType == AbCipDataType.Bool
|
||||
&& TrySplitBoolArrayElement(name, out var dwordName, out var bit))
|
||||
{
|
||||
_elementPath = CipPath.EncodeSymbolic(name);
|
||||
_dwordPath = CipPath.EncodeSymbolic(dwordName);
|
||||
_boolElementBit = bit;
|
||||
_boolLayout = BoolLayout.Unsettled;
|
||||
path = _dwordPath;
|
||||
}
|
||||
else
|
||||
{
|
||||
path = CipPath.EncodeSymbolic(name);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or FormatException or OverflowException)
|
||||
{
|
||||
@@ -137,20 +178,45 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
|
||||
{
|
||||
await ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (AbTransportException ex) when (ex.Status is AbStatus.TypeMismatch or AbStatus.Unsupported)
|
||||
catch (AbTransportException ex) when (AbCipRefusals.IsDeviceRefusal(ex))
|
||||
{
|
||||
// The controller answered — the tag exists. _status already holds the refusal.
|
||||
// The controller answered. _status already holds the refusal.
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task ReadAsync(CancellationToken cancellationToken)
|
||||
public Task ReadAsync(CancellationToken cancellationToken) => ReadCoreAsync(cancellationToken, mayRelayout: true);
|
||||
|
||||
private async Task ReadCoreAsync(CancellationToken cancellationToken, bool mayRelayout)
|
||||
{
|
||||
var client = Client();
|
||||
var path = Path();
|
||||
try
|
||||
{
|
||||
var (type, handle, data) = await client.ReadAsync(path, _elements, cancellationToken, ExpectedElementBytes(_p.AuthoredType)).ConfigureAwait(false);
|
||||
if (_boolElementBit is not null)
|
||||
{
|
||||
BoolLayout? shown = type == LogixTypeCode.BoolArray ? BoolLayout.Packed
|
||||
: type == LogixTypeCode.Bool ? BoolLayout.PerElement
|
||||
: null; // any other type (a mistyped tag): no layout evidence; the type check below reports it
|
||||
if (shown is { } layout)
|
||||
{
|
||||
var askedPacked = ReferenceEquals(path, _dwordPath);
|
||||
SetBoolLayout(layout);
|
||||
if ((layout == BoolLayout.Packed) != askedPacked)
|
||||
{
|
||||
// The reply is for the OTHER addressing (element n / 32 of a per-element array, or DWORD n of a
|
||||
// packed one) — not this element. Discard it and ask again under the layout it just showed.
|
||||
if (mayRelayout)
|
||||
{
|
||||
await ReadCoreAsync(cancellationToken, mayRelayout: false).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
throw new AbTransportException(AbStatus.TypeMismatch,
|
||||
$"'{_p.TagName}': the controller's BOOL-array layout changed between two reads; retrying next read.");
|
||||
}
|
||||
}
|
||||
}
|
||||
_data = data;
|
||||
_typeCode = type;
|
||||
_structHandle = handle;
|
||||
@@ -164,6 +230,13 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Settles a BOOL-array element tag's layout and selects the matching request path.</summary>
|
||||
private void SetBoolLayout(BoolLayout layout)
|
||||
{
|
||||
_boolLayout = layout;
|
||||
_path = layout == BoolLayout.PerElement ? _elementPath : _dwordPath;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Upper bound on one element's wire bytes for the scheduler's reply budget; 0 when the
|
||||
/// authored type is unknown or a structure, which sends the read alone rather than guess.
|
||||
@@ -196,6 +269,14 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
|
||||
|
||||
try
|
||||
{
|
||||
// An unsettled BOOL-array element layout is settled by a read first, so a write never goes to the
|
||||
// DWORD-addressed element of an element-addressed array (#737 I1). A packed layout's write is refused below
|
||||
// (0xD3 DWORD — it would clobber 31 neighbours); a per-element one writes "Bits[n]" as a BOOL.
|
||||
if (_boolElementBit is not null && _boolLayout == BoolLayout.Unsettled)
|
||||
{
|
||||
await ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
path = Path();
|
||||
}
|
||||
if (_pendingString is { } text)
|
||||
{
|
||||
// A STRING writes as the type the controller reported — a Logix STRING is a structure
|
||||
@@ -204,7 +285,7 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
|
||||
if (_typeCode is null || (_typeCode == LogixTypeCode.Struct && _structHandle is null))
|
||||
await ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (_typeCode == LogixTypeCode.Struct && _structHandle is null)
|
||||
throw new AbTransportException(AbStatus.Unsupported,
|
||||
throw new AbCipLocalRefusalException(
|
||||
$"STRING write to '{_p.TagName}': the controller returned no structure handle.");
|
||||
var payload = EncodeStringPayload(text);
|
||||
await client.WriteAsync(path, _typeCode!.Value, _structHandle, 1, payload, cancellationToken)
|
||||
@@ -213,7 +294,7 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
|
||||
else if (_writeBuffer is { } buffer)
|
||||
{
|
||||
var type = _typeCode ?? AtomicCode(_p.AuthoredType)
|
||||
?? throw new AbTransportException(AbStatus.Unsupported,
|
||||
?? throw new AbCipLocalRefusalException(
|
||||
$"Write to '{_p.TagName}': type unknown — no prior read and no authored atomic type.");
|
||||
// The read rule decides first: a same-width mismatch (REAL under a DINT-authored tag)
|
||||
// would otherwise send the int bit pattern as a REAL and report Good.
|
||||
@@ -223,17 +304,18 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
|
||||
// Width is checked only for codes with a known width: DATE / DATE_AND_TIME (0xCD/0xCF) and
|
||||
// Micro800 BYTE/WORD/LWORD (0xD1/0xD2/0xD4) are outside LogixTypeCode's table and write as today.
|
||||
var width = LogixTypeCode.ByteWidth(type);
|
||||
if (width > 0 && width != buffer.Length)
|
||||
if (width > 0 && width * _writeElements != buffer.Length)
|
||||
{
|
||||
// An authored BOOL over a packed BOOL-array DWORD: writing the DWORD would clobber the
|
||||
// 31 sibling bools (libplctag does exactly that), so refuse it as a config error.
|
||||
if (type == LogixTypeCode.BoolArray)
|
||||
if (type == LogixTypeCode.BoolArray && _writeElements == 1)
|
||||
throw new NotSupportedException(
|
||||
$"Write to '{_p.TagName}': a BOOL-array element (type 0xD3 DWORD) cannot be written as a single BOOL.");
|
||||
throw new AbTransportException(AbStatus.TypeMismatch,
|
||||
$"Write to '{_p.TagName}': encoded {buffer.Length} byte(s) but the controller's type 0x{type:X4} is {width} byte(s) wide.");
|
||||
$"Write to '{_p.TagName}': encoded {buffer.Length} byte(s) for {_writeElements} element(s) but the controller's type 0x{type:X4} is {width} byte(s) wide.");
|
||||
}
|
||||
await client.WriteAsync(path, type, null, 1, buffer, cancellationToken).ConfigureAwait(false);
|
||||
// U10: an array write is ONE Write Tag of _writeElements elements (the client fragments it if large).
|
||||
await client.WriteAsync(path, type, null, _writeElements, buffer, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -276,7 +358,7 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
|
||||
|
||||
return type switch
|
||||
{
|
||||
AbCipDataType.Bool => bitIndex is int bit ? GetBit(bit) : Span(offset, 1)[0] != 0,
|
||||
AbCipDataType.Bool => bitIndex is int bit ? GetBit(bit) : DecodeScalarBool(offset),
|
||||
AbCipDataType.SInt => (int)(sbyte)Span(offset, 1)[0],
|
||||
AbCipDataType.USInt => (int)Span(offset, 1)[0],
|
||||
AbCipDataType.Int => (int)BinaryPrimitives.ReadInt16LittleEndian(Span(offset, 2)),
|
||||
@@ -315,10 +397,22 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void EncodeArray(AbCipDataType type, IReadOnlyList<object?> values)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(values);
|
||||
_writeBuffer = null;
|
||||
_pendingString = null;
|
||||
var buffer = AbCipArrayCodec.Encode(type, values); // throws (nothing staged) before any state changes
|
||||
_writeElements = (ushort)Math.Clamp(values.Count, 1, ushort.MaxValue);
|
||||
_writeBuffer = buffer;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void EncodeValue(AbCipDataType type, int? bitIndex, object? value)
|
||||
{
|
||||
_writeBuffer = null;
|
||||
_writeElements = 1;
|
||||
_pendingString = null;
|
||||
switch (type)
|
||||
{
|
||||
@@ -410,6 +504,47 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
|
||||
catch (Exception) { /* ForTest only; Dispose must not throw */ }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A scalar BOOL. A packed (0xD3) reply is decoded ONLY as the element's own bit of the DWORD the packed layout
|
||||
/// addressed; any other 0xD3 reply (no element bit, or not settled packed) is refused rather than decoded as
|
||||
/// "byte 0 != 0" — that value would be some other bit, published as Good (#737 N1).
|
||||
/// </summary>
|
||||
private bool DecodeScalarBool(int offset)
|
||||
{
|
||||
if (offset == 0 && _typeCode == LogixTypeCode.BoolArray)
|
||||
{
|
||||
if (_boolElementBit is int element && _boolLayout == BoolLayout.Packed) return GetBit(element);
|
||||
throw new AbTransportException(AbStatus.TypeMismatch,
|
||||
$"'{_p.TagName}' is a scalar BOOL but the controller answered a packed BOOL-array DWORD (0xD3) with no element bit to select.");
|
||||
}
|
||||
return Span(offset, 1)[0] != 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Splits a BOOL-array element reference <c>Name[n]</c> (one non-negative subscript, last segment) into the
|
||||
/// DWORD it lives in (<c>Name[n / 32]</c>) and its bit within that DWORD (<c>n % 32</c>). A Logix BOOL array
|
||||
/// is stored as DWORDs (type 0xD3) and its element segment addresses those DWORDs.
|
||||
/// </summary>
|
||||
/// <param name="name">The tag name, bit selector already split off.</param>
|
||||
/// <param name="dwordName">The name addressing the containing DWORD.</param>
|
||||
/// <param name="bit">The element's bit within that DWORD.</param>
|
||||
/// <returns><see langword="true"/> when <paramref name="name"/> ends in a single subscript.</returns>
|
||||
internal static bool TrySplitBoolArrayElement(string name, out string dwordName, out int bit)
|
||||
{
|
||||
dwordName = name;
|
||||
bit = 0;
|
||||
if (!name.EndsWith(']')) return false;
|
||||
var open = name.LastIndexOf('[');
|
||||
if (open <= 0) return false;
|
||||
var inner = name[(open + 1)..^1];
|
||||
if (inner.Length == 0 || !inner.All(char.IsAsciiDigit)
|
||||
|| !int.TryParse(inner, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out var n))
|
||||
return false;
|
||||
dwordName = $"{name[..open]}[{n / 32}]";
|
||||
bit = n % 32;
|
||||
return true;
|
||||
}
|
||||
|
||||
private byte[] Path() =>
|
||||
_path ?? throw new InvalidOperationException("InitializeAsync has not been called.");
|
||||
|
||||
|
||||
@@ -48,6 +48,16 @@ public sealed class AbLegacyDriverOptions
|
||||
/// <summary>Gets or sets the default timeout for read/write operations.</summary>
|
||||
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
|
||||
|
||||
/// <summary>
|
||||
/// Whether scalar tags on adjacent elements of one N / B / F / L file are read as ONE multi-element PCCC read
|
||||
/// (driver-config key <c>coalesceReads</c>, default <see langword="true"/>). A block the device refuses falls
|
||||
/// back to per-tag reads on its own; set this false to read every tag singly. <see langword="null"/> (the key
|
||||
/// absent) means the default, ON - and null is never written, so a save through any serializer (the AdminUI
|
||||
/// form included) keeps a defaulted driver defaulted instead of pinning today's default (review 07 r0 M9).
|
||||
/// </summary>
|
||||
[System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)]
|
||||
public bool? CoalesceReads { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timeout for the AdminUI Test Connect probe, in seconds. The AdminUI clamps to a
|
||||
/// 60s server-side maximum; this default is what the form pre-fills for new instances.
|
||||
|
||||
+79
@@ -77,6 +77,10 @@ public static class AbLegacyTagDefinitionFactory
|
||||
if (rawLength >= 1)
|
||||
arrayLength = Math.Min(rawLength, AbLegacyArray.MaxElements);
|
||||
}
|
||||
// A deterministic refusal (review 07 H16): String / Timer / Counter / Control elements are structured
|
||||
// or variable-width and do not lay out as a flat array, so both runtimes refuse to decode them. Accepting
|
||||
// the tag only bought a handle create + a NotSupportedException on every poll.
|
||||
if (arrayLength is not null && !IsArrayElementTypeSupported(dataType)) return false;
|
||||
// "writable" defaults to true when absent (today's value) — makes read-only equipment tags
|
||||
// authorable.
|
||||
var writable = TagConfigJson.ReadWritable(root);
|
||||
@@ -173,6 +177,9 @@ public static class AbLegacyTagDefinitionFactory
|
||||
{
|
||||
if (AbLegacyAddress.Validate(accepted.Address, family) is { } addressError)
|
||||
warnings.Add($"AbLegacy tag address: {addressError} The driver reads this tag as BadNodeIdUnknown.");
|
||||
else if (accepted.ArrayLength is not null
|
||||
&& ArrayFileMismatch(accepted.DataType, accepted.Address) is { } widthError)
|
||||
warnings.Add(widthError);
|
||||
return warnings;
|
||||
}
|
||||
|
||||
@@ -224,6 +231,78 @@ public static class AbLegacyTagDefinitionFactory
|
||||
}
|
||||
|
||||
if (TagConfigJson.DescribeInvalidEnum<AbLegacyDataType>(root, "dataType") is { } w) warnings.Add(w);
|
||||
|
||||
if (IsArrayFlag(root) && ReadInt(root, "arrayLength") >= 1
|
||||
&& TagConfigJson.TryReadEnumStrict(root, "dataType", AbLegacyDataType.Int, out var dt)
|
||||
&& !IsArrayElementTypeSupported(dt))
|
||||
warnings.Add(ArrayElementTypeError(dt));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether <paramref name="type"/> can be the element type of an array tag. Only the flat word/long/float/bit
|
||||
/// files lay out as a contiguous array; String, Timer, Counter and Control elements are structured.
|
||||
/// </summary>
|
||||
/// <param name="type">The authored element data type.</param>
|
||||
/// <returns><see langword="true"/> for Bit, Int, AnalogInt, Long and Float.</returns>
|
||||
public static bool IsArrayElementTypeSupported(AbLegacyDataType type) => type is
|
||||
AbLegacyDataType.Bit or AbLegacyDataType.Int or AbLegacyDataType.AnalogInt
|
||||
or AbLegacyDataType.Long or AbLegacyDataType.Float;
|
||||
|
||||
/// <summary>The authoring / deploy-gate message for an array of an unsupported element type.</summary>
|
||||
/// <param name="type">The authored element data type.</param>
|
||||
/// <returns>The message.</returns>
|
||||
public static string ArrayElementTypeError(AbLegacyDataType type) =>
|
||||
$"AbLegacy {type} is not supported as an array element (String, Timer, Counter and Control elements are "
|
||||
+ "structured); author a scalar tag per element instead. The driver skips this tag and it reads BadNodeIdUnknown.";
|
||||
|
||||
/// <summary>
|
||||
/// The element width an array on <paramref name="address"/>'s data file has, as the data type that decodes /
|
||||
/// encodes it: N, B, A and the I/O/S word files are 16-bit words (<see cref="AbLegacyDataType.Int"/>), L is
|
||||
/// 32-bit (<see cref="AbLegacyDataType.Long"/>), F is IEEE float (<see cref="AbLegacyDataType.Float"/>).
|
||||
/// <see langword="null"/> for a file with no flat array layout (ST, T, C, R, function files) or an address that
|
||||
/// does not parse.
|
||||
/// </summary>
|
||||
/// <param name="address">The authored address.</param>
|
||||
/// <returns>The file's element type, or <see langword="null"/>.</returns>
|
||||
public static AbLegacyDataType? ArrayFileElementType(string address) =>
|
||||
AbLegacyAddress.TryParse(address) is { } a ? ArrayFileElementType(a) : null;
|
||||
|
||||
/// <inheritdoc cref="ArrayFileElementType(string)"/>
|
||||
public static AbLegacyDataType? ArrayFileElementType(AbLegacyAddress a) => a.FileLetter switch
|
||||
{
|
||||
"N" or "B" or "A" or "I" or "O" or "S" => AbLegacyDataType.Int,
|
||||
"L" => AbLegacyDataType.Long,
|
||||
"F" => AbLegacyDataType.Float,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Why an ARRAY authored as <paramref name="type"/> on <paramref name="address"/> does not match its data
|
||||
/// file's element width (review 07 r0 I3), or <see langword="null"/> when it does. Int / AnalogInt need a 16-bit
|
||||
/// word file (N/B/A/I/O/S), Long an L file, Float an F file; Bit arrays are bit-packed words and fit any 16-bit
|
||||
/// word file. A mismatch reads the file at the wrong width (garbage) and the driver refuses to WRITE it
|
||||
/// (<c>BadTypeMismatch</c>), because encoding at the authored width would corrupt the neighbouring elements.
|
||||
/// </summary>
|
||||
/// <param name="type">The authored element data type.</param>
|
||||
/// <param name="address">The authored address.</param>
|
||||
/// <returns>The mismatch message, or <see langword="null"/>.</returns>
|
||||
public static string? ArrayFileMismatch(AbLegacyDataType type, string address)
|
||||
{
|
||||
if (!IsArrayElementTypeSupported(type)) return null; // structured arrays have their own refusal
|
||||
// The file LETTER is static even for an indirect address (N7:[N7:0], N[N7:0]:5) — only the numbers move —
|
||||
// so the width rule applies to it too (review 07 r1 N5).
|
||||
if (AbLegacyAddress.TryParse(address) is not { } a) return null;
|
||||
var file = ArrayFileElementType(a);
|
||||
var ok = type switch
|
||||
{
|
||||
AbLegacyDataType.Int or AbLegacyDataType.AnalogInt or AbLegacyDataType.Bit => file == AbLegacyDataType.Int,
|
||||
_ => file == type,
|
||||
};
|
||||
return ok
|
||||
? null
|
||||
: $"AbLegacy {type} array on '{address}' does not match the {a.FileLetter} file's element width "
|
||||
+ "(Int/AnalogInt/Bit need an N/B/A word file, Long an L file, Float an F file): reads decode the wrong width "
|
||||
+ "and writes are refused BadTypeMismatch.";
|
||||
}
|
||||
|
||||
private static int ReadInt(JsonElement o, string name)
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
using System.Collections;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
/// <summary>
|
||||
/// Converts an OPC UA array-write value into the element-typed CLR array a runtime's
|
||||
/// <see cref="IAbLegacyTagRuntime.EncodeArray"/> takes. Pure: it runs BEFORE any runtime is created, so a refused
|
||||
/// write never costs a wire round trip and never touches driver health.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Elements are converted, not exact-matched.</b> ScadaBridge writes CLR lists such as <c>int[]</c> to
|
||||
/// Int16 nodes, and an OPC UA client may send any numeric array, so each element goes through the same checked
|
||||
/// <see cref="Convert"/> the scalar path uses. A value that does not fit is <c>BadOutOfRange</c>; one that is not
|
||||
/// a number at all is <c>BadTypeMismatch</c>.</para>
|
||||
/// <para><b>The length must equal the tag's element count</b> (<c>BadOutOfRange</c> otherwise): a shorter write
|
||||
/// would have to fill the tail from the last read buffer, which is the stale-elements defect this replaces.</para>
|
||||
/// <para><b>Bit arrays are refused</b> (<c>BadNotSupported</c>): a PCCC bit array is bit-packed into words, so
|
||||
/// encoding it rewrites whole words and would clobber neighbouring bits the PLC owns. String / Timer / Counter /
|
||||
/// Control arrays never reach here — the mapper rejects them.</para>
|
||||
/// </remarks>
|
||||
internal static class AbLegacyArrayWriteEncoder
|
||||
{
|
||||
/// <summary>Converts <paramref name="value"/> to a typed array of exactly <paramref name="count"/> elements.</summary>
|
||||
/// <param name="type">The tag's element data type.</param>
|
||||
/// <param name="value">The value the client wrote.</param>
|
||||
/// <param name="count">The tag's element count.</param>
|
||||
/// <param name="typed">The converted array when the result is Good.</param>
|
||||
/// <returns><see cref="AbLegacyStatusMapper.Good"/>, or the Bad status the write is answered with.</returns>
|
||||
public static uint TryConvert(AbLegacyDataType type, object? value, int count, out Array? typed)
|
||||
{
|
||||
typed = null;
|
||||
if (type is AbLegacyDataType.Bit) return AbLegacyStatusMapper.BadNotSupported;
|
||||
if (type is not (AbLegacyDataType.Int or AbLegacyDataType.AnalogInt or AbLegacyDataType.Long or AbLegacyDataType.Float))
|
||||
return AbLegacyStatusMapper.BadNotSupported;
|
||||
if (value is null or string || value is not IEnumerable items) return AbLegacyStatusMapper.BadTypeMismatch;
|
||||
|
||||
var elements = items.Cast<object?>().ToList();
|
||||
if (elements.Count != count) return AbLegacyStatusMapper.BadOutOfRange;
|
||||
try
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case AbLegacyDataType.Long:
|
||||
{
|
||||
var a = new int[count];
|
||||
for (var i = 0; i < count; i++) a[i] = Convert.ToInt32(Element(elements[i]));
|
||||
typed = a;
|
||||
break;
|
||||
}
|
||||
case AbLegacyDataType.Float:
|
||||
{
|
||||
var a = new float[count];
|
||||
for (var i = 0; i < count; i++) a[i] = Convert.ToSingle(Element(elements[i]));
|
||||
typed = a;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
{
|
||||
var a = new short[count];
|
||||
for (var i = 0; i < count; i++) a[i] = Convert.ToInt16(Element(elements[i]));
|
||||
typed = a;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return AbLegacyStatusMapper.Good;
|
||||
}
|
||||
catch (OverflowException) { return AbLegacyStatusMapper.BadOutOfRange; }
|
||||
catch (Exception ex) when (ex is FormatException or InvalidCastException) { return AbLegacyStatusMapper.BadTypeMismatch; }
|
||||
}
|
||||
|
||||
// A string element is not a number: Convert.ToInt16("12") would PARSE it, which no OPC UA numeric array carries.
|
||||
private static object Element(object? e) => e switch
|
||||
{
|
||||
null => throw new InvalidCastException("null array element"),
|
||||
string => throw new InvalidCastException("string array element"),
|
||||
bool => throw new InvalidCastException("bool array element"),
|
||||
_ => e,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
/// <summary>
|
||||
/// Adjacent-element read coalescing (review 07 P7): scalar tags on the same N / B / F / L data file whose
|
||||
/// elements are close together are read as ONE multi-element PCCC read (the same path array tags already use),
|
||||
/// then each tag is decoded from its offset in the block. One round trip instead of one per tag.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Only shapes the single-tag path decodes identically are coalesced</b>: a word tag
|
||||
/// (<c>Int</c>/<c>AnalogInt</c>) or a bit of a word (<c>Bit</c>, with or without <c>/n</c>) on an N or B file; a
|
||||
/// <c>Float</c> on an F file; a <c>Long</c> or a bit of a long on an L file. Indirect addresses, sub-elements
|
||||
/// (<c>T4:0.ACC</c>), arrays, the file-number-less system files (<c>S:</c>, <c>I:</c>, <c>O:</c>) and any tag
|
||||
/// authored with a type that does not match its file's element width keep the single-tag path, so a
|
||||
/// mis-authored tag still gets exactly the answer it got before.</para>
|
||||
/// <para><b>The span is safe to read.</b> A block runs from its lowest to its highest member, so every element
|
||||
/// it reads lies between two authored elements of the same file — inside the file whenever the authored tags
|
||||
/// are. A device REFUSAL of a block (an authored element past the end of the file, a PLC-5 native non-N file)
|
||||
/// — or ANY other status the device returned, incl. libplctag's remote error — falls back to single reads for that
|
||||
/// block's members in the same pass, so one bad tag never blackens its neighbours; a shape refused twice in a row
|
||||
/// is remembered so it is not retried every poll. Only a NO-REPLY failure fails the members together — they
|
||||
/// share the connection — exactly as their single reads would have.</para>
|
||||
/// <para>Turned off with the driver-config key <c>coalesceReads: false</c>.</para>
|
||||
/// </remarks>
|
||||
public sealed partial class AbLegacyDriver
|
||||
{
|
||||
/// <summary>Largest block, in data bytes. Below the 236-byte PCCC typed-read limit, so libplctag never needs
|
||||
/// to fragment a coalesced read (the native runtime would chunk it anyway).</summary>
|
||||
internal const int MaxCoalescedBytes = 200;
|
||||
|
||||
/// <summary>Largest run of UNAUTHORED elements a block may read across to join two members.</summary>
|
||||
internal const int MaxCoalesceGap = 8;
|
||||
|
||||
/// <summary>One unit of a device pass: a single tag, or a coalesced block of several.</summary>
|
||||
private sealed record ReadUnit(ReadItem? Single, CoalescedBlock? Block);
|
||||
|
||||
/// <summary>A coalesced block: <paramref name="Count"/> elements of one file from <paramref name="Start"/>.</summary>
|
||||
private sealed record CoalescedBlock(
|
||||
string FileLetter, int FileNumber, int Start, int Count, AbLegacyDataType ElementType, List<ReadItem> Members)
|
||||
{
|
||||
/// <summary>The runtime-cache key; distinct from any RawPath (RawPaths never contain '|').</summary>
|
||||
public string Key => $"|block|{FileLetter}{FileNumber}:{Start}+{Count}";
|
||||
|
||||
/// <summary>The block's base address in libplctag form.</summary>
|
||||
public string StartName => $"{FileLetter}{FileNumber}:{Start}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The element type a scalar tag is decoded from when it rides a coalesced block, or <see langword="null"/>
|
||||
/// when the tag keeps the single-tag path.
|
||||
/// </summary>
|
||||
private static AbLegacyDataType? CoalescedElementType(ReadItem item)
|
||||
{
|
||||
var a = item.Parsed;
|
||||
if (a.IsIndirect || a.SubElement is not null || a.FileNumber is null || IsArrayTag(item.Def)) return null;
|
||||
return (a.FileLetter, item.Def.DataType) switch
|
||||
{
|
||||
("N" or "B", AbLegacyDataType.Int or AbLegacyDataType.AnalogInt or AbLegacyDataType.Bit) => AbLegacyDataType.Int,
|
||||
("F", AbLegacyDataType.Float) => AbLegacyDataType.Float,
|
||||
("L", AbLegacyDataType.Long) => AbLegacyDataType.Long,
|
||||
("L", AbLegacyDataType.Bit) when a.BitIndex is not null => AbLegacyDataType.Long,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Plans a device pass: coalescable tags grouped per file into runs of nearby elements (a run of two or more
|
||||
/// tags becomes a block), everything else a single unit. Block shapes the device refused before stay single.
|
||||
/// </summary>
|
||||
private List<ReadUnit> PlanReadUnits(DeviceState device, List<ReadItem> items)
|
||||
{
|
||||
if (_options.CoalesceReads == false || items.Count < 2) return [.. items.Select(i => new ReadUnit(i, null))];
|
||||
|
||||
var units = new List<ReadUnit>(items.Count);
|
||||
var groups = new Dictionary<(string Letter, int File, AbLegacyDataType Type), List<ReadItem>>();
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (CoalescedElementType(item) is not { } type)
|
||||
{
|
||||
units.Add(new ReadUnit(item, null));
|
||||
continue;
|
||||
}
|
||||
var key = (item.Parsed.FileLetter, item.Parsed.FileNumber!.Value, type);
|
||||
if (!groups.TryGetValue(key, out var list)) groups[key] = list = [];
|
||||
list.Add(item);
|
||||
}
|
||||
|
||||
foreach (var ((letter, file, type), members) in groups)
|
||||
{
|
||||
var width = type == AbLegacyDataType.Int ? 2 : 4;
|
||||
var maxElements = MaxCoalescedBytes / width;
|
||||
var ordered = members.OrderBy(m => m.Parsed.WordNumber).ToList();
|
||||
var run = new List<ReadItem> { ordered[0] };
|
||||
for (var i = 1; i <= ordered.Count; i++)
|
||||
{
|
||||
var next = i < ordered.Count ? ordered[i] : null;
|
||||
var start = run[0].Parsed.WordNumber;
|
||||
if (next is not null
|
||||
&& next.Parsed.WordNumber - run[^1].Parsed.WordNumber <= MaxCoalesceGap + 1
|
||||
&& next.Parsed.WordNumber - start + 1 <= maxElements)
|
||||
{
|
||||
run.Add(next);
|
||||
continue;
|
||||
}
|
||||
var count = run[^1].Parsed.WordNumber - start + 1;
|
||||
var block = new CoalescedBlock(letter, file, start, count, type, run);
|
||||
if (run.Count < 2 || device.RefusedBlocks.ContainsKey(block.Key))
|
||||
units.AddRange(run.Select(r => new ReadUnit(r, null)));
|
||||
else
|
||||
units.Add(new ReadUnit(null, block));
|
||||
if (next is not null) run = [next];
|
||||
}
|
||||
}
|
||||
return units;
|
||||
}
|
||||
|
||||
/// <summary>What a block read concluded; <see cref="Fallback"/> = read the members singly instead.</summary>
|
||||
private enum BlockOutcome { Done, Fallback }
|
||||
|
||||
/// <summary>
|
||||
/// Reads one coalesced block and decodes each member from its offset. A device refusal or a local refusal
|
||||
/// returns <see cref="BlockOutcome.Fallback"/> (and remembers the shape) without writing any member's result.
|
||||
/// </summary>
|
||||
private async Task<(BlockOutcome Outcome, OpVerdict Verdict, string? Message)> ReadBlockAsync(
|
||||
DeviceState device, CoalescedBlock block, DataValueSnapshot[] results, DateTime now, CancellationToken ct)
|
||||
{
|
||||
void FailAll(uint status)
|
||||
{
|
||||
foreach (var m in block.Members) results[m.Index] = new DataValueSnapshot(null, status, null, now);
|
||||
}
|
||||
|
||||
(BlockOutcome, OpVerdict, string?) Refused(string why)
|
||||
{
|
||||
// The handle is never kept for a refused shape (review 07 r0 M5): it may never be planned again.
|
||||
EvictRuntime(device, block.Key);
|
||||
// Every refusal falls back to per-member reads THIS pass, so each member gets its own answer and a bad
|
||||
// member can never black out its neighbours. The shape is only REMEMBERED after a repeat — one refusal can
|
||||
// be transient (program mode, a download in progress), two in a row are deterministic.
|
||||
var refusals = device.BlockRefusals.AddOrUpdate(block.Key, 1, (_, n) => n + 1);
|
||||
if (refusals >= 2 && device.RefusedBlocks.TryAdd(block.Key, 0))
|
||||
{
|
||||
device.BlockRefusals.TryRemove(block.Key, out _);
|
||||
_logger.LogInformation(
|
||||
"AbLegacy coalesced read {Block} refused twice ({Reason}); its {Count} tags read singly from now on. Driver={DriverInstanceId} Device={DeviceHostAddress}",
|
||||
block.StartName + "+" + block.Count, why, block.Members.Count, _driverInstanceId, device.Options.HostAddress);
|
||||
}
|
||||
return (BlockOutcome.Fallback, OpVerdict.Neutral, null);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var createParams = new AbLegacyTagCreateParams(
|
||||
Gateway: device.ParsedAddress.Gateway,
|
||||
Port: device.ParsedAddress.Port,
|
||||
CipPath: device.EffectiveCipPath,
|
||||
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
|
||||
TagName: block.StartName,
|
||||
Timeout: _options.Timeout,
|
||||
ElementCount: block.Count);
|
||||
var runtime = await CreateSingleFlightAsync(
|
||||
device, device.Runtimes, block.Key, () => _tagFactory.Create(createParams), ct).ConfigureAwait(false);
|
||||
|
||||
int status;
|
||||
Array? data = null;
|
||||
var opLock = device.GetRuntimeLock(block.Key);
|
||||
await opLock.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await runtime.ReadAsync(ct).ConfigureAwait(false);
|
||||
status = runtime.GetStatus();
|
||||
if (status == 0) data = runtime.DecodeArray(block.ElementType, block.Count) as Array;
|
||||
}
|
||||
finally
|
||||
{
|
||||
opLock.Release();
|
||||
}
|
||||
|
||||
if (status != 0)
|
||||
{
|
||||
// ANY status the device returned (a refusal, a remote error, an unmapped reply, Unsupported) falls back
|
||||
// to per-member reads; only a no-reply status fails the block's members together (review 07 r0 I1).
|
||||
if (!IsNoReply((AbStatus)status))
|
||||
return Refused($"device status {(AbStatus)status}");
|
||||
EvictRuntime(device, block.Key);
|
||||
FailAll(AbLegacyStatusMapper.MapStatus(status));
|
||||
return (BlockOutcome.Done, OpVerdict.Fault, $"transport status {status} reading {block.StartName}+{block.Count}");
|
||||
}
|
||||
if (data is null || data.Length < block.Count)
|
||||
return Refused("the block decoded short");
|
||||
|
||||
foreach (var m in block.Members)
|
||||
results[m.Index] = new DataValueSnapshot(DecodeMember(block, m, data), AbLegacyStatusMapper.Good, now, now);
|
||||
device.BlockRefusals.TryRemove(block.Key, out _);
|
||||
device.FirstNonZeroStatusLogged = false;
|
||||
return (BlockOutcome.Done, OpVerdict.Answered, null);
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (ConnectionThrottledException tex)
|
||||
{
|
||||
FailAll(AbLegacyStatusMapper.BadCommunicationError);
|
||||
return (BlockOutcome.Done, OpVerdict.Throttled, tex.Message);
|
||||
}
|
||||
catch (AbTransportException ex) when (!IsNoReply(ex.Status))
|
||||
{
|
||||
return Refused(ex.Message);
|
||||
}
|
||||
catch (NotSupportedException nse)
|
||||
{
|
||||
return Refused(nse.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
EvictRuntime(device, block.Key);
|
||||
FailAll(AbLegacyStatusMapper.BadCommunicationError);
|
||||
return (BlockOutcome.Done, OpVerdict.Fault, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decodes one member from the block, returning exactly the CLR type and value the single-tag path's
|
||||
/// <see cref="IAbLegacyTagRuntime.DecodeValue"/> would (<c>int</c> for a word or long, <c>float</c>, <c>bool</c>).
|
||||
/// </summary>
|
||||
private static object DecodeMember(CoalescedBlock block, ReadItem m, Array data)
|
||||
{
|
||||
var offset = m.Parsed.WordNumber - block.Start;
|
||||
var bit = m.Parsed.BitIndex;
|
||||
switch (data)
|
||||
{
|
||||
case short[] words:
|
||||
{
|
||||
var w = words[offset];
|
||||
return m.Def.DataType == AbLegacyDataType.Bit
|
||||
? ((ushort)w >> (bit ?? 0) & 1) != 0
|
||||
: (int)w;
|
||||
}
|
||||
case int[] longs:
|
||||
{
|
||||
var l = longs[offset];
|
||||
return m.Def.DataType == AbLegacyDataType.Bit ? ((uint)l >> bit!.Value & 1) != 0 : l;
|
||||
}
|
||||
case float[] floats:
|
||||
return floats[offset];
|
||||
default:
|
||||
throw new InvalidOperationException($"Unexpected block buffer {data.GetType().Name}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -97,6 +97,7 @@ public static class AbLegacyDriverFactoryExtensions
|
||||
ProbeAddress = dto.Probe?.ProbeAddress ?? "S:0",
|
||||
},
|
||||
Timeout = PositiveTimeoutOrDefault(dto.TimeoutMs, 2_000),
|
||||
CoalesceReads = dto.CoalesceReads,
|
||||
// Absent ⇒ null ("use the default" = Libplctag, resolved by AbLegacyTransportResolver);
|
||||
// a bad value still fails exactly as any other bad enum field does.
|
||||
Transport = string.IsNullOrWhiteSpace(dto.Transport)
|
||||
@@ -157,6 +158,9 @@ public static class AbLegacyDriverFactoryExtensions
|
||||
/// </summary>
|
||||
public string? Transport { get; init; }
|
||||
|
||||
/// <summary>Gets or sets whether adjacent scalar reads are coalesced; absent means <see langword="true"/>.</summary>
|
||||
public bool? CoalesceReads { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the list of devices to connect to.
|
||||
/// </summary>
|
||||
|
||||
@@ -55,6 +55,10 @@ public sealed class AbLegacyDriverProbe : IDriverProbe
|
||||
catch (Exception ex) { return new(false, $"Config JSON is invalid: {ex.Message}", null); }
|
||||
if (opts is null) return new(false, "Config JSON deserialized to null.", null);
|
||||
|
||||
// The authored "Admin UI probe timeout" (probeTimeoutSeconds) tightens the caller's budget. It was parsed by
|
||||
// nothing before: Test Connect always ran on the caller's budget alone.
|
||||
timeout = EffectiveTimeout(timeout, opts.ProbeTimeoutSeconds);
|
||||
|
||||
var (parsed, firstDevice) = ExtractTarget(opts);
|
||||
if (parsed is null || string.IsNullOrWhiteSpace(parsed.Gateway) || parsed.Port <= 0)
|
||||
return new(false, "Config has no host/port to probe.", null);
|
||||
@@ -169,6 +173,22 @@ public sealed class AbLegacyDriverProbe : IDriverProbe
|
||||
/// <returns>Whether the status proves the controller answered.</returns>
|
||||
internal static bool IsReachableStatus(AbStatus s) => s is AbStatus.Ok or AbStatus.NotFound;
|
||||
|
||||
/// <summary>
|
||||
/// The deadline the probe runs under: the caller's budget, tightened by the config's
|
||||
/// <see cref="AbLegacyDriverOptions.ProbeTimeoutSeconds"/> when that is positive and shorter (same rule as the
|
||||
/// Galaxy probe). A non-positive caller budget is floored to 1 s first.
|
||||
/// </summary>
|
||||
/// <param name="callerTimeout">The budget the host handed the probe.</param>
|
||||
/// <param name="configuredSeconds">The config's <c>probeTimeoutSeconds</c>; ignored when not positive.</param>
|
||||
/// <returns>The effective probe deadline.</returns>
|
||||
internal static TimeSpan EffectiveTimeout(TimeSpan callerTimeout, int configuredSeconds)
|
||||
{
|
||||
var caller = callerTimeout > TimeSpan.Zero ? callerTimeout : TimeSpan.FromSeconds(1);
|
||||
if (configuredSeconds <= 0) return caller;
|
||||
var configured = TimeSpan.FromSeconds(configuredSeconds);
|
||||
return configured < caller ? configured : caller;
|
||||
}
|
||||
|
||||
private static (AbLegacyHostAddress? parsed, AbLegacyDeviceOptions? device) ExtractTarget(AbLegacyDriverOptions opts)
|
||||
{
|
||||
// Parse the first device's ab:// host address to extract the gateway IP + EIP port.
|
||||
|
||||
@@ -50,6 +50,20 @@ public interface IAbLegacyTagRuntime : IDisposable
|
||||
/// <param name="bitIndex">Optional bit index for bit-level access.</param>
|
||||
/// <param name="value">The value to encode.</param>
|
||||
void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value);
|
||||
|
||||
/// <summary>
|
||||
/// Encodes EVERY element of a multi-element tag for the next <see cref="WriteAsync"/>, at the element width
|
||||
/// of <paramref name="type"/>. <paramref name="values"/> is already element-typed by the driver
|
||||
/// (<c>short[]</c> for Int/AnalogInt, <c>int[]</c> for Long, <c>float[]</c> for Float) and its length must
|
||||
/// equal the runtime's element count (<see cref="AbLegacyTagCreateParams.ElementCount"/>), so no element of
|
||||
/// the write comes from a stale read buffer. Bit and structured element arrays are not encodable.
|
||||
/// </summary>
|
||||
/// <param name="type">The PCCC element data type.</param>
|
||||
/// <param name="values">The typed elements, exactly element-count long.</param>
|
||||
/// <exception cref="ArgumentException">The length differs from the element count, or the array's element type
|
||||
/// does not match <paramref name="type"/>.</exception>
|
||||
/// <exception cref="NotSupportedException"><paramref name="type"/> has no flat array encoding.</exception>
|
||||
void EncodeArray(AbLegacyDataType type, Array values);
|
||||
}
|
||||
|
||||
public interface IAbLegacyTagFactory
|
||||
|
||||
@@ -23,6 +23,15 @@ internal static class LibplctagLegacyStatusTranslation
|
||||
Status.ErrorPartial => AbStatus.Partial,
|
||||
Status.ErrorAbort => AbStatus.Abort,
|
||||
Status.ErrorBadReply => AbStatus.BadReply,
|
||||
// A reply ARRIVED but carries an error libplctag does not break out: it folds EVERY non-zero PCCC STS and CIP
|
||||
// general status into PLCTAG_ERR_REMOTE_ERR / ERR_BAD_STATUS. That covers the PLC refusing (element past the
|
||||
// end of the file) AND a routing hop reporting the end device is gone (CIP 0x01/0x0204 unconnected send timed
|
||||
// out, 0x07 connection lost, PCCC STS 0x30 remote node missing). So it is neither a no-reply comms fault
|
||||
// (review 07 r0 I1 — it must not fall into the BadConnection default, which black-outs coalesced neighbours and
|
||||
// trips fail-fast) nor proof of reachability (review 07 r1 N1). AbStatus has no dedicated "opaque remote error"
|
||||
// value and Driver.Ab.Transport is Lane K's, so this maps to the existing BadReply — "a reply we cannot
|
||||
// classify" — which the driver treats as OPAQUE (AbLegacyDriver.IsOpaque). OPC UA: BadCommunicationError.
|
||||
Status.ErrorRemoteErr or Status.ErrorBadStatus => AbStatus.BadReply,
|
||||
_ => AbStatus.BadConnection,
|
||||
};
|
||||
|
||||
|
||||
@@ -15,6 +15,9 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
|
||||
{
|
||||
private readonly Tag _tag;
|
||||
|
||||
/// <summary>The element count the tag was created with (1 for a scalar).</summary>
|
||||
private readonly int _count;
|
||||
|
||||
/// <summary>Why a write to this tag is refused before the wire (<see cref="WriteRefusal"/>), else null.</summary>
|
||||
private readonly string? _writeRefusal;
|
||||
|
||||
@@ -32,6 +35,7 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
|
||||
public LibplctagLegacyTagRuntime(AbLegacyTagCreateParams p)
|
||||
{
|
||||
_tag = BuildTag(p);
|
||||
_count = Math.Max(1, p.ElementCount);
|
||||
_writeRefusal = WriteRefusal(p);
|
||||
}
|
||||
|
||||
@@ -244,6 +248,32 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void EncodeArray(AbLegacyDataType type, Array values)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(values);
|
||||
if (values.Length != _count)
|
||||
throw new ArgumentException($"Array write of {values.Length} elements to a {_count}-element tag.", nameof(values));
|
||||
// Same offsets DecodeArray reads. EVERY element is set, so the buffer libplctag sends holds nothing from the
|
||||
// last read (a scalar Set*(0, ...) used to re-send elements 1..n-1 as they were last read).
|
||||
switch (type, values)
|
||||
{
|
||||
case (AbLegacyDataType.Int or AbLegacyDataType.AnalogInt, short[] s):
|
||||
for (var i = 0; i < s.Length; i++) _tag.SetInt16(i * 2, s[i]);
|
||||
break;
|
||||
case (AbLegacyDataType.Long, int[] l):
|
||||
for (var i = 0; i < l.Length; i++) _tag.SetInt32(i * 4, l[i]);
|
||||
break;
|
||||
case (AbLegacyDataType.Float, float[] f):
|
||||
for (var i = 0; i < f.Length; i++) _tag.SetFloat32(i * 4, f[i]);
|
||||
break;
|
||||
case (AbLegacyDataType.Int or AbLegacyDataType.AnalogInt or AbLegacyDataType.Long or AbLegacyDataType.Float, _):
|
||||
throw new ArgumentException($"{values.GetType().Name} is not the element type of a {type} array.", nameof(values));
|
||||
default:
|
||||
throw new NotSupportedException($"AbLegacyDataType {type} has no array write encoding.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Disposes the tag and releases its resources.</summary>
|
||||
public void Dispose() => _tag.Dispose();
|
||||
|
||||
|
||||
@@ -448,6 +448,32 @@ internal sealed class NativeAbLegacyTagRuntime : IAbLegacyTagRuntime
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void EncodeArray(AbLegacyDataType type, Array values)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(values);
|
||||
if (values.Length != _count)
|
||||
throw new ArgumentException($"Array write of {values.Length} elements to a {_count}-element tag.", nameof(values));
|
||||
// Same layout as DecodeArray: element i at i * elementSize. Every element is overwritten, so nothing the
|
||||
// write sends comes from the last read.
|
||||
switch (type, values)
|
||||
{
|
||||
case (AbLegacyDataType.Int or AbLegacyDataType.AnalogInt, short[] s):
|
||||
for (var i = 0; i < s.Length; i++) BinaryPrimitives.WriteInt16LittleEndian(SliceW(i * 2, 2), s[i]);
|
||||
break;
|
||||
case (AbLegacyDataType.Long, int[] l):
|
||||
for (var i = 0; i < l.Length; i++) BinaryPrimitives.WriteInt32LittleEndian(SliceW(i * 4, 4), l[i]);
|
||||
break;
|
||||
case (AbLegacyDataType.Float, float[] f):
|
||||
for (var i = 0; i < f.Length; i++) BinaryPrimitives.WriteSingleLittleEndian(SliceW(i * 4, 4), f[i]);
|
||||
break;
|
||||
case (AbLegacyDataType.Int or AbLegacyDataType.AnalogInt or AbLegacyDataType.Long or AbLegacyDataType.Float, _):
|
||||
throw new ArgumentException($"{values.GetType().Name} is not the element type of a {type} array.", nameof(values));
|
||||
default:
|
||||
throw new NotSupportedException($"AbLegacyDataType {type} has no array write encoding.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases the lease on the shared device connection (the last release closes it) without waiting:
|
||||
/// closing does I/O (Forward Close / UnRegisterSession) and the driver disposes under a per-tag lock.
|
||||
|
||||
+56
-14
@@ -38,13 +38,16 @@ public static class CalculationDriverFactoryExtensions
|
||||
/// <param name="registry">The driver factory registry to register with.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory for per-instance diagnostics loggers.</param>
|
||||
/// <param name="scriptRoot">Optional root script logger (fans failure entries onto the <c>script-logs</c> topic).</param>
|
||||
/// <param name="scripting">The node-wide <c>Scripting</c> options every Calculation driver inherits; a key
|
||||
/// authored in a driver's own config overrides that one knob. Null ⇒ the compiled defaults.</param>
|
||||
public static void Register(
|
||||
DriverFactoryRegistry registry,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
ScriptRootLogger? scriptRoot = null)
|
||||
ScriptRootLogger? scriptRoot = null,
|
||||
ScriptingOptions? scripting = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory, scriptRoot));
|
||||
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory, scriptRoot, scripting));
|
||||
}
|
||||
|
||||
/// <summary>Public overload for the Server-side bootstrapper + test consumers.</summary>
|
||||
@@ -63,6 +66,18 @@ public static class CalculationDriverFactoryExtensions
|
||||
public static CalculationDriver CreateInstance(
|
||||
string driverInstanceId, string driverConfigJson,
|
||||
ILoggerFactory? loggerFactory, ScriptRootLogger? scriptRoot)
|
||||
=> CreateInstance(driverInstanceId, driverConfigJson, loggerFactory, scriptRoot, nodeScripting: null);
|
||||
|
||||
/// <summary>The full overload: also takes the node-wide <c>Scripting</c> options the driver inherits.</summary>
|
||||
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
|
||||
/// <param name="driverConfigJson">The merged driver config JSON (carries the <c>RawTags</c> array).</param>
|
||||
/// <param name="loggerFactory">Optional logger factory for per-instance diagnostics loggers.</param>
|
||||
/// <param name="scriptRoot">Optional root script logger for failure fan-out.</param>
|
||||
/// <param name="nodeScripting">The node-wide run-guard options; null ⇒ the compiled defaults.</param>
|
||||
/// <returns>The constructed <see cref="CalculationDriver"/>.</returns>
|
||||
public static CalculationDriver CreateInstance(
|
||||
string driverInstanceId, string driverConfigJson,
|
||||
ILoggerFactory? loggerFactory, ScriptRootLogger? scriptRoot, ScriptingOptions? nodeScripting)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
||||
@@ -71,15 +86,14 @@ public static class CalculationDriverFactoryExtensions
|
||||
?? throw new InvalidOperationException(
|
||||
$"Calculation driver config for '{driverInstanceId}' deserialised to null");
|
||||
|
||||
// Run-guard knobs are optional and default to the node-wide ScriptingOptions defaults; only an
|
||||
// explicitly authored key overrides one (so a config carrying neither binds Scripting = null).
|
||||
ScriptingOptions? scripting = null;
|
||||
if (dto.MaxAllocBytesPerRun is not null || dto.QuarantineAfterConsecutiveTimeouts is not null)
|
||||
{
|
||||
scripting = new ScriptingOptions();
|
||||
if (dto.MaxAllocBytesPerRun is { } alloc) scripting.MaxAllocBytesPerRun = alloc;
|
||||
if (dto.QuarantineAfterConsecutiveTimeouts is { } n) scripting.QuarantineAfterConsecutiveTimeouts = n;
|
||||
}
|
||||
var scripting = ResolveScripting(
|
||||
dto.MaxAllocBytesPerRun, dto.QuarantineAfterConsecutiveTimeouts, nodeScripting, out var adjustments);
|
||||
var logger = loggerFactory?.CreateLogger<CalculationDriver>();
|
||||
// An authored value is never changed silently: the run guard would clamp it anyway, without a word.
|
||||
foreach (var adjustment in adjustments)
|
||||
logger?.LogWarning(
|
||||
"Calculation driver {DriverInstanceId}: its run-guard config was out of range and is clamped: {Adjustment}",
|
||||
driverInstanceId, adjustment);
|
||||
|
||||
var options = new CalculationDriverOptions
|
||||
{
|
||||
@@ -89,9 +103,37 @@ public static class CalculationDriverFactoryExtensions
|
||||
Scripting = scripting,
|
||||
};
|
||||
|
||||
return new CalculationDriver(
|
||||
options, driverInstanceId, scriptRoot,
|
||||
logger: loggerFactory?.CreateLogger<CalculationDriver>());
|
||||
return new CalculationDriver(options, driverInstanceId, scriptRoot, logger: logger);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The run-guard options for one Calculation driver. The node-wide options are the base. A key authored
|
||||
/// in the driver's config overrides only its own knob, on a copy, so the shared node object is never
|
||||
/// written. With neither, the result is null and the guard uses its compiled defaults.
|
||||
/// </summary>
|
||||
/// <param name="maxAllocBytesPerRun">The driver config's <c>maxAllocBytesPerRun</c>, or null.</param>
|
||||
/// <param name="quarantineAfterConsecutiveTimeouts">The driver config's <c>quarantineAfterConsecutiveTimeouts</c>, or null.</param>
|
||||
/// <param name="nodeScripting">The node-wide options, or null.</param>
|
||||
/// <param name="adjustments">The clamps applied to the driver's OWN keys (the node options were already
|
||||
/// clamped and logged at bind), for the caller to log. Empty when nothing was out of range.</param>
|
||||
/// <returns>The options to hand the driver, or null.</returns>
|
||||
internal static ScriptingOptions? ResolveScripting(
|
||||
long? maxAllocBytesPerRun, int? quarantineAfterConsecutiveTimeouts, ScriptingOptions? nodeScripting,
|
||||
out IReadOnlyList<string> adjustments)
|
||||
{
|
||||
adjustments = [];
|
||||
if (maxAllocBytesPerRun is null && quarantineAfterConsecutiveTimeouts is null) return nodeScripting;
|
||||
|
||||
var resolved = new ScriptingOptions();
|
||||
if (nodeScripting is not null)
|
||||
{
|
||||
resolved.MaxAllocBytesPerRun = nodeScripting.MaxAllocBytesPerRun;
|
||||
resolved.QuarantineAfterConsecutiveTimeouts = nodeScripting.QuarantineAfterConsecutiveTimeouts;
|
||||
resolved.StuckOrphanQuarantineFactor = nodeScripting.StuckOrphanQuarantineFactor;
|
||||
}
|
||||
if (maxAllocBytesPerRun is { } alloc) resolved.MaxAllocBytesPerRun = alloc;
|
||||
if (quarantineAfterConsecutiveTimeouts is { } n) resolved.QuarantineAfterConsecutiveTimeouts = n;
|
||||
return resolved.Sanitized(out adjustments);
|
||||
}
|
||||
|
||||
/// <summary>The merged-config DTO the calc factory binds from. The Calculation driver has no
|
||||
|
||||
@@ -48,8 +48,23 @@ public static class S7TagDefinitionFactory
|
||||
/// <param name="def">The mapped definition when this returns <see langword="true"/>.</param>
|
||||
/// <returns><see langword="true"/> when <paramref name="tagConfig"/> is a valid S7 address object.</returns>
|
||||
public static bool FromTagConfig(string tagConfig, string rawPath, out S7TagDefinition def)
|
||||
=> FromTagConfig(tagConfig, rawPath, out def, out _);
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="FromTagConfig(string, string, out S7TagDefinition)"/>, also reporting WHY a structurally
|
||||
/// valid blob was refused by <see cref="S7TagShapeRules"/> (an unparseable address, or a shape the
|
||||
/// driver cannot serve). <paramref name="shapeRejection"/> stays <see langword="null"/> for a JSON-level
|
||||
/// refusal (missing keys, bad enum), which <see cref="Inspect"/> explains on its own.
|
||||
/// </summary>
|
||||
/// <param name="tagConfig">The authored equipment-tag TagConfig JSON.</param>
|
||||
/// <param name="rawPath">The tag's RawPath — becomes the definition's identity (<c>Name</c>).</param>
|
||||
/// <param name="def">The mapped definition when this returns <see langword="true"/>.</param>
|
||||
/// <param name="shapeRejection">The shape/address rejection reason, when that is why it refused.</param>
|
||||
/// <returns><see langword="true"/> when the driver can serve the tag.</returns>
|
||||
public static bool FromTagConfig(string tagConfig, string rawPath, out S7TagDefinition def, out string? shapeRejection)
|
||||
{
|
||||
def = null!;
|
||||
shapeRejection = null;
|
||||
if (string.IsNullOrWhiteSpace(tagConfig)) return false;
|
||||
try
|
||||
{
|
||||
@@ -78,7 +93,7 @@ public static class S7TagDefinitionFactory
|
||||
// "writable" defaults to true when absent (today's value); node-level authz still governs
|
||||
// writes. Honouring the key makes read-only equipment tags authorable.
|
||||
var writable = TagConfigJson.ReadWritable(root);
|
||||
def = new S7TagDefinition(
|
||||
var mapped = new S7TagDefinition(
|
||||
Name: rawPath,
|
||||
Address: address,
|
||||
DataType: dataType,
|
||||
@@ -86,6 +101,15 @@ public static class S7TagDefinitionFactory
|
||||
StringLength: stringLength == 0 ? MaxStringLength : stringLength,
|
||||
WriteIdempotent: false,
|
||||
ArrayCount: arrayCount);
|
||||
// The address must parse and the shape must be one the driver can serve. These used to be
|
||||
// init-time guards that THREW and faulted every tag on the PLC; refusing here skips only
|
||||
// this tag, and the editor + deploy gate (which ask this method) refuse it too.
|
||||
if (S7TagShapeRules.Validate(mapped, out _) is { } reason)
|
||||
{
|
||||
shapeRejection = reason;
|
||||
return false;
|
||||
}
|
||||
def = mapped;
|
||||
return true;
|
||||
}
|
||||
catch (JsonException) { return false; }
|
||||
@@ -162,7 +186,9 @@ public static class S7TagDefinitionFactory
|
||||
/// <b>reject</b> — a structurally unparseable or non-object document, an absent / non-string /
|
||||
/// blank <c>address</c>, an <b>absent</b> <c>dataType</c> (see <see cref="DeclaresDataType"/>), a
|
||||
/// present-but-invalid <c>dataType</c> (the runtime enum read is STRICT, so a
|
||||
/// typo rejects the tag), or an out-of-range <c>stringLength</c> on a <c>String</c> tag. Such a tag
|
||||
/// typo rejects the tag), an out-of-range <c>stringLength</c> on a <c>String</c> tag, or a shape
|
||||
/// <see cref="S7TagShapeRules"/> refuses (an unparseable address, a wrong access width, a mis-typed
|
||||
/// Timer/Counter, an unsupported array). Such a tag
|
||||
/// is skipped by the driver and reads <c>BadNodeIdUnknown</c> forever. Empty when the mapper accepts
|
||||
/// the blob, when it is blank, or when it is not an equipment-tag object. Never throws.
|
||||
/// </summary>
|
||||
@@ -184,7 +210,13 @@ public static class S7TagDefinitionFactory
|
||||
|
||||
// The accept gate: whatever the driver reads draws nothing. The RAW reference is handed to the
|
||||
// mapper — the driver reads the stored blob, not a trimmed copy of it.
|
||||
if (FromTagConfig(reference, InspectionRawPath, out _)) return warnings;
|
||||
if (FromTagConfig(reference, InspectionRawPath, out _, out var shapeRejection)) return warnings;
|
||||
|
||||
if (shapeRejection is not null)
|
||||
{
|
||||
warnings.Add(shapeRejection + " The driver will skip this tag and it will read BadNodeIdUnknown.");
|
||||
return warnings;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||
|
||||
/// <summary>
|
||||
/// <b>The single authority on which S7 tag shapes the driver can serve.</b> Consulted by
|
||||
/// <see cref="S7TagDefinitionFactory.FromTagConfig"/>, so the AdminUI editor, the deploy gate
|
||||
/// (<see cref="S7TagDefinitionFactory.Inspect"/>) and the driver's own init all refuse the same
|
||||
/// tags with the same words.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// These rules used to be init-time guards in <c>S7Driver</c> that THREW. One mis-authored tag
|
||||
/// (<c>DB1.DBZ0</c>, <c>DB1.DBW0</c> typed <c>Float64</c>, <c>T5</c> typed <c>Int32</c>) faulted the
|
||||
/// whole driver, and the actor retried that same init every 10 s forever, darkening every tag on
|
||||
/// the PLC. The editor and the deploy gate checked none of it, and a CSV import or API blob
|
||||
/// bypasses the editor entirely. A rejected tag is now skipped on its own (it reads
|
||||
/// <c>BadNodeIdUnknown</c>) and the deploy gate warns about it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Every rule rejects a tag that could never be served correctly. None of them can newly break
|
||||
/// a tag that used to work: each one either faulted init, or failed (or silently mis-decoded)
|
||||
/// every read of that tag.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class S7TagShapeRules
|
||||
{
|
||||
/// <summary>
|
||||
/// S7 data types addressed by START BYTE (the <c>B</c> suffix) rather than by an access-width
|
||||
/// suffix. The driver decodes them from a contiguous byte buffer, so a wide tag authored against a
|
||||
/// non-byte suffix (DBW/DBD/...) would mis-frame its value.
|
||||
/// </summary>
|
||||
private static readonly HashSet<S7DataType> ByteAnchoredDataTypes =
|
||||
[
|
||||
S7DataType.Int64,
|
||||
S7DataType.UInt64,
|
||||
S7DataType.Float64,
|
||||
S7DataType.String,
|
||||
S7DataType.DateTime,
|
||||
S7DataType.WString,
|
||||
S7DataType.Char,
|
||||
S7DataType.WChar,
|
||||
S7DataType.Dtl,
|
||||
S7DataType.Date,
|
||||
S7DataType.Tod,
|
||||
S7DataType.Time,
|
||||
S7DataType.S5Time,
|
||||
];
|
||||
|
||||
/// <summary>True when <paramref name="dataType"/> is a wide/structured type read from a byte-anchored block.</summary>
|
||||
/// <param name="dataType">The declared data type.</param>
|
||||
/// <returns><see langword="true"/> for the byte-anchored types.</returns>
|
||||
public static bool IsByteAnchored(S7DataType dataType) => ByteAnchoredDataTypes.Contains(dataType);
|
||||
|
||||
/// <summary>
|
||||
/// Judges one tag definition. Returns <see langword="null"/> when the driver can serve it, or
|
||||
/// the operator-facing reason it cannot.
|
||||
/// </summary>
|
||||
/// <param name="def">The mapped tag definition.</param>
|
||||
/// <param name="parsed">The parsed address when this returns <see langword="null"/>.</param>
|
||||
/// <returns>The rejection reason, or <see langword="null"/> when the tag is servable.</returns>
|
||||
public static string? Validate(S7TagDefinition def, out S7ParsedAddress parsed)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(def);
|
||||
parsed = default;
|
||||
|
||||
S7ParsedAddress addr;
|
||||
try
|
||||
{
|
||||
addr = S7AddressParser.Parse(def.Address);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
return $"S7 address '{def.Address}' does not parse: {ex.Message}";
|
||||
}
|
||||
|
||||
var isWide = IsByteAnchored(def.DataType);
|
||||
var isArray = def.ArrayCount is >= 1;
|
||||
|
||||
// Wide-type arrays have no decoder.
|
||||
if (isWide && isArray)
|
||||
return $"S7 {def.DataType} arrays are not supported. Use a scalar {def.DataType} tag, or an " +
|
||||
"Int16/UInt16/Int32/UInt32/Float32/Byte/Bool element type for an array.";
|
||||
|
||||
// Timer / Counter type constraints: a timer decodes to Float64 seconds, a counter to an Int32 count.
|
||||
if (addr.Area is S7Area.Timer && def.DataType != S7DataType.Float64)
|
||||
return $"S7 Timer address '{def.Address}' is typed {def.DataType}. Timer tags must be " +
|
||||
"DataType=Float64 (decoded to seconds, read-only).";
|
||||
if (addr.Area is S7Area.Counter && def.DataType != S7DataType.Int32)
|
||||
return $"S7 Counter address '{def.Address}' is typed {def.DataType}. Counter tags must be " +
|
||||
"DataType=Int32 (decoded to a count, read-only).";
|
||||
if (addr.Area is S7Area.Timer or S7Area.Counter)
|
||||
{
|
||||
if (isArray)
|
||||
return $"S7 Timer/Counter address '{def.Address}' cannot be an array.";
|
||||
if (def.Writable)
|
||||
return $"S7 Timer/Counter address '{def.Address}' is declared writable. Timer/Counter tags " +
|
||||
"are read-only; set Writable=false.";
|
||||
parsed = addr;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Wide/structured scalars on DB/M/I/Q must be byte-anchored (DBB/MB/IB/QB).
|
||||
if (isWide)
|
||||
{
|
||||
if (addr.Size != S7Size.Byte)
|
||||
return $"S7 {def.DataType} at '{def.Address}' is not byte-addressed. Wide/structured types " +
|
||||
"must be byte-addressed (DBB/MB/IB/QB), e.g. 'DB1.DBB0', 'MB4'.";
|
||||
parsed = addr;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Narrow types: the declared type must match the address's access width, or every read fails
|
||||
// (or decodes garbage) with a type mismatch.
|
||||
var required = def.DataType switch
|
||||
{
|
||||
S7DataType.Bool => S7Size.Bit,
|
||||
S7DataType.Byte => S7Size.Byte,
|
||||
S7DataType.Int16 or S7DataType.UInt16 => S7Size.Word,
|
||||
_ => S7Size.DWord, // Int32 / UInt32 / Float32
|
||||
};
|
||||
if (addr.Size != required)
|
||||
return $"S7 {def.DataType} at '{def.Address}' has the wrong access width ({addr.Size}). " +
|
||||
$"A {def.DataType} tag needs a {SuffixHint(required)} address.";
|
||||
|
||||
if (isArray && def.DataType == S7DataType.Bool)
|
||||
{
|
||||
// Element i of a Bool array is bit 0 of byte (start + i): the array is byte-granular. A
|
||||
// non-zero bit offset used to be silently ignored (DB1.DBX0.3 returned bit 0 of bytes 0..n).
|
||||
if (addr.BitOffset != 0)
|
||||
return $"S7 Bool array at '{def.Address}' starts at bit {addr.BitOffset}. A Bool array reads " +
|
||||
"one byte per element (bit 0 of each byte), so it must start at bit 0 (e.g. 'DB1.DBX0.0').";
|
||||
// A byte-granular write would overwrite the 7 neighbouring bits of every byte it touches.
|
||||
if (def.Writable)
|
||||
return $"S7 Bool array at '{def.Address}' is declared writable. Bool array writes are not " +
|
||||
"supported (a byte-granular write would clobber neighbouring bits); set Writable=false " +
|
||||
"or write the bits as individual scalar Bool tags.";
|
||||
}
|
||||
|
||||
parsed = addr;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string SuffixHint(S7Size size) => size switch
|
||||
{
|
||||
S7Size.Bit => "bit (DBX/M/I/Q n.b)",
|
||||
S7Size.Byte => "byte (DBB/MB/IB/QB)",
|
||||
S7Size.Word => "word (DBW/MW/IW/QW)",
|
||||
_ => "double-word (DBD/MD/ID/QD)",
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -42,6 +42,14 @@ public sealed class S7DriverProbe : IDriverProbe
|
||||
if (string.IsNullOrWhiteSpace(host) || port <= 0)
|
||||
return new(false, "Config has no host/port to probe.", null);
|
||||
|
||||
// The S7 handshake + test read are bounded by the driver's own per-operation timeout
|
||||
// (config "timeoutMs", what the deployed driver will wait), capped by the caller's overall
|
||||
// Test Connect timeout. The options type carries it as a TimeSpan the JSON never names, so it
|
||||
// used to be ignored and every handshake waited the full Test Connect timeout (R07:H7).
|
||||
var opTimeout = ReadTimeoutMs(configJson) is { } ms && TimeSpan.FromMilliseconds(ms) < timeout
|
||||
? TimeSpan.FromMilliseconds(ms)
|
||||
: timeout;
|
||||
|
||||
// Phase 1: bare TCP preflight — fast rejection for unreachable hosts.
|
||||
var sw = Stopwatch.StartNew();
|
||||
try
|
||||
@@ -71,9 +79,9 @@ public sealed class S7DriverProbe : IDriverProbe
|
||||
var plc = S7Tsap.Resolve(opts) is { } tsap
|
||||
? new Plc(host, port, tsap)
|
||||
: new Plc(S7CpuTypeMap.ToS7Net(opts.CpuType), host, port, opts.Rack, opts.Slot);
|
||||
plc.ReadTimeout = (int)timeout.TotalMilliseconds;
|
||||
plc.ReadTimeout = (int)opTimeout.TotalMilliseconds;
|
||||
using var handshakeCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
handshakeCts.CancelAfter(timeout);
|
||||
handshakeCts.CancelAfter(opTimeout);
|
||||
try
|
||||
{
|
||||
await plc.OpenAsync(handshakeCts.Token);
|
||||
@@ -128,15 +136,17 @@ public sealed class S7DriverProbe : IDriverProbe
|
||||
return null;
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (PlcException pex) when (S7Driver.IsAccessDenied(pex))
|
||||
// S7.Net's read paths raise the CPU's item error as a BARE Exception ("Received error from
|
||||
// PLC: …"), not a PlcException, so both classifiers walk the whole chain (the driver's own).
|
||||
catch (Exception ex) when (S7Driver.IsAccessDenied(ex))
|
||||
{
|
||||
return new(false,
|
||||
"S7 connected but data access is denied — enable PUT/GET communication in TIA Portal " +
|
||||
$"(Protection & Security) for this CPU. PLC reported: {pex.Message}", null);
|
||||
$"(Protection & Security) for this CPU. PLC reported: {ex.Message}", null);
|
||||
}
|
||||
catch (PlcException pex) when (pex.ErrorCode == ErrorCode.ReadData)
|
||||
catch (Exception ex) when (S7Driver.IsRefusal(ex))
|
||||
{
|
||||
return null; // address not available on this CPU — not a connection problem
|
||||
return null; // address not available on this CPU — the CPU answered; not a connection problem
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -146,4 +156,31 @@ public sealed class S7DriverProbe : IDriverProbe
|
||||
|
||||
private static (string host, int port) ExtractTarget(S7DriverOptions opts)
|
||||
=> (opts.Host, opts.Port);
|
||||
|
||||
/// <summary>
|
||||
/// The config's <c>timeoutMs</c> (any casing, as the driver factory binds it), or null when absent
|
||||
/// or not a positive integer.
|
||||
/// </summary>
|
||||
/// <param name="configJson">The driver config.</param>
|
||||
/// <returns>The configured per-operation timeout in milliseconds, or null.</returns>
|
||||
internal static int? ReadTimeoutMs(string configJson)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(configJson);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
|
||||
foreach (var prop in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
if (!string.Equals(prop.Name, "timeoutMs", StringComparison.OrdinalIgnoreCase)) continue;
|
||||
return prop.Value.ValueKind == JsonValueKind.Number && prop.Value.TryGetInt32(out var ms) && ms > 0
|
||||
? ms
|
||||
: null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,14 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||
/// <b>The safety invariant is that a span never extends past its last member's end.</b> A
|
||||
/// span may deliberately read bytes nobody asked for BETWEEN its members (that is what the
|
||||
/// gap budget buys), but every byte it requests lies inside a range some member was going
|
||||
/// to read anyway. A grouped read therefore cannot fault (area overrun, DB overrun) where
|
||||
/// the individual per-tag reads would have succeeded.
|
||||
/// to read anyway.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>That does NOT make a grouped read as safe as the per-tag reads</b> (R07:P2): the block is
|
||||
/// the union of its members' ranges, so ONE member the CPU refuses (running past the DB's real
|
||||
/// end, an over-declared STRING) makes it refuse the whole block, neighbours included.
|
||||
/// <see cref="S7Driver.ReadAsync"/> owns that case: a refused multi-member span is re-read one
|
||||
/// member at a time, and the members that must not be grouped are left out of later plans.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Timer and Counter tags are never grouped.</b> Their
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Max concurrent reads</label>
|
||||
<InputNumber @bind-Value="_form.MaxConcurrentReads" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">Read work items in flight at once. Default 8; 1 = sequential.</div>
|
||||
<div class="form-text">Read work items in flight at once. Default 8; 1 = sequential (libplctag). The native transport always uses at least 64 — it packs concurrent reads into one request.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="form-check form-switch mt-4">
|
||||
|
||||
+12
-1
@@ -28,6 +28,13 @@
|
||||
</InputSelect>
|
||||
<div class="form-text">Which protocol stack talks to the controller. Default omits the setting (libplctag). Native is opt-in: wire-proven only for N-file reads/writes and F8 reads. Takes effect on the next deploy.</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-check form-switch mt-4">
|
||||
<InputCheckbox @bind-Value="_form.CoalesceReads" @bind-Value:after="EmitAsync" class="form-check-input" id="ablegacyCoalesceReads" />
|
||||
<label class="form-check-label" for="ablegacyCoalesceReads">Coalesce adjacent reads</label>
|
||||
</div>
|
||||
<div class="form-text">Read nearby scalar tags on one N/B/F/L file as a single PCCC request. A block the PLC refuses falls back to per-tag reads automatically. Default on.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -97,7 +104,7 @@
|
||||
/// <summary>JSON paths this form edits; everything else in the stored config is preserved.</summary>
|
||||
internal static readonly string[] OwnedPaths =
|
||||
[
|
||||
"transport", "probeTimeoutSeconds",
|
||||
"transport", "probeTimeoutSeconds", "coalesceReads",
|
||||
"probe.enabled", "probe.probeAddress",
|
||||
];
|
||||
|
||||
@@ -163,6 +170,7 @@
|
||||
// PLC-5 / LogixPccc exclusion still applies). Never re-emit a concrete default here — that would pin
|
||||
// every edited driver to it.
|
||||
public AbLegacyTransport? Transport { get; set; }
|
||||
public bool CoalesceReads { get; set; } = true;
|
||||
|
||||
// Probe
|
||||
public bool ProbeEnabled { get; set; } = true;
|
||||
@@ -200,6 +208,7 @@
|
||||
_loadedProbeTimeout = o.Probe.Timeout,
|
||||
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
|
||||
Transport = o.Transport,
|
||||
CoalesceReads = o.CoalesceReads ?? true,
|
||||
ProbeEnabled = o.Probe.Enabled,
|
||||
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
|
||||
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
|
||||
@@ -220,6 +229,8 @@
|
||||
},
|
||||
Timeout = DriverConfigMs.Save(TimeoutSeconds, _loadedTimeout),
|
||||
Transport = Transport,
|
||||
// On = the default: OMIT the key (never pin a concrete default); off is the only value written.
|
||||
CoalesceReads = CoalesceReads ? null : false,
|
||||
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ public sealed record ScriptTagInfo(string Path, string Kind, string DataType, st
|
||||
/// else. Verified chain: <c>AddressSpaceComposer</c> harvests the <c>ctx.GetTag("…")</c> literals
|
||||
/// (via <c>EquipmentScriptPaths.ExtractDependencyRefs</c>) into <c>DependencyRefs</c>; those become
|
||||
/// <c>VirtualTagActor._dependencyRefs</c>, registered with <c>DependencyMuxActor</c>, whose
|
||||
/// <c>_byRef</c> map is keyed (Ordinal) by <c>DriverInstanceActor.AttributeValuePublished.FullReference</c>
|
||||
/// <c>_byRef</c> map is keyed (Ordinal) by <c>DriverInstanceActor.AttributeValueItem.FullReference</c>
|
||||
/// — and in v3 a driver's wire reference <b>is the RawPath</b>
|
||||
/// (<c>DriverHostActor._nodeIdByDriverRef</c> is keyed <c>(DriverInstanceId, RawPath)</c>).
|
||||
/// </para>
|
||||
|
||||
@@ -268,7 +268,16 @@ public sealed class ScriptAnalysisService
|
||||
var typeInfo = model.GetTypeInfo(memberAccess.Expression);
|
||||
var type = typeInfo.Type ?? typeInfo.ConvertedType;
|
||||
if (type == null) return null;
|
||||
return type.GetMembers()
|
||||
// The type's own members, then the INSTANCE members it inherits (stopping before System.Object, whose
|
||||
// ToString/GetHashCode noise was never offered). Without the base chain, a member declared only on
|
||||
// ScriptContext — ctx.CancellationToken — compiled and published but never completed. Inherited statics
|
||||
// (ScriptContext.Deadband) are left out: they are not reachable through an instance receiver. This applies to
|
||||
// EVERY receiver, not only ctx: a struct or enum receiver now also offers the System.ValueType /
|
||||
// System.Enum instance members it inherits (CompareTo, HasFlag, ...), all of which compile.
|
||||
var inherited = new List<ISymbol>();
|
||||
for (var b = type.BaseType; b is not null && b.SpecialType != SpecialType.System_Object; b = b.BaseType)
|
||||
inherited.AddRange(b.GetMembers().Where(m => !m.IsStatic));
|
||||
return type.GetMembers().Concat(inherited)
|
||||
.Where(m => m.CanBeReferencedByName && !m.IsImplicitlyDeclared)
|
||||
// NotApplicable covers members (e.g. some BCL/special members) that carry no explicit accessibility but are referenceable.
|
||||
.Where(m => m.DeclaredAccessibility == Accessibility.Public || m.DeclaredAccessibility == Accessibility.NotApplicable)
|
||||
|
||||
@@ -87,6 +87,16 @@ public sealed class AbLegacyTagConfigModel
|
||||
if (isArray && arrayLength > AbLegacyArray.MaxElements)
|
||||
return $"AbLegacy array length cannot exceed {AbLegacyArray.MaxElements} (PCCC data-file element limit).";
|
||||
|
||||
// Same rule the reader enforces (review 07 H16): structured elements never form an array. Stated here so
|
||||
// the CSV-import / API paths get the specific reason instead of the parse gate's generic one.
|
||||
if (isArray && arrayLength >= 1 && !AbLegacyTagDefinitionFactory.IsArrayElementTypeSupported(DataType))
|
||||
return AbLegacyTagDefinitionFactory.ArrayElementTypeError(DataType);
|
||||
|
||||
// An array must match its data file's element width (review 07 r0 I3): the driver refuses to write a
|
||||
// mismatched one, and reads it at the wrong width.
|
||||
if (isArray && arrayLength >= 1 && AbLegacyTagDefinitionFactory.ArrayFileMismatch(DataType, Address.Trim()) is { } widthError)
|
||||
return widthError;
|
||||
|
||||
// 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(
|
||||
|
||||
@@ -112,17 +112,15 @@ public sealed class S7TagConfigModel
|
||||
+ string.Join(", ", Enum.GetNames<S7DataType>()) + ".";
|
||||
}
|
||||
|
||||
// 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
|
||||
// Array shape: the friendlier editor wording for two of the S7TagShapeRules refusals (a wide-type
|
||||
// array, a writable Bool array). The reader below refuses them too (and the rest of the shape
|
||||
// rules), so this is wording, not a second authority. 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.
|
||||
if (TagConfigJson.GetBool(_bag, "isArray"))
|
||||
{
|
||||
var dt = DataType;
|
||||
if (dt is S7DataType.Int64 or S7DataType.UInt64 or S7DataType.Float64
|
||||
or S7DataType.String or S7DataType.DateTime or S7DataType.WString or S7DataType.Char
|
||||
or S7DataType.WChar or S7DataType.Dtl or S7DataType.Date or S7DataType.Tod
|
||||
or S7DataType.Time or S7DataType.S5Time)
|
||||
if (S7TagShapeRules.IsByteAnchored(dt))
|
||||
{
|
||||
return $"S7 does not support {dt} arrays — use Bool (read-only), Byte, Int16, UInt16, Int32, "
|
||||
+ "UInt32 or Float32 for an array tag, or a scalar tag.";
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Binds the node-wide <c>Scripting</c> section into <see cref="ScriptingOptions"/> and clamps it
|
||||
/// (<see cref="ScriptingOptions.Sanitized"/>). One place, so <c>Program.cs</c> and its unit test bind the same
|
||||
/// way and a misspelled key cannot silently fall back to the compiled defaults unnoticed.
|
||||
/// </summary>
|
||||
public static class ScriptingOptionsBinding
|
||||
{
|
||||
/// <summary>Binds and clamps the <c>Scripting</c> section.</summary>
|
||||
/// <param name="configuration">The host configuration.</param>
|
||||
/// <param name="adjustments">One line per clamped value, for the caller to log as a Warning.</param>
|
||||
/// <returns>The clamped options; the compiled defaults when the section is absent.</returns>
|
||||
public static ScriptingOptions Bind(IConfiguration configuration, out IReadOnlyList<string> adjustments)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
var bound = configuration.GetSection(ScriptingOptions.SectionName).Get<ScriptingOptions>() ?? new ScriptingOptions();
|
||||
return bound.Sanitized(out adjustments);
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,8 @@ public static class DriverFactoryBootstrap
|
||||
// The calc driver needs the root script logger so a script failure fans out onto the
|
||||
// script-logs topic; resolve it here (null on nodes without the script pipeline wired).
|
||||
var scriptRoot = sp.GetService<ZB.MOM.WW.OtOpcUa.Core.Scripting.ScriptRootLogger>();
|
||||
// The node-wide Scripting section (bound + clamped in Program.cs); null in test graphs ⇒ defaults.
|
||||
var scripting = sp.GetService<ZB.MOM.WW.OtOpcUa.Core.Scripting.ScriptingOptions>();
|
||||
// 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>();
|
||||
@@ -84,7 +86,7 @@ public static class DriverFactoryBootstrap
|
||||
// failures; without this it writes to NullLogger in production.
|
||||
ConfigureAbTransportLogging(sp);
|
||||
Register(registry, loggerFactory, secretResolver, primaryRoleSource, ResolveAbCipDefaultTransport(sp),
|
||||
scriptRoot);
|
||||
scriptRoot, scripting);
|
||||
return registry;
|
||||
});
|
||||
services.AddSingleton<IDriverFactory>(sp =>
|
||||
@@ -209,11 +211,12 @@ public static class DriverFactoryBootstrap
|
||||
ISecretResolver secretResolver,
|
||||
IDriverPrimaryRoleSource primaryRoleSource,
|
||||
Driver.AbCip.AbCipTransport abCipDefaultTransport,
|
||||
ZB.MOM.WW.OtOpcUa.Core.Scripting.ScriptRootLogger? scriptRoot = null)
|
||||
ZB.MOM.WW.OtOpcUa.Core.Scripting.ScriptRootLogger? scriptRoot = null,
|
||||
ZB.MOM.WW.OtOpcUa.Core.Scripting.ScriptingOptions? scripting = null)
|
||||
{
|
||||
Driver.AbCip.AbCipDriverFactoryExtensions.Register(registry, loggerFactory, abCipDefaultTransport);
|
||||
Driver.AbLegacy.AbLegacyDriverFactoryExtensions.Register(registry, loggerFactory);
|
||||
Driver.Calculation.CalculationDriverFactoryExtensions.Register(registry, loggerFactory, scriptRoot);
|
||||
Driver.Calculation.CalculationDriverFactoryExtensions.Register(registry, loggerFactory, scriptRoot, scripting);
|
||||
Driver.FOCAS.FocasDriverFactoryExtensions.Register(registry, loggerFactory);
|
||||
Driver.Galaxy.GalaxyDriverFactoryExtensions.Register(registry, secretResolver, loggerFactory);
|
||||
Driver.Modbus.ModbusDriverFactoryExtensions.Register(registry, loggerFactory);
|
||||
|
||||
@@ -365,10 +365,22 @@ if (hasDriver)
|
||||
// F8b — production virtual-tag evaluator (Roslyn-compiled scripts cached per expression).
|
||||
// Replaces the F8-default NullVirtualTagEvaluator so VirtualTagActor evaluates real user
|
||||
// scripts at runtime.
|
||||
// Node-wide script run-guard knobs (Scripting:MaxAllocBytesPerRun / QuarantineAfterConsecutiveTimeouts /
|
||||
// StuckOrphanQuarantineFactor). Bound once and clamped into sane ranges here, so an absurd value is a startup
|
||||
// Warning rather than an OverflowException inside a script run. The one instance is shared by every script
|
||||
// seam on the node: the virtual-tag evaluator below, the scripted-alarm engine (DriverHostActor, via
|
||||
// WithOtOpcUaRuntimeActors) and every Calculation driver (DriverFactoryBootstrap).
|
||||
var scriptingOptions = ZB.MOM.WW.OtOpcUa.Host.Configuration.ScriptingOptionsBinding.Bind(
|
||||
builder.Configuration, out var scriptingAdjustments);
|
||||
foreach (var adjustment in scriptingAdjustments)
|
||||
Log.Warning("Scripting misconfiguration detected at startup: {Adjustment}", adjustment);
|
||||
builder.Services.AddSingleton(scriptingOptions);
|
||||
|
||||
builder.Services.AddSingleton<RoslynVirtualTagEvaluator>(sp =>
|
||||
new RoslynVirtualTagEvaluator(
|
||||
sp.GetRequiredService<ILoggerFactory>().CreateLogger<RoslynVirtualTagEvaluator>(),
|
||||
sp.GetRequiredService<ScriptRootLogger>()));
|
||||
sp.GetRequiredService<ScriptRootLogger>(),
|
||||
scripting: sp.GetRequiredService<ScriptingOptions>()));
|
||||
builder.Services.AddSingleton<IVirtualTagEvaluator>(sp => sp.GetRequiredService<RoslynVirtualTagEvaluator>());
|
||||
|
||||
// Script-log fan-out (Layer 0). The DPS publisher resolves the ActorSystem lazily so it never
|
||||
|
||||
@@ -93,37 +93,33 @@ public sealed class AddressSpaceApplier
|
||||
}
|
||||
|
||||
var ts = DateTime.UtcNow;
|
||||
var removedCount = 0;
|
||||
// A removed equipment is a FOLDER: it gets no terminal condition write (review 01 L4 — the write the
|
||||
// applier used to make against the equipment id never found a condition and minted a placeholder
|
||||
// variable that shadowed the folder's SDK lookup entry). Its subtree teardown is in ApplyPureRemove.
|
||||
var removedCount = plan.RemovedEquipment.Count;
|
||||
// Swallowed removal-pass condition-write failures are tallied here (not lost to a per-node Warning)
|
||||
// so a degraded removal is operator-visible via AddressSpaceApplyOutcome.FailedNodes (archreview 01/S-1).
|
||||
var failedNodes = 0;
|
||||
foreach (var eq in plan.RemovedEquipment)
|
||||
{
|
||||
if (!SafeWriteAlarmCondition(eq.EquipmentId, RemovedConditionState, ts, AddressSpaceRealm.Uns)) failedNodes++;
|
||||
removedCount++;
|
||||
}
|
||||
foreach (var alarm in plan.RemovedAlarms)
|
||||
{
|
||||
if (!SafeWriteAlarmCondition(alarm.ScriptedAlarmId, RemovedConditionState, ts, AddressSpaceRealm.Uns)) failedNodes++;
|
||||
removedCount++;
|
||||
}
|
||||
// Removed equipment tags / VirtualTags are plain variable nodes (no Part 9 condition to write
|
||||
// before tear-down), but they ARE real removals — count them so AddressSpaceApplyOutcome.RemovedNodes
|
||||
// is accurate on a removed-tag-only deploy, which now reaches the rebuild path below. v3 Batch 4:
|
||||
// removed raw containers/tags + UNS reference variables are likewise real removals.
|
||||
removedCount += plan.RemovedEquipmentTags.Count + plan.RemovedEquipmentVirtualTags.Count +
|
||||
// Removed VirtualTags are plain variable nodes (no Part 9 condition to write before tear-down), but
|
||||
// they ARE real removals — count them so AddressSpaceApplyOutcome.RemovedNodes is accurate on a
|
||||
// removed-vtag-only deploy. v3 Batch 4: removed raw containers/tags + UNS reference variables are
|
||||
// likewise real removals.
|
||||
removedCount += plan.RemovedEquipmentVirtualTags.Count +
|
||||
plan.RemovedRawContainers.Count + plan.RemovedRawTags.Count + plan.RemovedUnsReferenceVariables.Count;
|
||||
|
||||
var changedCount =
|
||||
plan.ChangedEquipment.Count + plan.ChangedDrivers.Count + plan.ChangedAlarms.Count +
|
||||
plan.ChangedEquipmentTags.Count +
|
||||
plan.ChangedEquipmentVirtualTags.Count +
|
||||
plan.ChangedRawContainers.Count + plan.ChangedRawTags.Count + plan.ChangedUnsReferenceVariables.Count +
|
||||
// A UNS Area/Line rename is an in-place change to an existing folder node.
|
||||
plan.RenamedFolders.Count;
|
||||
var addedCount =
|
||||
plan.AddedEquipment.Count + plan.AddedDrivers.Count + plan.AddedAlarms.Count +
|
||||
plan.AddedEquipmentTags.Count +
|
||||
plan.AddedEquipmentVirtualTags.Count +
|
||||
plan.AddedRawContainers.Count + plan.AddedRawTags.Count + plan.AddedUnsReferenceVariables.Count;
|
||||
|
||||
@@ -145,7 +141,7 @@ public sealed class AddressSpaceApplier
|
||||
// order even when an id is reused across the remove + add sets (the recreated node is a fresh
|
||||
// NodeState, correct — it is a different tag). Any remove false/throw ⇒ full-rebuild fallback.
|
||||
// • Rebuild ⇒ the default-closed safety valve for any node-affecting change (ChangedEquipment /
|
||||
// ChangedAlarms / non-surgical ChangedEquipmentTags / node-relevant ChangedEquipmentVirtualTags)
|
||||
// ChangedAlarms / node-relevant ChangedEquipmentVirtualTags / non-surgical raw-tag deltas)
|
||||
// — the classifier's rule 2, which also catches any future plan field that makes a changed
|
||||
// record unequal, so the worst outcome of a misclassification is today's full rebuild.
|
||||
// ChangedDrivers is node-inert (routes through DriverHostActor's spawn plan) — the classifier leaves
|
||||
@@ -156,7 +152,6 @@ public sealed class AddressSpaceApplier
|
||||
// afterward by the publish actor's Materialise passes + announce).
|
||||
var hasRemovePass = kind is AddressSpaceChangeKind.PureRemove or AddressSpaceChangeKind.AddRemoveMix;
|
||||
|
||||
var surgicalTagDeltas = plan.ChangedEquipmentTags.Where(AddressSpaceChangeClassifier.TagDeltaIsSurgicalEligible).ToList();
|
||||
// UNS Area / Line renames are surgically applicable (in-place DisplayName swap)
|
||||
// when no structural rebuild fires. When a rebuild DOES fire (any add/remove/structural change),
|
||||
// MaterialiseHierarchy re-creates every folder with the new display names, so the renames are
|
||||
@@ -176,7 +171,7 @@ public sealed class AddressSpaceApplier
|
||||
rebuildFailed = !SafeRebuild();
|
||||
rebuilt = true;
|
||||
}
|
||||
else if (surgicalTagDeltas.Count > 0 || renamedFolders.Count > 0 || hasRemovePass ||
|
||||
else if (renamedFolders.Count > 0 || hasRemovePass ||
|
||||
surgicalRawTagDeltas.Count > 0 || unsRefDeltas.Count > 0)
|
||||
{
|
||||
if (_sink is ISurgicalAddressSpaceSink surgical)
|
||||
@@ -194,25 +189,6 @@ public sealed class AddressSpaceApplier
|
||||
}
|
||||
if (!ok) { allApplied = false; break; }
|
||||
}
|
||||
foreach (var d in allApplied ? surgicalTagDeltas : Enumerable.Empty<AddressSpacePlan.EquipmentTagDelta>())
|
||||
{
|
||||
// Compute the node id + writable + historian + shape EXACTLY as MaterialiseEquipmentTags
|
||||
// would so the in-place update matches what a rebuild would have produced. Array tags are
|
||||
// forced read-only (same as EnsureVariable: the driver write path doesn't handle arrays).
|
||||
var nodeId = UnsEquipmentVar(d.Current.EquipmentId, d.Current.FolderPath, d.Current.Name);
|
||||
var writable = d.Current.Writable && !d.Current.IsArray;
|
||||
var historian = d.Current.IsHistorized
|
||||
? (string.IsNullOrWhiteSpace(d.Current.HistorianTagname) ? d.Current.FullName : d.Current.HistorianTagname)
|
||||
: null;
|
||||
bool ok;
|
||||
try { ok = surgical.UpdateTagAttributes(nodeId, writable, historian, d.Current.DataType, d.Current.IsArray, d.Current.ArrayLength, AddressSpaceRealm.Uns); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "AddressSpaceApplier: surgical UpdateTagAttributes threw for {Node}", nodeId);
|
||||
ok = false;
|
||||
}
|
||||
if (!ok) { allApplied = false; break; }
|
||||
}
|
||||
// v3 RAW tags — the same in-place attribute update, in the RAW realm. Adding or dropping ONE
|
||||
// UnsTagReference flips its backing raw tag to Changed purely through ReferencingEquipmentPaths,
|
||||
// and before this path existed that tore down every node on the server for an edit that creates
|
||||
@@ -243,6 +219,9 @@ public sealed class AddressSpaceApplier
|
||||
ok = false;
|
||||
}
|
||||
if (!ok) { allApplied = false; break; }
|
||||
RecordReshape(AddressSpaceRealm.Raw, d.Current.NodeId,
|
||||
d.Previous.DataType, d.Previous.IsArray, d.Previous.ArrayLength,
|
||||
d.Current.DataType, d.Current.IsArray, d.Current.ArrayLength);
|
||||
}
|
||||
// v3 UNS reference variables — TWO shapes, split on whether the delta moves the node's
|
||||
// IDENTITY (2026-08-15 review I2).
|
||||
@@ -294,6 +273,9 @@ public sealed class AddressSpaceApplier
|
||||
updated = false;
|
||||
}
|
||||
if (!updated) { allApplied = false; break; }
|
||||
RecordReshape(AddressSpaceRealm.Uns, d.Current.NodeId,
|
||||
d.Previous.DataType, d.Previous.BackingIsArray, d.Previous.BackingArrayLength,
|
||||
d.Current.DataType, d.Current.BackingIsArray, d.Current.BackingArrayLength);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -320,8 +302,8 @@ public sealed class AddressSpaceApplier
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"AddressSpaceApplier: applied plan (kind={Kind}, added={Added}, removed={Removed}, changed={Changed}, surgicalTags={Surgical}, surgicalRawTags={SurgicalRaw}, unsRefDeltas={UnsRefs}, renamedFolders={Renamed}, rebuild={Rebuild})",
|
||||
kind, addedCount, removedCount, changedCount, rebuilt ? 0 : surgicalTagDeltas.Count,
|
||||
"AddressSpaceApplier: applied plan (kind={Kind}, added={Added}, removed={Removed}, changed={Changed}, surgicalRawTags={SurgicalRaw}, unsRefDeltas={UnsRefs}, renamedFolders={Renamed}, rebuild={Rebuild})",
|
||||
kind, addedCount, removedCount, changedCount,
|
||||
rebuilt ? 0 : surgicalRawTagDeltas.Count, rebuilt ? 0 : unsRefDeltas.Count,
|
||||
rebuilt ? 0 : renamedFolders.Count, rebuilt);
|
||||
|
||||
@@ -342,10 +324,9 @@ public sealed class AddressSpaceApplier
|
||||
/// 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
|
||||
/// subtree removal and skipped individually. For surviving-equipment removals: a removed VirtualTag
|
||||
/// variable gets a terminal Bad <c>WriteValue</c> then <see cref="ISurgicalAddressSpaceSink.RemoveVariableNode"/>;
|
||||
/// 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
|
||||
/// a removed scripted alarm gets 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
|
||||
/// in-flight MonitoredItem observes (a final Bad); after the node is gone, re-subscription gets
|
||||
@@ -363,25 +344,6 @@ public sealed class AddressSpaceApplier
|
||||
plan.RemovedEquipment.Select(e => e.EquipmentId), StringComparer.Ordinal);
|
||||
bool NotSubsumed(string equipmentId) => !removedEquipmentIds.Contains(equipmentId);
|
||||
|
||||
// Removed equipment tags — value variables OR alarm-bearing conditions — for SURVIVING equipment.
|
||||
foreach (var tag in plan.RemovedEquipmentTags.Where(t => NotSubsumed(t.EquipmentId)))
|
||||
{
|
||||
var nodeId = UnsEquipmentVar(tag.EquipmentId, tag.FolderPath, tag.Name);
|
||||
if (tag.Alarm is not null)
|
||||
{
|
||||
// Alarm-bearing tag → a Part 9 condition node. Terminal RemovedConditionState (today-gap
|
||||
// closed), then remove the condition in place.
|
||||
if (!SafeWriteAlarmCondition(nodeId, RemovedConditionState, ts, AddressSpaceRealm.Uns)) failedNodes++;
|
||||
if (!SafeRemoveAlarmCondition(surgical, nodeId, AddressSpaceRealm.Uns)) return false;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Plain value variable → terminal Bad so an in-flight MonitoredItem sees the removal.
|
||||
SafeWriteValue(nodeId, null, OpcUaStatus.Bad, ts, AddressSpaceRealm.Uns);
|
||||
if (!SafeRemoveVariable(surgical, nodeId, AddressSpaceRealm.Uns)) return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Removed VirtualTags (always plain value variables) for surviving equipment.
|
||||
foreach (var v in plan.RemovedEquipmentVirtualTags.Where(t => NotSubsumed(t.EquipmentId)))
|
||||
{
|
||||
@@ -550,7 +512,6 @@ public sealed class AddressSpaceApplier
|
||||
ArgumentNullException.ThrowIfNull(plan);
|
||||
|
||||
var ids = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var t in plan.AddedEquipmentTags) ids.Add(MaterialiseParent(t.EquipmentId, t.FolderPath));
|
||||
foreach (var v in plan.AddedEquipmentVirtualTags) ids.Add(MaterialiseParent(v.EquipmentId, v.FolderPath));
|
||||
foreach (var a in plan.AddedAlarms) ids.Add(a.EquipmentId);
|
||||
// v3 Batch 4 / review I5 — an added UNS reference variable is materialised directly under its
|
||||
@@ -1074,96 +1035,9 @@ public sealed class AddressSpaceApplier
|
||||
return failed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Materialise Equipment-namespace tags from a composition snapshot.
|
||||
/// For each <see cref="EquipmentTagPlan"/>,
|
||||
/// ensure its optional <c>FolderPath</c> sub-folder under the existing equipment folder, then
|
||||
/// ensure a Variable (NodeId = <c>FullName</c>, the driver-side ref) inside it. Variables
|
||||
/// start BadWaitingForInitialData; the driver fills live values in a later milestone.
|
||||
/// Idempotent. This is a sink-based pass, NOT a reuse of <c>EquipmentNodeWalker</c>: no
|
||||
/// sink-backed <c>IAddressSpaceBuilder</c> adapter exists, and the walker re-creates the
|
||||
/// whole Area/Line/Equipment tree with browse-path NodeIds, incompatible with this path's
|
||||
/// logical-Id NodeIds and the already-materialised equipment folders. This pass adds ONLY
|
||||
/// variables (and any per-tag sub-folder); <see cref="MaterialiseHierarchy"/> owns the
|
||||
/// equipment folders and this pass never re-creates them. The sink's <c>EnsureVariable</c>
|
||||
/// takes a plain <c>string dataType</c> (not a DriverAttributeInfo).
|
||||
/// <para><b>Subset overload (2026-08-15 surgical-deploys).</b> <paramref name="only"/> narrows WHAT is
|
||||
/// materialised to a plan's added tags; <paramref name="composition"/> stays the FULL composition for
|
||||
/// context. A <c>null</c> subset means "the whole composition" (the rebuild path).</para>
|
||||
/// </summary>
|
||||
/// <param name="composition">The FULL composition — the context even when a subset is passed.</param>
|
||||
/// <param name="only">Materialise only these tags; <c>null</c> ⇒ the whole composition's.</param>
|
||||
/// <returns>The count of swallowed sink failures in this pass (0 on a clean apply) — the publish actor
|
||||
/// sums it into the degraded-apply surface (archreview 01/S-1).</returns>
|
||||
public int MaterialiseEquipmentTags(
|
||||
AddressSpaceComposition composition,
|
||||
IReadOnlyList<EquipmentTagPlan>? only = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(composition);
|
||||
var equipmentTags = only ?? composition.EquipmentTags;
|
||||
if (equipmentTags.Count == 0) return 0;
|
||||
|
||||
var failed = 0;
|
||||
// Sub-folders first — a tag's FolderPath becomes one folder UNDER its equipment folder
|
||||
// (deduped per distinct equipment+path). Tags with no FolderPath hang directly under the
|
||||
// equipment folder, which MaterialiseHierarchy already created (never re-create
|
||||
// the equipment folder here).
|
||||
var foldersCreated = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var tag in equipmentTags)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tag.FolderPath)) continue;
|
||||
var folderNodeId = UnsSubFolder(tag.EquipmentId, tag.FolderPath);
|
||||
if (!foldersCreated.Add(folderNodeId)) continue;
|
||||
if (!SafeEnsureFolder(folderNodeId, parentNodeId: tag.EquipmentId, displayName: tag.FolderPath, realm: AddressSpaceRealm.Uns)) failed++;
|
||||
}
|
||||
|
||||
// Variables: NodeId is FOLDER-SCOPED ("<parent>/<Name>"), NOT the raw FullName — a driver
|
||||
// ref (e.g. a Modbus register) is not unique across identical machines, so FullName-as-NodeId
|
||||
// would collide in the sink (EnsureVariable keys on NodeId) and drop all but one machine's
|
||||
// signal. The driver-side FullName lives on EquipmentTagPlan for the later values milestone to
|
||||
// route by. Parent is the FolderPath sub-folder when set, else the equipment folder directly.
|
||||
// Per-variable idempotency relies on the sink's own EnsureVariable.
|
||||
foreach (var tag in equipmentTags)
|
||||
{
|
||||
var parent = string.IsNullOrWhiteSpace(tag.FolderPath)
|
||||
? tag.EquipmentId
|
||||
: UnsSubFolder(tag.EquipmentId, tag.FolderPath);
|
||||
var nodeId = UnsEquipmentVar(tag.EquipmentId, tag.FolderPath, tag.Name);
|
||||
if (tag.Alarm is not null)
|
||||
{
|
||||
// Native alarm tag → a real Part 9 condition node (reuses the scripted-alarm path),
|
||||
// NOT a value variable. Parent is the sub-folder when set, else the equipment folder.
|
||||
if (!SafeMaterialiseAlarmCondition(nodeId, parent, tag.Name, tag.Alarm.AlarmType, tag.Alarm.Severity, isNative: true, realm: AddressSpaceRealm.Uns)) failed++;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Phase C: a historized tag materialises Historizing + HistoryRead. Resolve the effective
|
||||
// historian tagname HERE (default-vs-override): a null/blank override falls back to the
|
||||
// driver-side FullName; null means the tag is not historized at all.
|
||||
string? historianTagname = tag.IsHistorized
|
||||
? (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 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++;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"AddressSpaceApplier: equipment tags materialised (tags={Tags}, equipment={Equipment}, subset={Subset}, failed={Failed})",
|
||||
equipmentTags.Count,
|
||||
equipmentTags.Select(t => t.EquipmentId).Distinct(StringComparer.Ordinal).Count(),
|
||||
only is not null,
|
||||
failed);
|
||||
return failed;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Materialise Equipment-namespace VirtualTags from a composition snapshot — the VirtualTag
|
||||
/// analogue of <see cref="MaterialiseEquipmentTags"/>. For each <see cref="EquipmentVirtualTagPlan"/>,
|
||||
/// computed-signal pass. For each <see cref="EquipmentVirtualTagPlan"/>,
|
||||
/// ensure its optional <c>FolderPath</c> sub-folder under the existing equipment folder (in
|
||||
/// practice <c>FolderPath</c> is empty for VirtualTags, so this is usually a no-op), then ensure
|
||||
/// a Variable inside it. Like the tag pass, the variable's NodeId is FOLDER-SCOPED
|
||||
@@ -1285,10 +1159,53 @@ public sealed class AddressSpaceApplier
|
||||
/// <returns><c>true</c> when the variable was ensured; <c>false</c> when the sink threw.</returns>
|
||||
private bool SafeEnsureVariable(string nodeId, string? parentNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false)
|
||||
{
|
||||
try { _sink.EnsureVariable(nodeId, parentNodeId, displayName, dataType, writable, realm, historianTagname, isArray, arrayLength, refuseArrayOnScalar); return true; }
|
||||
try
|
||||
{
|
||||
_sink.EnsureVariable(nodeId, parentNodeId, displayName, dataType, writable, realm, historianTagname, isArray, arrayLength, refuseArrayOnScalar);
|
||||
_recording?.Ensured.Add((realm, nodeId));
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: EnsureVariable threw for {Node}", nodeId); return false; }
|
||||
}
|
||||
|
||||
/// <summary>Records an in-place UpdateTagAttributes that changed the node's SHAPE (DataType / array-ness /
|
||||
/// array length): the node manager drops such a node's value to BadWaitingForInitialData.</summary>
|
||||
private void RecordReshape(
|
||||
AddressSpaceRealm realm, string nodeId,
|
||||
string previousDataType, bool previousIsArray, uint? previousArrayLength,
|
||||
string dataType, bool isArray, uint? arrayLength)
|
||||
{
|
||||
if (_recording is null) return;
|
||||
if (string.Equals(previousDataType, dataType, StringComparison.Ordinal)
|
||||
&& previousIsArray == isArray
|
||||
&& (!isArray || previousArrayLength == arrayLength))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_recording.Reshaped[(realm, nodeId)] = new VariableShape(dataType, isArray, arrayLength);
|
||||
}
|
||||
|
||||
/// <summary>The recording in progress, or null. See <see cref="BeginRecording"/>.</summary>
|
||||
private MaterialisationRecording? _recording;
|
||||
|
||||
/// <summary>
|
||||
/// Starts recording what the following apply and materialise passes do to VARIABLES, until
|
||||
/// <see cref="EndRecording"/>: every variable ensured, and every variable an in-place attribute update
|
||||
/// re-shaped (which resets its value). The publish actor scopes the replay of writes it deferred while a plan
|
||||
/// was in flight to these — a variable the apply kept took the live write already, and rewriting it would
|
||||
/// revert a client write made in the window. Single-caller (the publish actor's thread), like the apply.
|
||||
/// </summary>
|
||||
/// <returns>The live recording.</returns>
|
||||
public MaterialisationRecording BeginRecording()
|
||||
{
|
||||
_recording = new MaterialisationRecording();
|
||||
return _recording;
|
||||
}
|
||||
|
||||
/// <summary>Stops the recording <see cref="BeginRecording"/> started (the returned recording is kept).</summary>
|
||||
public void EndRecording() => _recording = null;
|
||||
|
||||
// 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
|
||||
@@ -1401,3 +1318,91 @@ public sealed record AddressSpaceApplyOutcome(
|
||||
bool RebuildCalled,
|
||||
bool RebuildFailed = false,
|
||||
int FailedNodes = 0);
|
||||
|
||||
/// <summary>What one apply did to variables (see <see cref="AddressSpaceApplier.BeginRecording"/>).</summary>
|
||||
public sealed class MaterialisationRecording
|
||||
{
|
||||
/// <summary>Every variable an <c>EnsureVariable</c> was issued for, as (realm, NodeId).</summary>
|
||||
public HashSet<(AddressSpaceRealm, string)> Ensured { get; } = new();
|
||||
|
||||
/// <summary>Every variable an in-place attribute update RE-SHAPED (its value reset), with its new shape.</summary>
|
||||
public Dictionary<(AddressSpaceRealm, string), VariableShape> Reshaped { get; } = new();
|
||||
}
|
||||
|
||||
/// <summary>A variable's value shape: its Tag.DataType name, and its array-ness / 1-D length.</summary>
|
||||
/// <param name="DataType">The Tag.DataType name ("Int32", "Float", …; unknown ⇒ BaseDataType, any value fits).</param>
|
||||
/// <param name="IsArray">Whether the node is a one-dimensional array.</param>
|
||||
/// <param name="ArrayLength">The fixed array length, when known (null / 0 ⇒ any length).</param>
|
||||
public sealed record VariableShape(string DataType, bool IsArray, uint? ArrayLength)
|
||||
{
|
||||
/// <summary>
|
||||
/// Fits <paramref name="value"/> to this shape for a write onto a re-shaped node: unchanged when it already
|
||||
/// fits, converted (invariant culture) when a lossless-enough conversion exists, otherwise refused — an
|
||||
/// old-typed value must never be served under the new DataType (the reset exists to prevent exactly that).
|
||||
/// A null value always fits (a status annotation carries none).
|
||||
/// </summary>
|
||||
/// <param name="value">The value to fit.</param>
|
||||
/// <param name="fitted">The fitted value, when this returns true.</param>
|
||||
/// <returns>True when the value fits (as is, or converted).</returns>
|
||||
public bool TryFit(object? value, out object? fitted)
|
||||
{
|
||||
fitted = null;
|
||||
if (value is null) return true;
|
||||
var element = ElementType(DataType);
|
||||
if (!IsArray)
|
||||
{
|
||||
if (value is Array) return false;
|
||||
return TryConvert(value, element, out fitted);
|
||||
}
|
||||
|
||||
if (value is not Array array || value is string) return false;
|
||||
if (ArrayLength is > 0 && array.Length != ArrayLength) return false;
|
||||
if (element is null) { fitted = value; return true; }
|
||||
var result = Array.CreateInstance(element, array.Length);
|
||||
for (var i = 0; i < array.Length; i++)
|
||||
{
|
||||
if (!TryConvert(array.GetValue(i), element, out var item) || item is null) return false;
|
||||
result.SetValue(item, i);
|
||||
}
|
||||
|
||||
fitted = result;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryConvert(object? value, Type? target, out object? converted)
|
||||
{
|
||||
converted = value;
|
||||
if (value is null || target is null || target.IsInstanceOfType(value)) return true;
|
||||
if (value is not IConvertible) return false;
|
||||
try
|
||||
{
|
||||
converted = Convert.ChangeType(value, target, System.Globalization.CultureInfo.InvariantCulture);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidCastException or FormatException or OverflowException)
|
||||
{
|
||||
converted = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The CLR type the node manager serves for a Tag.DataType name (its ResolveBuiltInDataType set);
|
||||
/// null ⇒ BaseDataType, where any value fits.</summary>
|
||||
private static Type? ElementType(string dataType) => dataType switch
|
||||
{
|
||||
"Boolean" => typeof(bool),
|
||||
"SByte" => typeof(sbyte),
|
||||
"Byte" => typeof(byte),
|
||||
"Int16" => typeof(short),
|
||||
"UInt16" => typeof(ushort),
|
||||
"Int32" => typeof(int),
|
||||
"UInt32" => typeof(uint),
|
||||
"Int64" => typeof(long),
|
||||
"UInt64" => typeof(ulong),
|
||||
"Float" => typeof(float),
|
||||
"Double" => typeof(double),
|
||||
"String" => typeof(string),
|
||||
"DateTime" => typeof(DateTime),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ public enum AddressSpaceChangeKind
|
||||
/// <summary><see cref="AddressSpacePlan.IsEmpty"/> — nothing to do.</summary>
|
||||
Empty,
|
||||
|
||||
/// <summary>Only surgical-eligible changed equipment/raw tags, surgical UNS-reference deltas,
|
||||
/// <summary>Only surgical-eligible changed raw tags, surgical UNS-reference deltas,
|
||||
/// node-irrelevant vtag changes, folder renames, and/or node-inert driver deltas — the existing F10b
|
||||
/// in-place path. No adds, no removes.</summary>
|
||||
AttributeOnly,
|
||||
@@ -27,7 +27,7 @@ public enum AddressSpaceChangeKind
|
||||
AddRemoveMix,
|
||||
|
||||
/// <summary>Anything with a node-affecting change (ChangedEquipment, ChangedAlarms, ChangedRawContainers,
|
||||
/// non-surgical ChangedEquipmentTags, alarm-bearing/identity-changing ChangedRawTags, node-relevant
|
||||
/// alarm-bearing/identity-changing ChangedRawTags, node-relevant
|
||||
/// ChangedEquipmentVirtualTags) — the unclassifiable-safe default. Full rebuild.</summary>
|
||||
Rebuild,
|
||||
}
|
||||
@@ -53,8 +53,7 @@ public static class AddressSpaceChangeClassifier
|
||||
if (plan.IsEmpty) return AddressSpaceChangeKind.Empty;
|
||||
|
||||
// 2 — any node-affecting CHANGE forces a full rebuild (default-closed). ChangedEquipment /
|
||||
// ChangedAlarms / ChangedRawContainers are always node-affecting; a changed equipment tag is
|
||||
// node-affecting UNLESS it is surgical-eligible, a changed raw tag UNLESS it is
|
||||
// ChangedAlarms / ChangedRawContainers are always node-affecting; a changed raw tag UNLESS it is
|
||||
// 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.
|
||||
//
|
||||
@@ -94,7 +93,6 @@ public static class AddressSpaceChangeClassifier
|
||||
plan.ChangedAlarms.Count > 0 ||
|
||||
plan.ChangedRawContainers.Count > 0 ||
|
||||
plan.ChangedRawTags.Any(d => !RawTagDeltaIsSurgicalEligible(d)) ||
|
||||
plan.ChangedEquipmentTags.Any(d => !TagDeltaIsSurgicalEligible(d)) ||
|
||||
plan.ChangedEquipmentVirtualTags.Any(d => !VtagDeltaIsNodeIrrelevant(d)))
|
||||
{
|
||||
return AddressSpaceChangeKind.Rebuild;
|
||||
@@ -105,12 +103,12 @@ public static class AddressSpaceChangeClassifier
|
||||
// add/remove split below and leave a driver-only plan classified AttributeOnly.
|
||||
var hasAdds =
|
||||
plan.AddedEquipment.Count + plan.AddedAlarms.Count +
|
||||
plan.AddedEquipmentTags.Count + plan.AddedEquipmentVirtualTags.Count +
|
||||
plan.AddedEquipmentVirtualTags.Count +
|
||||
plan.AddedRawContainers.Count + plan.AddedRawTags.Count +
|
||||
plan.AddedUnsReferenceVariables.Count > 0;
|
||||
var hasRemoves =
|
||||
plan.RemovedEquipment.Count + plan.RemovedAlarms.Count +
|
||||
plan.RemovedEquipmentTags.Count + plan.RemovedEquipmentVirtualTags.Count +
|
||||
plan.RemovedEquipmentVirtualTags.Count +
|
||||
plan.RemovedRawContainers.Count + plan.RemovedRawTags.Count +
|
||||
plan.RemovedUnsReferenceVariables.Count > 0;
|
||||
|
||||
@@ -141,36 +139,6 @@ public static class AddressSpaceChangeClassifier
|
||||
Historize = d.Current.Historize,
|
||||
}).Equals(d.Current);
|
||||
|
||||
// F10b: a CHANGED equipment tag whose ONLY differences are Writable / IsHistorized /
|
||||
// HistorianTagname / DataType / IsArray / ArrayLength (a plain value variable — no alarm condition node)
|
||||
// can be updated IN PLACE on the existing node via ISurgicalAddressSpaceSink.UpdateTagAttributes,
|
||||
// avoiding a full rebuild (preserving subscriptions). The presentation-shape fields (DataType / IsArray /
|
||||
// ArrayLength) join the whitelist now that the surgical sink swaps DataType + ValueRank + ArrayDimensions
|
||||
// in place (and raises a GeneralModelChangeEvent). FullName / DriverInstanceId / Name / identity / alarm
|
||||
// differences still fall through to a rebuild — FullName/DriverInstanceId re-route the node to a different
|
||||
// driver point, Name re-derives the NodeId, and an alarm flip turns the node into a Part 9 condition. The
|
||||
// override-unequal default also covers any future field.
|
||||
// REACH (live-verified): the shape path only fires for drivers whose TagConfig carries a stable
|
||||
// top-level "FullName" (Galaxy = tag_name.AttributeName; OpcUaClient = the node id) — there a DataType/array
|
||||
// edit leaves FullName untouched ⇒ surgical. For structured-TagConfig protocol drivers (Modbus/S7/AbCip/…)
|
||||
// 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
|
||||
{
|
||||
Writable = d.Current.Writable,
|
||||
IsHistorized = d.Current.IsHistorized,
|
||||
HistorianTagname = d.Current.HistorianTagname,
|
||||
DataType = d.Current.DataType,
|
||||
IsArray = d.Current.IsArray,
|
||||
ArrayLength = d.Current.ArrayLength,
|
||||
}).Equals(d.Current);
|
||||
|
||||
// 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 only feeds the node's writability — the
|
||||
|
||||
@@ -23,28 +23,15 @@ public sealed record AddressSpacePlan(
|
||||
IReadOnlyList<ScriptedAlarmPlan> RemovedAlarms,
|
||||
IReadOnlyList<AddressSpacePlan.AlarmDelta> ChangedAlarms)
|
||||
{
|
||||
/// <summary>
|
||||
/// Equipment-namespace tag diff sets, keyed by <see cref="EquipmentTagPlan.TagId"/>. Added as
|
||||
/// init-only members (defaulting empty) rather than positional parameters so existing
|
||||
/// <c>AddressSpacePlan</c> construction sites compile unchanged — consistent with how
|
||||
/// <see cref="AddressSpaceComposition.EquipmentTags"/> was added. Without these, an
|
||||
/// incremental deploy that changes ONLY equipment tags produced an empty plan and
|
||||
/// <c>OpcUaPublishActor.HandleRebuild</c> short-circuited before materialising them.
|
||||
/// </summary>
|
||||
public IReadOnlyList<EquipmentTagPlan> AddedEquipmentTags { get; init; } = Array.Empty<EquipmentTagPlan>();
|
||||
/// <inheritdoc cref="AddedEquipmentTags"/>
|
||||
public IReadOnlyList<EquipmentTagPlan> RemovedEquipmentTags { get; init; } = Array.Empty<EquipmentTagPlan>();
|
||||
/// <inheritdoc cref="AddedEquipmentTags"/>
|
||||
public IReadOnlyList<EquipmentTagDelta> ChangedEquipmentTags { get; init; } = Array.Empty<EquipmentTagDelta>();
|
||||
|
||||
/// <summary>
|
||||
/// Equipment-namespace VirtualTag diff sets, keyed by <see cref="EquipmentVirtualTagPlan.VirtualTagId"/>.
|
||||
/// The value-side analogue of <see cref="AddedEquipmentTags"/>: a VirtualTag carries an
|
||||
/// A VirtualTag carries an
|
||||
/// <c>Expression</c> evaluated over <c>DependencyRefs</c>, so a deploy that changes ONLY
|
||||
/// VirtualTags (e.g. a new computed signal or an edited formula) must still produce a
|
||||
/// non-empty plan and drive a rebuild — without these the diff was blind to VirtualTags and
|
||||
/// such a deploy silently no-op'd. Added as init-only members (defaulting empty) for the same
|
||||
/// compile-compatibility reason as <see cref="AddedEquipmentTags"/>.
|
||||
/// such a deploy silently no-op'd. Added as init-only members (defaulting empty) so existing
|
||||
/// construction sites compile unchanged. (The retired v3 equipment-tag diff sets — review 01 U2 — are gone:
|
||||
/// device values live in the Raw subtree and reach equipment as <see cref="UnsReferenceVariable"/>s.)
|
||||
/// </summary>
|
||||
public IReadOnlyList<EquipmentVirtualTagPlan> AddedEquipmentVirtualTags { get; init; } = Array.Empty<EquipmentVirtualTagPlan>();
|
||||
/// <inheritdoc cref="AddedEquipmentVirtualTags"/>
|
||||
@@ -65,7 +52,7 @@ public sealed record AddressSpacePlan(
|
||||
/// uses), preserving every client's subscriptions; a sink lacking the surgical capability or a
|
||||
/// missing folder falls back to a full rebuild. Added as an init-only member (defaulting empty) so
|
||||
/// every existing <c>AddressSpacePlan</c> construction site compiles unchanged — consistent with
|
||||
/// the EquipmentTag / EquipmentVirtualTag diff sets above.
|
||||
/// the EquipmentVirtualTag diff sets above.
|
||||
/// </summary>
|
||||
public IReadOnlyList<FolderRename> RenamedFolders { get; init; } = Array.Empty<FolderRename>();
|
||||
|
||||
@@ -74,7 +61,7 @@ public sealed record AddressSpacePlan(
|
||||
/// keyed by <see cref="RawContainerNode.NodeId"/> (the RawPath). A rename of any container changes
|
||||
/// its RawPath (and every descendant's), so it manifests as remove(old)+add(new) — an OPC UA NodeId
|
||||
/// is immutable identity. Init-only, defaulting empty, so existing construction sites compile
|
||||
/// unchanged (consistent with the EquipmentTag diff sets above).
|
||||
/// unchanged.
|
||||
/// </summary>
|
||||
public IReadOnlyList<RawContainerNode> AddedRawContainers { get; init; } = Array.Empty<RawContainerNode>();
|
||||
/// <inheritdoc cref="AddedRawContainers"/>
|
||||
@@ -114,7 +101,6 @@ public sealed record AddressSpacePlan(
|
||||
AddedEquipment.Count == 0 && RemovedEquipment.Count == 0 && ChangedEquipment.Count == 0 &&
|
||||
AddedDrivers.Count == 0 && RemovedDrivers.Count == 0 && ChangedDrivers.Count == 0 &&
|
||||
AddedAlarms.Count == 0 && RemovedAlarms.Count == 0 && ChangedAlarms.Count == 0 &&
|
||||
AddedEquipmentTags.Count == 0 && RemovedEquipmentTags.Count == 0 && ChangedEquipmentTags.Count == 0 &&
|
||||
AddedEquipmentVirtualTags.Count == 0 && RemovedEquipmentVirtualTags.Count == 0 && ChangedEquipmentVirtualTags.Count == 0 &&
|
||||
AddedRawContainers.Count == 0 && RemovedRawContainers.Count == 0 && ChangedRawContainers.Count == 0 &&
|
||||
AddedRawTags.Count == 0 && RemovedRawTags.Count == 0 && ChangedRawTags.Count == 0 &&
|
||||
@@ -124,7 +110,6 @@ public sealed record AddressSpacePlan(
|
||||
public sealed record EquipmentDelta(EquipmentNode Previous, EquipmentNode Current);
|
||||
public sealed record DriverDelta(DriverInstancePlan Previous, DriverInstancePlan Current);
|
||||
public sealed record AlarmDelta(ScriptedAlarmPlan Previous, ScriptedAlarmPlan Current);
|
||||
public sealed record EquipmentTagDelta(EquipmentTagPlan Previous, EquipmentTagPlan Current);
|
||||
public sealed record EquipmentVirtualTagDelta(EquipmentVirtualTagPlan Previous, EquipmentVirtualTagPlan Current);
|
||||
|
||||
/// <summary>A Raw container node present in both snapshots (same RawPath) with ≥1 differing field.</summary>
|
||||
@@ -177,12 +162,7 @@ public static class AddressSpacePlanner
|
||||
a => a.ScriptedAlarmId,
|
||||
(a, b) => new AddressSpacePlan.AlarmDelta(a, b));
|
||||
|
||||
var (addedEqTags, removedEqTags, changedEqTags) = DiffById(
|
||||
previous.EquipmentTags, next.EquipmentTags,
|
||||
t => t.TagId,
|
||||
(a, b) => new AddressSpacePlan.EquipmentTagDelta(a, b));
|
||||
|
||||
// VirtualTags diff by VirtualTagId, mirroring the EquipmentTags pass. EquipmentVirtualTagPlan
|
||||
// VirtualTags diff by VirtualTagId. EquipmentVirtualTagPlan
|
||||
// overrides record equality to compare ALL fields by value — scalars (Expression/DataType/
|
||||
// Name/FolderPath) plus DependencyRefs element-wise (SequenceEqual). So a no-op redeploy (fresh
|
||||
// list instances, identical contents) correctly diffs to empty; only a real content change is
|
||||
@@ -232,9 +212,6 @@ public static class AddressSpacePlanner
|
||||
addedDrv, removedDrv, changedDrv,
|
||||
addedAlarm, removedAlarm, changedAlarm)
|
||||
{
|
||||
AddedEquipmentTags = addedEqTags,
|
||||
RemovedEquipmentTags = removedEqTags,
|
||||
ChangedEquipmentTags = changedEqTags,
|
||||
AddedEquipmentVirtualTags = addedVTags,
|
||||
RemovedEquipmentVirtualTags = removedVTags,
|
||||
ChangedEquipmentVirtualTags = changedVTags,
|
||||
|
||||
@@ -105,6 +105,55 @@ public sealed class OpcUaApplicationHostOptions
|
||||
/// is always element 0.
|
||||
/// </summary>
|
||||
public IList<string> PeerApplicationUris { get; set; } = new List<string>();
|
||||
|
||||
/// <summary>
|
||||
/// SDK request-processing and transport quotas (<c>OpcUa:Quotas:*</c>, review 01 P6). The defaults are
|
||||
/// exactly the values that used to be hard-coded (5 / 100 / 200 request threads and queue) and the SDK's
|
||||
/// own transport defaults, so an absent section changes nothing.
|
||||
/// </summary>
|
||||
public OpcUaQuotaOptions Quotas { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The <c>OpcUa:Quotas</c> section: the SDK's request-thread / queue ceilings and its transport quotas.
|
||||
/// Request quotas carry the former hard-coded defaults; a transport quota left <c>null</c> keeps the SDK
|
||||
/// default (<see cref="TransportQuotas"/>'s constructor values).
|
||||
/// </summary>
|
||||
public sealed class OpcUaQuotaOptions
|
||||
{
|
||||
/// <summary>Minimum SDK request-processing threads (default 5).</summary>
|
||||
public int MinRequestThreadCount { get; set; } = 5;
|
||||
|
||||
/// <summary>Maximum SDK request-processing threads (default 100).</summary>
|
||||
public int MaxRequestThreadCount { get; set; } = 100;
|
||||
|
||||
/// <summary>Requests the SDK queues before refusing with <c>BadTooManyOperations</c> (default 200) — the
|
||||
/// burst ceiling review 01 P6 flagged.</summary>
|
||||
public int MaxQueuedRequestCount { get; set; } = 200;
|
||||
|
||||
/// <summary>Per-operation timeout in milliseconds; <c>null</c> keeps the SDK default.</summary>
|
||||
public int? OperationTimeoutMs { get; set; }
|
||||
|
||||
/// <summary>Largest string the transport decodes; <c>null</c> keeps the SDK default.</summary>
|
||||
public int? MaxStringLength { get; set; }
|
||||
|
||||
/// <summary>Largest byte string the transport decodes; <c>null</c> keeps the SDK default.</summary>
|
||||
public int? MaxByteStringLength { get; set; }
|
||||
|
||||
/// <summary>Largest array the transport decodes; <c>null</c> keeps the SDK default.</summary>
|
||||
public int? MaxArrayLength { get; set; }
|
||||
|
||||
/// <summary>Largest message the transport accepts, in bytes; <c>null</c> keeps the SDK default.</summary>
|
||||
public int? MaxMessageSize { get; set; }
|
||||
|
||||
/// <summary>Transport buffer size in bytes; <c>null</c> keeps the SDK default.</summary>
|
||||
public int? MaxBufferSize { get; set; }
|
||||
|
||||
/// <summary>Secure-channel lifetime in milliseconds; <c>null</c> keeps the SDK default.</summary>
|
||||
public int? ChannelLifetimeMs { get; set; }
|
||||
|
||||
/// <summary>Security-token lifetime in milliseconds; <c>null</c> keeps the SDK default.</summary>
|
||||
public int? SecurityTokenLifetimeMs { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -435,10 +484,8 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
var serverConfig = new ServerConfiguration
|
||||
{
|
||||
BaseAddresses = { $"opc.tcp://{_options.PublicHostname}:{_options.OpcUaPort}/OtOpcUa" },
|
||||
MinRequestThreadCount = 5,
|
||||
MaxRequestThreadCount = 100,
|
||||
MaxQueuedRequestCount = 200,
|
||||
};
|
||||
ApplyRequestQuotas(serverConfig, _options.Quotas);
|
||||
|
||||
foreach (var policy in BuildSecurityPolicies(_options.EnabledSecurityProfiles))
|
||||
{
|
||||
@@ -469,7 +516,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
RejectedCertificateStore = new CertificateTrustList { StoreType = "Directory", StorePath = Path.Combine(_options.PkiStoreRoot, "rejected") },
|
||||
AutoAcceptUntrustedCertificates = _options.AutoAcceptUntrustedClientCertificates,
|
||||
},
|
||||
TransportQuotas = new TransportQuotas(),
|
||||
TransportQuotas = BuildTransportQuotas(_options.Quotas),
|
||||
ClientConfiguration = new ClientConfiguration(),
|
||||
TraceConfiguration = new TraceConfiguration(),
|
||||
};
|
||||
@@ -479,6 +526,40 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
return config;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies the request-processing quotas (<c>OpcUa:Quotas:*</c>, review 01 P6 — they were hard-coded at
|
||||
/// 5 / 100 / 200) to the SDK server configuration.
|
||||
/// </summary>
|
||||
/// <param name="serverConfig">The configuration to set.</param>
|
||||
/// <param name="quotas">The configured quotas.</param>
|
||||
internal static void ApplyRequestQuotas(ServerConfiguration serverConfig, OpcUaQuotaOptions quotas)
|
||||
{
|
||||
serverConfig.MinRequestThreadCount = quotas.MinRequestThreadCount;
|
||||
serverConfig.MaxRequestThreadCount = quotas.MaxRequestThreadCount;
|
||||
serverConfig.MaxQueuedRequestCount = quotas.MaxQueuedRequestCount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the SDK transport quotas from <c>OpcUa:Quotas:*</c>: every member starts at the SDK default and
|
||||
/// only a configured (non-null) value replaces it, so an absent section is byte-identical to the former
|
||||
/// <c>new TransportQuotas()</c>.
|
||||
/// </summary>
|
||||
/// <param name="quotas">The configured quotas.</param>
|
||||
/// <returns>The transport quotas to install.</returns>
|
||||
internal static TransportQuotas BuildTransportQuotas(OpcUaQuotaOptions quotas)
|
||||
{
|
||||
var t = new TransportQuotas();
|
||||
if (quotas.OperationTimeoutMs is { } operationTimeout) t.OperationTimeout = operationTimeout;
|
||||
if (quotas.MaxStringLength is { } maxString) t.MaxStringLength = maxString;
|
||||
if (quotas.MaxByteStringLength is { } maxByteString) t.MaxByteStringLength = maxByteString;
|
||||
if (quotas.MaxArrayLength is { } maxArray) t.MaxArrayLength = maxArray;
|
||||
if (quotas.MaxMessageSize is { } maxMessage) t.MaxMessageSize = maxMessage;
|
||||
if (quotas.MaxBufferSize is { } maxBuffer) t.MaxBufferSize = maxBuffer;
|
||||
if (quotas.ChannelLifetimeMs is { } channelLifetime) t.ChannelLifetime = channelLifetime;
|
||||
if (quotas.SecurityTokenLifetimeMs is { } tokenLifetime) t.SecurityTokenLifetime = tokenLifetime;
|
||||
return t;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps each configured <see cref="OpcUaSecurityProfile"/> to a SDK
|
||||
/// <see cref="ServerSecurityPolicy"/>. Duplicate profiles are silently de-duped because
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -54,7 +54,7 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||
/// and the ONLY value ingress from driver children (<see cref="DriverInstanceActor"/> wraps even a
|
||||
/// single data change in an <c>AttributeValueBatchPublished</c>). <see cref="FanCommsLossToDependencyMux"/>
|
||||
/// fabricates a batch of the same shape straight to the dependency mux, so the host neither receives nor
|
||||
/// sends the per-value <c>AttributeValuePublished</c> any more.
|
||||
/// sends a per-value message any more (the per-value record is deleted).
|
||||
/// </summary>
|
||||
public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedStash
|
||||
{
|
||||
@@ -202,6 +202,20 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
private readonly IHistoryWriter _historyWriter;
|
||||
private readonly IActorRef? _virtualTagHostOverride;
|
||||
private readonly ScriptRootLogger? _scriptRootLogger;
|
||||
|
||||
/// <summary>Node-wide script run-guard options for the scripted-alarm engine; null ⇒ compiled defaults.</summary>
|
||||
private readonly ScriptingOptions? _scripting;
|
||||
|
||||
/// <summary>
|
||||
/// Driver children this host has stopped (<see cref="StopChild"/>, an operator Restart) and is watching
|
||||
/// until they terminate. A value burst from one of them is DROPPED in <see cref="ForwardBatchToMux"/>:
|
||||
/// <c>Context.Stop</c> is asynchronous, so the old child keeps publishing for the rest of the apply
|
||||
/// handler and its own stop, and forwarding those bursts after <see cref="EvictRetainedValues"/> would
|
||||
/// re-retain a removed driver's last Good in the dependency mux indefinitely. Akka delivers
|
||||
/// <see cref="Terminated"/> after the last message the watched child sent here, so removing the entry
|
||||
/// there bounds the set without ever dropping a live incarnation's values.
|
||||
/// </summary>
|
||||
private readonly HashSet<IActorRef> _retiredChildren = new();
|
||||
private readonly IActorRef? _scriptedAlarmHostOverride;
|
||||
private readonly ILoggingAdapter _log = Context.GetLogger();
|
||||
|
||||
@@ -264,6 +278,11 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
/// from <see cref="PushDesiredSubscriptions"/>.</summary>
|
||||
private IActorRef? _scriptedAlarmHost;
|
||||
|
||||
/// <summary>The scripted-alarm plans last sent to <see cref="_scriptedAlarmHost"/>, re-sent when it reports a
|
||||
/// supervision restart (<see cref="ScriptedAlarmHostActor.ScriptedAlarmHostRestarted"/>). Null until the
|
||||
/// first apply.</summary>
|
||||
private IReadOnlyList<EquipmentScriptedAlarmPlan>? _lastScriptedAlarmPlans;
|
||||
|
||||
private RevisionHash? _currentRevision;
|
||||
|
||||
/// <summary>
|
||||
@@ -641,8 +660,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
/// VirtualTag host instead of spawning a real <see cref="VirtualTagHostActor"/> child, so tests
|
||||
/// can intercept the <see cref="VirtualTagHostActor.ApplyVirtualTags"/> message. Null in
|
||||
/// production (the real host is spawned).</param>
|
||||
/// <param name="loggerFactory">Retained for constructor-signature stability; unused since the
|
||||
/// <c>EfAlarmConditionStateStore</c> fallback that consumed it was retired. Defaults to null.</param>
|
||||
/// <param name="scriptRootLogger">Optional root script logger required to spawn the ScriptedAlarm
|
||||
/// host (the engine + its script logging hang off it). When null the ScriptedAlarm host is left
|
||||
/// unspawned — the graceful dev/None-deployment path.</param>
|
||||
@@ -694,6 +711,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
/// capture to a mesh-partner Primary the cluster failure detector reports unreachable.</param>
|
||||
/// <param name="historyUpdateRoleView">Where the OPC UA HistoryUpdate verdict is published for the node manager's
|
||||
/// HistoryUpdate Primary gate. Null on nodes that wire no redundancy manager.</param>
|
||||
/// <param name="scripting">The node-wide <c>Scripting</c> run-guard options handed to the scripted-alarm
|
||||
/// engine. Null ⇒ the compiled defaults.</param>
|
||||
/// <returns>The Akka.NET <see cref="Akka.Actor.Props"/> used to spawn this actor.</returns>
|
||||
public static Props Props(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext>? dbFactory,
|
||||
@@ -707,7 +726,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
IVirtualTagEvaluator? virtualTagEvaluator = null,
|
||||
IHistoryWriter? historyWriter = null,
|
||||
IActorRef? virtualTagHostOverride = null,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
ScriptRootLogger? scriptRootLogger = null,
|
||||
IActorRef? scriptedAlarmHostOverride = null,
|
||||
IDriverCapabilityInvokerFactory? invokerFactory = null,
|
||||
@@ -728,7 +746,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
Func<CommonsNodeId, bool>? sharesAlarmQueueWithLocal = null,
|
||||
TimeSpan? applyOutcomeTimeout = null,
|
||||
bool captureWhilePrimaryUnreachable = true,
|
||||
IHistoryUpdateRoleView? historyUpdateRoleView = null) =>
|
||||
IHistoryUpdateRoleView? historyUpdateRoleView = null,
|
||||
ScriptingOptions? scripting = null) =>
|
||||
// WARNING: this forwarding list is POSITIONAL, and Props.Create compiles it into an
|
||||
// expression tree. Six IActorRef? parameters and several interface-typed ones mean a
|
||||
// mis-ordered argument is usually type-compatible and therefore compiles clean, then binds
|
||||
@@ -737,11 +756,12 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
Akka.Actor.Props.Create(() => new DriverHostActor(
|
||||
dbFactory, localNode, ackRouter, driverFactory, localRoles, dependencyMux, opcUaPublishActor,
|
||||
healthPublisher, virtualTagEvaluator, historyWriter, virtualTagHostOverride,
|
||||
loggerFactory, scriptRootLogger, scriptedAlarmHostOverride, invokerFactory, driverMemberCountProvider,
|
||||
scriptRootLogger, scriptedAlarmHostOverride, invokerFactory, driverMemberCountProvider,
|
||||
deploymentArtifactCache, redundancyRoleView, replicationPeerHost, fetchAndCacheMode, artifactFetcher,
|
||||
alarmStateStore, telemetryHub, primaryRoleSource, historizationRoleView,
|
||||
sharesHistorizationScopeWithLocal, applyWedgeTimeout, reprovisionBackoff, commsRecoveryAuditDelay,
|
||||
sharesAlarmQueueWithLocal, applyOutcomeTimeout, captureWhilePrimaryUnreachable, historyUpdateRoleView));
|
||||
sharesAlarmQueueWithLocal, applyOutcomeTimeout, captureWhilePrimaryUnreachable, historyUpdateRoleView,
|
||||
scripting));
|
||||
|
||||
/// <summary>Initializes a new DriverHostActor with the specified dependencies.</summary>
|
||||
/// <param name="dbFactory">Database context factory for configuration database access.</param>
|
||||
@@ -760,8 +780,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
/// for historized VirtualTag results; defaults to <see cref="NullHistoryWriter"/>.</param>
|
||||
/// <param name="virtualTagHostOverride">Test seam: when supplied, used as the VirtualTag host
|
||||
/// instead of spawning a real <see cref="VirtualTagHostActor"/> child.</param>
|
||||
/// <param name="loggerFactory">Retained for constructor-signature stability; unused since the
|
||||
/// <c>EfAlarmConditionStateStore</c> fallback that consumed it was retired. Defaults to null.</param>
|
||||
/// <param name="scriptRootLogger">Optional root script logger required to spawn the ScriptedAlarm
|
||||
/// host; when null the host is left unspawned.</param>
|
||||
/// <param name="scriptedAlarmHostOverride">Test seam: when supplied, used as the ScriptedAlarm host
|
||||
@@ -830,6 +848,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
/// (<see cref="PrimaryGatePolicy.ShouldServeHistoryUpdate"/> — the data-plane verdict, but closed until the first
|
||||
/// redundancy snapshot) is published for the node manager's HistoryUpdate Primary gate. Null on nodes that wire no
|
||||
/// redundancy manager.</param>
|
||||
/// <param name="scripting">The node-wide <c>Scripting</c> run-guard options handed to the scripted-alarm
|
||||
/// engine. Null ⇒ the compiled defaults.</param>
|
||||
public DriverHostActor(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext>? dbFactory,
|
||||
CommonsNodeId localNode,
|
||||
@@ -842,7 +862,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
IVirtualTagEvaluator? virtualTagEvaluator = null,
|
||||
IHistoryWriter? historyWriter = null,
|
||||
IActorRef? virtualTagHostOverride = null,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
ScriptRootLogger? scriptRootLogger = null,
|
||||
IActorRef? scriptedAlarmHostOverride = null,
|
||||
IDriverCapabilityInvokerFactory? invokerFactory = null,
|
||||
@@ -863,8 +882,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
Func<CommonsNodeId, bool>? sharesAlarmQueueWithLocal = null,
|
||||
TimeSpan? applyOutcomeTimeout = null,
|
||||
bool captureWhilePrimaryUnreachable = true,
|
||||
IHistoryUpdateRoleView? historyUpdateRoleView = null)
|
||||
IHistoryUpdateRoleView? historyUpdateRoleView = null,
|
||||
ScriptingOptions? scripting = null)
|
||||
{
|
||||
_scripting = scripting;
|
||||
_captureWhilePrimaryUnreachable = captureWhilePrimaryUnreachable;
|
||||
_historyUpdateRoleView = historyUpdateRoleView;
|
||||
_applyOutcomeTimeout = applyOutcomeTimeout ?? DefaultApplyOutcomeTimeout;
|
||||
@@ -1146,8 +1167,9 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
/// null and ApplyScriptedAlarms becomes a no-op. The engine is built around a fresh
|
||||
/// <see cref="DependencyMuxTagUpstreamSource"/> + the wired <see cref="IAlarmStateStore"/> (the
|
||||
/// replicated <c>LocalDbAlarmConditionStateStore</c> on a driver-role node); when no store was
|
||||
/// wired the host is skipped. The host (spawned as a child) owns + disposes the engine in its
|
||||
/// PostStop, so it stops with the driver host.
|
||||
/// wired the host is skipped. The host is spawned through the factory <c>Props</c>, so EACH
|
||||
/// incarnation (a supervision restart included) builds, owns and disposes its own engine; it stops
|
||||
/// with the driver host.
|
||||
/// </summary>
|
||||
private void SpawnScriptedAlarmHost()
|
||||
{
|
||||
@@ -1178,13 +1200,23 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
return;
|
||||
}
|
||||
|
||||
// Factory Props: every incarnation of the host (a supervision restart included) builds and owns a fresh
|
||||
// (upstream, engine) pair, so a restart never reuses an engine the previous incarnation disposed or left
|
||||
// half-loaded. The closure captures locals only; it runs in the child's constructor.
|
||||
var store = _alarmStateStore;
|
||||
var upstream = new DependencyMuxTagUpstreamSource();
|
||||
var engine = new ScriptedAlarmEngine(
|
||||
upstream, store, new ScriptLoggerFactory(_scriptRootLogger.Logger), _scriptRootLogger.Logger);
|
||||
var scriptLogger = _scriptRootLogger.Logger;
|
||||
var scripting = _scripting;
|
||||
_scriptedAlarmHost = Context.ActorOf(
|
||||
ScriptedAlarmHostActor.Props(
|
||||
_opcUaPublishActor, _dependencyMux, upstream, engine, _localNode, telemetryHub: _telemetryHub),
|
||||
_opcUaPublishActor, _dependencyMux,
|
||||
() =>
|
||||
{
|
||||
var upstream = new DependencyMuxTagUpstreamSource();
|
||||
var engine = new ScriptedAlarmEngine(
|
||||
upstream, store, new ScriptLoggerFactory(scriptLogger), scriptLogger, scripting: scripting);
|
||||
return (upstream, engine);
|
||||
},
|
||||
_localNode, telemetryHub: _telemetryHub),
|
||||
"scripted-alarm-host");
|
||||
}
|
||||
|
||||
@@ -1500,6 +1532,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
Receive<ApplyWedged>(HandleApplyWedged);
|
||||
Receive<GetDiagnostics>(HandleGetDiagnostics);
|
||||
Receive<DriverInstanceActor.AttributeValueBatchPublished>(ForwardBatchToMux);
|
||||
Receive<Terminated>(OnRetiredChildTerminated);
|
||||
Receive<ScriptedAlarmHostActor.ScriptedAlarmHostRestarted>(_ => HandleScriptedAlarmHostRestarted());
|
||||
Receive<DriverInstanceActor.AttributeAlarmPublished>(ForwardNativeAlarm);
|
||||
Receive<DriverInstanceActor.ConnectivityChanged>(OnDriverConnectivityChanged);
|
||||
Receive<CommsRecoveryAudit>(HandleCommsRecoveryAudit);
|
||||
@@ -1562,6 +1596,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
Receive<ApplyWedged>(HandleApplyWedged);
|
||||
Receive<GetDiagnostics>(HandleGetDiagnostics);
|
||||
Receive<DriverInstanceActor.AttributeValueBatchPublished>(ForwardBatchToMux);
|
||||
Receive<Terminated>(OnRetiredChildTerminated);
|
||||
Receive<ScriptedAlarmHostActor.ScriptedAlarmHostRestarted>(_ => HandleScriptedAlarmHostRestarted());
|
||||
Receive<DriverInstanceActor.AttributeAlarmPublished>(ForwardNativeAlarm);
|
||||
Receive<DriverInstanceActor.ConnectivityChanged>(OnDriverConnectivityChanged);
|
||||
Receive<CommsRecoveryAudit>(HandleCommsRecoveryAudit);
|
||||
@@ -1606,6 +1642,16 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
/// </summary>
|
||||
private void ForwardBatchToMux(DriverInstanceActor.AttributeValueBatchPublished msg)
|
||||
{
|
||||
// A burst from an incarnation this host already stopped: forwarding it would undo the SourceRemoved
|
||||
// eviction (see _retiredChildren). Its ConnectivityChanged is NOT filtered — the respawn path relies on
|
||||
// that Bad fan to cover the gap.
|
||||
if (_retiredChildren.Contains(Sender))
|
||||
{
|
||||
_log.Debug("DriverHost {Node}: dropped a {Count}-item value burst from a stopped incarnation of {Id}",
|
||||
_localNode, msg.Items.Count, msg.DriverInstanceId);
|
||||
return;
|
||||
}
|
||||
|
||||
// One envelope per burst; the mux filters per item (it registered a batch receive).
|
||||
_dependencyMux?.Tell(msg);
|
||||
|
||||
@@ -2501,6 +2547,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
Receive<DriverInstanceActor.AttributeValueBatchPublished>(m =>
|
||||
_log.Debug("DriverHost {Node}: dropping a {Count}-item value burst from {Id} while Stale (no address space)",
|
||||
_localNode, m.Items.Count, m.DriverInstanceId));
|
||||
Receive<Terminated>(OnRetiredChildTerminated);
|
||||
Receive<ScriptedAlarmHostActor.ScriptedAlarmHostRestarted>(_ => HandleScriptedAlarmHostRestarted());
|
||||
Receive<DriverInstanceActor.AttributeAlarmPublished>(m =>
|
||||
_log.Debug("DriverHost {Node}: dropping a native alarm transition ({Cond}) from {Id} while Stale",
|
||||
_localNode, m.Args.ConditionId, m.DriverInstanceId));
|
||||
@@ -3652,6 +3700,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
// (re)loads its engine + re-registers mux interest for the union of dependency refs. Covers
|
||||
// both the fresh-apply and bootstrap-restore paths (both call this method); the Stale-recovery
|
||||
// path deliberately does not, matching driver + VirtualTag recovery.
|
||||
_lastScriptedAlarmPlans = composition.EquipmentScriptedAlarms;
|
||||
_scriptedAlarmHost?.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(composition.EquipmentScriptedAlarms));
|
||||
if (composition.EquipmentScriptedAlarms.Count > 0)
|
||||
{
|
||||
@@ -3816,8 +3865,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
{
|
||||
if (!_children.TryGetValue(driverInstanceId, out var entry)) return;
|
||||
StopDependencyAdapter(driverInstanceId);
|
||||
RetireChild(entry.Actor);
|
||||
Context.Stop(entry.Actor);
|
||||
_children.Remove(driverInstanceId);
|
||||
EvictRetainedValues(driverInstanceId);
|
||||
// The instance left the plan (StopChild is only reached via plan.ToStop), so its cached
|
||||
// Health/Resilience snapshots must not replay to new telemetry subscribers. Release the shared
|
||||
// resilience state FIRST: the resilience publisher re-emits the tracker's snapshots every tick, so
|
||||
@@ -3836,6 +3887,56 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
_log.Info("DriverHost {Node}: stopped driver child {Id}", _localNode, driverInstanceId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tells the dependency mux to drop every retained value this driver instance produced, so a stopped
|
||||
/// driver's last Good is never replayed to a later replay-opting subscriber as if it were current. Sent
|
||||
/// wherever a driver OBJECT ends: a deploy removal, the stop half of a config-change respawn
|
||||
/// (<see cref="StopChild"/>) and an operator Restart (a new driver object). Deliberately NOT sent on a
|
||||
/// supervision restart (<see cref="HandleReprovisionRequested"/>): that re-uses the same driver, whose last value is
|
||||
/// still its current one. Sent before any respawn, on the same host→mux pair the new incarnation's
|
||||
/// values travel, so the eviction is ordered ahead of them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The stopped child keeps publishing until it actually terminates, and for a REMOVED driver nothing
|
||||
/// would ever overwrite a value re-retained after this eviction (it never publishes again, and its
|
||||
/// PostStop Bad fan resolves no refs once the apply has rebuilt the index without it). So the caller
|
||||
/// also <see cref="RetireChild"/>s it, and <see cref="ForwardBatchToMux"/> drops every burst from a
|
||||
/// retired child.
|
||||
/// </remarks>
|
||||
/// <param name="driverInstanceId">The driver instance that ended.</param>
|
||||
private void EvictRetainedValues(string driverInstanceId) =>
|
||||
_dependencyMux?.Tell(new DependencyMuxActor.SourceRemoved(driverInstanceId));
|
||||
|
||||
/// <summary>Marks a child this host is about to stop as retired and watches it until it terminates.</summary>
|
||||
/// <param name="child">The child being stopped.</param>
|
||||
private void RetireChild(IActorRef child)
|
||||
{
|
||||
_retiredChildren.Add(child);
|
||||
Context.Watch(child);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The scripted-alarm host was restarted by supervision and has an empty engine: re-send the last plans so
|
||||
/// its inputs and operator commands work again without waiting for the next deploy. Only the CURRENT host's
|
||||
/// signal is honoured.
|
||||
/// </summary>
|
||||
private void HandleScriptedAlarmHostRestarted()
|
||||
{
|
||||
if (_scriptedAlarmHost is null || !Sender.Equals(_scriptedAlarmHost)) return;
|
||||
if (_lastScriptedAlarmPlans is null)
|
||||
{
|
||||
_log.Debug("DriverHost {Node}: ScriptedAlarm host restarted before any apply; nothing to re-send", _localNode);
|
||||
return;
|
||||
}
|
||||
_log.Warning("DriverHost {Node}: ScriptedAlarm host was restarted by supervision; re-applying {Count} scripted alarm(s)",
|
||||
_localNode, _lastScriptedAlarmPlans.Count);
|
||||
_scriptedAlarmHost.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(_lastScriptedAlarmPlans));
|
||||
}
|
||||
|
||||
/// <summary>A retired child has terminated; nothing more can arrive from it.</summary>
|
||||
/// <param name="msg">The termination notice.</param>
|
||||
private void OnRetiredChildTerminated(Terminated msg) => _retiredChildren.Remove(msg.ActorRef);
|
||||
|
||||
private static string ActorNameFor(string driverInstanceId, long generation)
|
||||
{
|
||||
// Akka actor names cannot contain '/', ':', or whitespace. Mangle defensively. The monotonic
|
||||
@@ -3881,8 +3982,12 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
|
||||
// Stop the existing child actor — DriverInstanceActor.PostStop calls ShutdownAsync.
|
||||
StopDependencyAdapter(msg.DriverInstanceId);
|
||||
RetireChild(entry.Actor);
|
||||
Context.Stop(entry.Actor);
|
||||
_children.Remove(msg.DriverInstanceId);
|
||||
// An operator Restart builds a NEW driver object, so the old one's retained values are evicted like a
|
||||
// removal's; the respawned driver republishes its own.
|
||||
EvictRetainedValues(msg.DriverInstanceId);
|
||||
// The old incarnation's health must not read as the new one's until it publishes its own.
|
||||
_healthPublisher.Forget(msg.DriverInstanceId);
|
||||
|
||||
|
||||
@@ -108,21 +108,16 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
/// state — a no-op when already Reconnecting or Connecting.
|
||||
/// </summary>
|
||||
public sealed record ForceReconnect;
|
||||
/// <summary>One driver-published value. <b>No production sender remains</b> — the subscription path
|
||||
/// batches every data change into an <see cref="AttributeValueBatchPublished"/>, and the comms-loss fan
|
||||
/// (<c>DriverHostActor.FanCommsLossToDependencyMux</c>), which was the last sender to fabricate this
|
||||
/// message, now fabricates a batch too. The dependency mux still accepts it for tests and direct senders.
|
||||
/// <summary>One value within an <see cref="AttributeValueBatchPublished"/>.
|
||||
/// <para><paramref name="StatusCode"/> is the driver's full OPC UA status code including its sub-code
|
||||
/// — not a severity bucket. Classify it with <see cref="OpcUaStatus.IsGood"/> and friends rather
|
||||
/// than comparing it to <see cref="OpcUaStatus.Good"/>.</para></summary>
|
||||
public sealed record AttributeValuePublished(string DriverInstanceId, string FullReference, object? Value, uint StatusCode, DateTime TimestampUtc);
|
||||
/// <summary>One value within an <see cref="AttributeValueBatchPublished"/>.
|
||||
/// than comparing it to <see cref="OpcUaStatus.Good"/>.</para>
|
||||
/// <para>A struct deliberately: an <c>AttributeValueItem[]</c> then holds its items inline, so a burst of
|
||||
/// N values costs one array allocation instead of one array plus N boxes. Indexed access through
|
||||
/// <see cref="IReadOnlyList{T}"/> does not box.</para></summary>
|
||||
public readonly record struct AttributeValueItem(string FullReference, object? Value, uint StatusCode, DateTime TimestampUtc);
|
||||
/// <summary>Batched form of <see cref="AttributeValuePublished"/> — every changed attribute of ONE
|
||||
/// driver production burst in one message. The parent fans the whole batch to the mux and the OPC UA
|
||||
/// <summary>The ONLY value message a driver child sends: every changed attribute of ONE driver production
|
||||
/// burst in one message (a single data change is a one-item batch). The parent fans the whole batch to the mux and the OPC UA
|
||||
/// publish path in one hop each. Status codes ride verbatim.</summary>
|
||||
public sealed record AttributeValueBatchPublished(string DriverInstanceId, IReadOnlyList<AttributeValueItem> Items);
|
||||
private sealed record DataChangeForward(string FullReference, DataValueSnapshot Snapshot);
|
||||
@@ -132,7 +127,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
private sealed record DataChangeBatchForward(IReadOnlyList<DataChangeItem> Items);
|
||||
/// <summary>Published to the parent whenever the subscribed driver (an <see cref="IAlarmSource"/>) fires
|
||||
/// <see cref="IAlarmSource.OnAlarmEvent"/>. The parent (<see cref="DriverHostActor"/>) projects + routes it
|
||||
/// to the materialised Part 9 condition. Parallels <see cref="AttributeValuePublished"/>.</summary>
|
||||
/// to the materialised Part 9 condition. Parallels <see cref="AttributeValueBatchPublished"/>.</summary>
|
||||
public sealed record AttributeAlarmPublished(string DriverInstanceId, AlarmEventArgs Args);
|
||||
private sealed record NativeAlarmRaised(AlarmEventArgs Args);
|
||||
/// <summary>Self-sent on Connected entry / when alarm refs are (re)pushed, to establish the native-alarm
|
||||
|
||||
@@ -86,19 +86,17 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
/// <param name="Realm">The namespace realm the condition lives in (<see cref="AddressSpaceRealm.Raw"/> for native).</param>
|
||||
public sealed record AlarmQualityUpdate(string AlarmNodeId, uint StatusCode, DateTime TimestampUtc, AddressSpaceRealm Realm);
|
||||
/// <summary>
|
||||
/// Triggers an address-space rebuild. <paramref name="DeploymentId"/> is the deployment
|
||||
/// just applied by the host; the rebuild loads THAT artifact so materialisation matches the
|
||||
/// applied config + the SubscribeBulk pass. It is null only for legacy/dev callers, which
|
||||
/// fall back to the latest sealed deployment (lags a not-yet-sealed apply by one revision).
|
||||
/// Triggers an address-space rebuild of the artifact carried in <paramref name="Artifact"/>.
|
||||
/// <paramref name="DeploymentId"/> is the deployment just applied by the host (echoed in the reply and
|
||||
/// the logs). Review 02 P5: the ConfigDb fallback for a message carrying no bytes is gone — it had no
|
||||
/// production producer and would have blocked the pinned OPC UA thread on central SQL — so a message
|
||||
/// without bytes is "no answer" and holds the served address space.
|
||||
/// </summary>
|
||||
/// <param name="Correlation">Correlation id for tracing this rebuild.</param>
|
||||
/// <param name="DeploymentId">The applied deployment whose artifact to materialise, or null for the latest-sealed fallback.</param>
|
||||
/// <param name="DeploymentId">The applied deployment whose artifact this is, or null for a legacy caller.</param>
|
||||
/// <param name="Artifact">
|
||||
/// The artifact bytes already in hand, used INSTEAD of loading them from the ConfigDb. This is
|
||||
/// what makes boot-from-cache actually serve its address space: on a central-SQL outage the
|
||||
/// host has the cached blob but <see cref="DeploymentId"/> alone would drive a ConfigDb read
|
||||
/// that cannot succeed, leaving the rebuild a no-op and clients browsing an empty server. Null
|
||||
/// on the normal path, where loading from the ConfigDb by id is correct.
|
||||
/// The artifact bytes. Every producer (the deploy path, boot-from-cache, FetchAndCache) has them in
|
||||
/// hand; null or empty is treated as no answer.
|
||||
/// </param>
|
||||
/// <param name="ReportOutcome">
|
||||
/// when <see langword="true"/>, the actor replies to the sender with a
|
||||
@@ -128,11 +126,110 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
|
||||
public sealed record ServiceLevelChanged(byte ServiceLevel);
|
||||
|
||||
/// <summary>
|
||||
/// Review 02 P5 — a rebuild whose artifact parse, cluster-scoped composition and plan were computed
|
||||
/// OFF the pinned OPC UA thread (a <c>Task.Run</c> piped back to <c>Self</c>), ready for the sink passes,
|
||||
/// which are the only part that must run on this actor's thread. Carries the <see cref="Request"/> and
|
||||
/// the original <see cref="Requester"/> so the <see cref="RebuildCompleted"/> reply reaches whoever asked.
|
||||
/// </summary>
|
||||
/// <para><see cref="Incarnation"/> stamps the actor instance that started the plan: a <c>PipeTo(Self)</c>
|
||||
/// outlives a restart (the ActorRef survives it), and a plan computed against a previous instance's
|
||||
/// <see cref="_lastApplied"/> must not be applied by the next one. <see cref="AuthorityError"/> carries a
|
||||
/// data-plane-authority parse failure, which never fails the address-space apply (review fix I2).</para>
|
||||
/// </summary>
|
||||
private sealed record PlanReady(
|
||||
object Incarnation, RebuildAddressSpace Request, IActorRef Requester, byte[] Artifact,
|
||||
DataPlaneAuthorityProjection? Authority, Exception? AuthorityError,
|
||||
AddressSpaceComposition Composition, AddressSpacePlan Plan, IReadOnlyList<string> Inconsistencies);
|
||||
|
||||
/// <summary>The off-thread parse / plan step threw (unreadable bytes, a composer fault).</summary>
|
||||
private sealed record PlanFailed(object Incarnation, RebuildAddressSpace Request, IActorRef Requester, Exception Error);
|
||||
|
||||
/// <summary>Test seams for the off-thread plan step. Production passes none.</summary>
|
||||
internal sealed class TestHooks
|
||||
{
|
||||
/// <summary>Runs on the thread pool at the start of every plan step (a test blocks it to hold a plan in flight).</summary>
|
||||
internal Action? BeforePlan { get; init; }
|
||||
|
||||
/// <summary>Replaces <see cref="ParsedDeploymentArtifact.ParseDataPlaneAuthority"/> (a test makes it throw).</summary>
|
||||
internal Func<ParsedDeploymentArtifact, string, DataPlaneAuthorityProjection>? ParseAuthority { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>The default bound on <see cref="_deferred"/>: distinct (realm, id) keys held while a plan is in flight.</summary>
|
||||
internal const int DefaultDeferredWriteCapacity = 250_000;
|
||||
|
||||
/// <summary>Identity of THIS actor instance (see <see cref="PlanReady.Incarnation"/>).</summary>
|
||||
private readonly object _incarnation = new();
|
||||
private readonly TestHooks? _hooks;
|
||||
|
||||
/// <summary>
|
||||
/// Review fix C1 — the latest value / status / alarm write per (realm, id) received while a rebuild's plan is
|
||||
/// in flight (or queued), replayed through the sink after each apply. Writes are non-creating (review 01 L1),
|
||||
/// so a driver's first publish for a node the in-flight plan ADDS used to be skipped — and a change-driven
|
||||
/// driver (or a poll group whose last-value cache already holds it) never sends it again, leaving the node
|
||||
/// <c>BadWaitingForInitialData</c> indefinitely. On a full rebuild the same holds for every node: the
|
||||
/// re-subscribe's first values land on old nodes the apply then discards.
|
||||
/// <para>Writes are still applied IMMEDIATELY (existing nodes keep updating during the plan); the buffer
|
||||
/// only remembers each key's latest so the replay can land it on a node the apply created. Replaying onto a
|
||||
/// node that already holds it is inert: the same value/status/timestamp is filtered by every monitored
|
||||
/// item's data-change filter, and an alarm write is delta-gated against the node's current state. That is
|
||||
/// also why alarms coalesce to the latest state per id rather than replaying every transition: replaying an
|
||||
/// older transition onto a live condition would move it backwards and fire spurious events.</para>
|
||||
/// </summary>
|
||||
private readonly DeferredWrites _deferred;
|
||||
|
||||
/// <summary>
|
||||
/// Review fix round 2 (N2 / M1) — WHICH deferred writes the next replay may land: only the ones whose live write
|
||||
/// could not have, i.e. onto nodes the apply just CREATED. A node the apply kept took the live write already,
|
||||
/// and replaying it is not inert: a client changed it in the window (an SDK-applied Acknowledge / Confirm /
|
||||
/// Shelve on a condition, an optimistic Write on a variable) and the replay would roll that back and fire
|
||||
/// spurious Part 9 events. Set by <see cref="ApplyPlan"/>, consumed by <see cref="FinishRebuild"/>.
|
||||
/// <list type="bullet">
|
||||
/// <item>Variables: after a FULL rebuild every one (each was recreated, its live write landed on the
|
||||
/// discarded node); otherwise exactly those the apply asked the sink to ensure (the applier records them).</item>
|
||||
/// <item>Variables an in-place attribute update RE-SHAPED (review N4): the node manager reset their value to
|
||||
/// BadWaitingForInitialData, so the window's value is replayed — but only once fitted to the new shape
|
||||
/// (<see cref="VariableShape.TryFit"/>); a value that does not fit is dropped and the node left waiting.
|
||||
/// The raw node and its UNS mirror are re-shaped by the same edit, so both get the same treatment.</item>
|
||||
/// <item>Conditions: those in the applied composition but not the previous one; after a full rebuild ALSO
|
||||
/// every kept SCRIPTED condition (review N3) — the node manager carries only NATIVE conditions' state over
|
||||
/// a rebuild, so a scripted one is recreated fresh and needs the window's state. A kept native condition
|
||||
/// is restored from the carry-over (which already holds the live write and any SDK ack), so it is not
|
||||
/// replayed. Accepted cost on the scripted path: an SDK ack on the discarded instance in the window is
|
||||
/// lost with it; the engine's own ack emission (a scripted ack routes to the engine) restores it.</item>
|
||||
/// <item>Known gaps (degraded path only, review m1 / m2): a degraded non-rebuild apply does not advance
|
||||
/// <c>_lastApplied</c>, so the next apply counts its conditions as added again and may replay onto them;
|
||||
/// and the empty-plan repair of a degraded composition scopes no condition (the composition is
|
||||
/// unchanged), so a condition the repair newly places gets no window state — writes to it between the
|
||||
/// two applies were already dropped before this replay existed.</item>
|
||||
/// </list>
|
||||
/// (Precise "did the live write land" would need the sink to report it; the production sink chain runs through
|
||||
/// Commons' DeferredAddressSpaceSink, outside this lane — follow-up F7.)
|
||||
/// </summary>
|
||||
private ReplayScope _replayScope = ReplayScope.Nothing;
|
||||
|
||||
private sealed record ReplayScope(
|
||||
bool AllVariables,
|
||||
IReadOnlySet<(AddressSpaceRealm, string)> Variables,
|
||||
IReadOnlyDictionary<(AddressSpaceRealm, string), VariableShape> Reshaped,
|
||||
IReadOnlySet<(AddressSpaceRealm, string)> Conditions)
|
||||
{
|
||||
private static readonly IReadOnlySet<(AddressSpaceRealm, string)> None = new HashSet<(AddressSpaceRealm, string)>();
|
||||
public static readonly ReplayScope Nothing =
|
||||
new(false, None, new Dictionary<(AddressSpaceRealm, string), VariableShape>(), None);
|
||||
}
|
||||
|
||||
/// <summary>True while a rebuild's parse / plan step is running off-thread. Rebuilds are applied strictly in
|
||||
/// arrival order — the plan is a diff against <see cref="_lastApplied"/>, which only the apply advances — so a
|
||||
/// rebuild arriving meanwhile waits in <see cref="_pendingRebuilds"/>; value, status and alarm writes do not — they
|
||||
/// are applied at once AND remembered in <see cref="_deferred"/> for replay after the apply.</summary>
|
||||
private bool _planInFlight;
|
||||
private readonly Queue<(RebuildAddressSpace Request, IActorRef Requester)> _pendingRebuilds = new();
|
||||
|
||||
private readonly IOpcUaAddressSpaceSink _sink;
|
||||
private readonly IServiceLevelPublisher _serviceLevel;
|
||||
private readonly bool _subscribeRedundancyTopic;
|
||||
private readonly NodeId? _localNode;
|
||||
private readonly IDbContextFactory<OtOpcUaConfigDbContext>? _dbFactory;
|
||||
private readonly AddressSpaceApplier? _applier;
|
||||
/// <summary>Receives the API keys + ACL grants carried by each applied artifact, so
|
||||
/// OPC UA session activation can authenticate. Null when nothing consumes it (tests, and any node
|
||||
@@ -184,9 +281,9 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
|
||||
/// <summary>Production Props — pins the OPC UA dispatcher + subscribes to the
|
||||
/// <c>redundancy-state</c> DPS topic so cluster transitions drive the local ServiceLevel
|
||||
/// publish path. When <paramref name="dbFactory"/> + <paramref name="applier"/> are supplied,
|
||||
/// <see cref="RebuildAddressSpace"/> reads the latest deployment artifact + drives the
|
||||
/// applier through the sink. When <paramref name="dbHealthProbe"/> is supplied the local
|
||||
/// publish path. When <paramref name="applier"/> is supplied, <see cref="RebuildAddressSpace"/>
|
||||
/// diffs + applies the artifact bytes it carries through the sink (<paramref name="dbFactory"/> is
|
||||
/// accepted for wiring compatibility only — review 02 P5 removed the ConfigDb fallback). When <paramref name="dbHealthProbe"/> is supplied the local
|
||||
/// ServiceLevel is computed via <see cref="ServiceLevelCalculator"/> from real DB-health +
|
||||
/// staleness + role-leader inputs; otherwise the legacy role-only switch is used.</summary>
|
||||
/// <param name="sink">The OPC UA address space sink.</param>
|
||||
@@ -293,6 +390,28 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
dataPlaneAuthority,
|
||||
utcNow));
|
||||
|
||||
/// <summary>Test-only Props exposing the deferred-write bound and the plan-step seams.</summary>
|
||||
internal static Props PropsForTests(
|
||||
IOpcUaAddressSpaceSink sink,
|
||||
AddressSpaceApplier? applier,
|
||||
TestHooks? hooks,
|
||||
int deferredWriteCapacity = DefaultDeferredWriteCapacity,
|
||||
NodeId? localNode = null,
|
||||
IDataPlaneAuthoritySink? dataPlaneAuthority = null) =>
|
||||
// CreateBy, not Create(expression): Akka's expression path activates through a PUBLIC constructor, and the
|
||||
// seam-carrying constructor is internal so the test seams stay off the public API (review M3).
|
||||
Akka.Actor.Props.CreateBy(new TestProducer(() => new OpcUaPublishActor(
|
||||
sink, NullServiceLevelPublisher.Instance, false, localNode, null, applier, null, null, null, null,
|
||||
false, dataPlaneAuthority, null, deferredWriteCapacity, hooks)));
|
||||
|
||||
/// <summary>Produces the actor through its internal (seam-carrying) constructor, for <see cref="PropsForTests(IOpcUaAddressSpaceSink, AddressSpaceApplier?, TestHooks?, int, NodeId?, IDataPlaneAuthoritySink?)"/>.</summary>
|
||||
private sealed class TestProducer(Func<OpcUaPublishActor> create) : IIndirectActorProducer
|
||||
{
|
||||
public Type ActorType => typeof(OpcUaPublishActor);
|
||||
public ActorBase Produce() => create();
|
||||
public void Release(ActorBase actor) { }
|
||||
}
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="OpcUaPublishActor"/> class.</summary>
|
||||
/// <param name="sink">The OPC UA address space sink.</param>
|
||||
/// <param name="serviceLevel">The service level publisher.</param>
|
||||
@@ -329,13 +448,41 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
bool dbLess = false,
|
||||
IDataPlaneAuthoritySink? dataPlaneAuthority = null,
|
||||
Func<DateTime>? utcNow = null)
|
||||
: this(sink, serviceLevel, subscribeRedundancyTopic, localNode, dbFactory, applier, dbHealthProbe, staleWindow,
|
||||
probeFreshnessWindow, healthTickInterval, dbLess, dataPlaneAuthority, utcNow, DefaultDeferredWriteCapacity, hooks: null)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>The full constructor: also the deferred-write bound and the test seams (internal — tests only).</summary>
|
||||
/// <param name="deferredWriteCapacity">The bound on distinct nodes whose writes are held for replay while a rebuild plan is in flight.</param>
|
||||
/// <param name="hooks">Test seams for the off-thread plan step; null in production.</param>
|
||||
internal OpcUaPublishActor(
|
||||
IOpcUaAddressSpaceSink sink,
|
||||
IServiceLevelPublisher serviceLevel,
|
||||
bool subscribeRedundancyTopic,
|
||||
NodeId? localNode,
|
||||
IDbContextFactory<OtOpcUaConfigDbContext>? dbFactory,
|
||||
AddressSpaceApplier? applier,
|
||||
IActorRef? dbHealthProbe,
|
||||
TimeSpan? staleWindow,
|
||||
TimeSpan? probeFreshnessWindow,
|
||||
TimeSpan? healthTickInterval,
|
||||
bool dbLess,
|
||||
IDataPlaneAuthoritySink? dataPlaneAuthority,
|
||||
Func<DateTime>? utcNow,
|
||||
int deferredWriteCapacity,
|
||||
TestHooks? hooks)
|
||||
{
|
||||
_deferred = new DeferredWrites(Math.Max(1, deferredWriteCapacity));
|
||||
_hooks = hooks;
|
||||
_sink = sink;
|
||||
_dataPlaneAuthority = dataPlaneAuthority;
|
||||
_serviceLevel = serviceLevel;
|
||||
_subscribeRedundancyTopic = subscribeRedundancyTopic;
|
||||
_localNode = localNode;
|
||||
_dbFactory = dbFactory;
|
||||
// dbFactory is accepted for wiring compatibility only (review 02 P5): the bytes-less ConfigDb read the
|
||||
// rebuild used to fall back to is gone — every producer carries the artifact bytes in the message.
|
||||
_ = dbFactory;
|
||||
_applier = applier;
|
||||
_dbHealthProbe = dbHealthProbe;
|
||||
_dbLess = dbLess;
|
||||
@@ -350,6 +497,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
Receive<AlarmStateUpdate>(HandleAlarmUpdate);
|
||||
Receive<AlarmQualityUpdate>(HandleAlarmQualityUpdate);
|
||||
Receive<RebuildAddressSpace>(HandleRebuild);
|
||||
Receive<PlanReady>(HandlePlanReady);
|
||||
Receive<PlanFailed>(HandlePlanFailed);
|
||||
Receive<ServiceLevelChanged>(HandleServiceLevelChanged);
|
||||
Receive<RedundancyStateChanged>(HandleRedundancyStateChanged);
|
||||
Receive<DbHealthProbeActor.DbHealthStatus>(HandleDbHealthStatus);
|
||||
@@ -358,6 +507,24 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
Receive<SubscribeAck>(_ => { /* PubSub ack */ });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review M5 — a restart discards <see cref="_pendingRebuilds"/>; answer each queued requester (degraded) rather
|
||||
/// than leave it to the deploy timeout. The in-flight plan's requester is answered when its plan pipes back to
|
||||
/// the new instance (<see cref="IsFromAnotherIncarnation"/>).
|
||||
/// </summary>
|
||||
/// <inheritdoc />
|
||||
protected override void PreRestart(Exception reason, object message)
|
||||
{
|
||||
while (_pendingRebuilds.TryDequeue(out var queued))
|
||||
{
|
||||
if (!queued.Request.ReportOutcome) continue;
|
||||
queued.Requester.Tell(new RebuildCompleted(queued.Request.Correlation, queued.Request.DeploymentId, true,
|
||||
"the address-space publisher restarted before this rebuild was applied; it was not applied"));
|
||||
}
|
||||
|
||||
base.PreRestart(reason, message);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void PreStart()
|
||||
{
|
||||
@@ -380,6 +547,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
{
|
||||
try
|
||||
{
|
||||
if (PlanPipelineBusy) DeferValue(new ValueWrite(msg.NodeId, msg.Value, msg.StatusCode, msg.TimestampUtc, msg.Realm));
|
||||
_sink.WriteValue(msg.NodeId, msg.Value, msg.StatusCode, msg.TimestampUtc, msg.Realm);
|
||||
Interlocked.Increment(ref _writes);
|
||||
OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair<string, object?>("kind", "value"));
|
||||
@@ -401,6 +569,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
/// a whole batch because one item was bad.</para></summary>
|
||||
private void HandleAttributeBatchUpdate(AttributeValueBatchUpdate msg)
|
||||
{
|
||||
if (PlanPipelineBusy)
|
||||
foreach (var item in msg.Items) DeferValue(item);
|
||||
try
|
||||
{
|
||||
_sink.WriteValues(msg.Items);
|
||||
@@ -431,6 +601,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
/// value write on the same counter.</summary>
|
||||
private void HandleAttributeStatusBatchUpdate(AttributeStatusBatchUpdate msg)
|
||||
{
|
||||
if (PlanPipelineBusy)
|
||||
foreach (var item in msg.Items) DeferStatus(item);
|
||||
try
|
||||
{
|
||||
_sink.WriteStatuses(msg.Items);
|
||||
@@ -454,6 +626,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
|
||||
private void HandleAlarmUpdate(AlarmStateUpdate msg)
|
||||
{
|
||||
if (PlanPipelineBusy) DeferAlarm(msg);
|
||||
try
|
||||
{
|
||||
_sink.WriteAlarmCondition(msg.AlarmNodeId, msg.State, msg.TimestampUtc, msg.Realm);
|
||||
@@ -468,6 +641,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
|
||||
private void HandleAlarmQualityUpdate(AlarmQualityUpdate msg)
|
||||
{
|
||||
if (PlanPipelineBusy) DeferAlarmQuality(msg);
|
||||
try
|
||||
{
|
||||
_sink.WriteAlarmQuality(msg.AlarmNodeId, msg.StatusCode, msg.TimestampUtc, msg.Realm);
|
||||
@@ -480,114 +654,467 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleRebuild(RebuildAddressSpace msg)
|
||||
private void HandleRebuild(RebuildAddressSpace msg) => StartRebuild(msg, Sender);
|
||||
|
||||
/// <summary>
|
||||
/// The entry half of a rebuild, on the actor thread. The raw-sink fallback (no applier) and the
|
||||
/// no-artifact case complete synchronously; otherwise the artifact parse, the cluster-scoped composition
|
||||
/// and the plan — the O(artifact) work that used to serialise every live value batch and (because the
|
||||
/// node manager's lock is the SDK's) every client Publish behind it — run on the thread pool and come
|
||||
/// back as a <see cref="PlanReady"/>. Only the sink materialisation (<see cref="ApplyPlan"/>) runs here.
|
||||
/// </summary>
|
||||
private void StartRebuild(RebuildAddressSpace msg, IActorRef requester)
|
||||
{
|
||||
// Captured up front: the pipeline below is synchronous on this actor's thread today, but the reply
|
||||
// must reach whoever asked even if a future edit introduces an await into it.
|
||||
var requester = Sender;
|
||||
(bool Degraded, string? Reason) outcome;
|
||||
try
|
||||
// Strict arrival order for EVERY rebuild, including the ones that complete synchronously (no applier, no
|
||||
// bytes): a bytes-less request arriving mid-plan used to answer before the plan ahead of it, so replies went
|
||||
// out of order. HandleRebuild is the only entry that can find the pipeline busy; the drain calls this only
|
||||
// when it is idle.
|
||||
if (_planInFlight)
|
||||
{
|
||||
outcome = HandleRebuildCore(msg);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// close the apply on EVERY exit, early returns and a throwing pass included: the node
|
||||
// manager reports here the native conditions a rebuild carried over (after the notifier wiring the
|
||||
// passes did) and discards any captured state no materialise claimed. Skipping it on a throw would
|
||||
// leave that state for a later surgical re-add of the same id to resurrect. No-op when nothing is
|
||||
// pending; never throws out of the handler.
|
||||
try { _sink.CompleteAddressSpaceApply(); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Error(ex, "OpcUaPublish: sink.CompleteAddressSpaceApply threw (correlation={Correlation})", msg.Correlation);
|
||||
}
|
||||
_pendingRebuilds.Enqueue((msg, requester));
|
||||
return;
|
||||
}
|
||||
|
||||
// the outcome goes out only after the apply (including its completion step) is done.
|
||||
if (msg.ReportOutcome)
|
||||
requester.Tell(new RebuildCompleted(msg.Correlation, msg.DeploymentId, outcome.Degraded, outcome.Reason));
|
||||
}
|
||||
|
||||
/// <summary>The rebuild pipeline. Returns whether the apply was degraded, and why.</summary>
|
||||
private (bool Degraded, string? Reason) HandleRebuildCore(RebuildAddressSpace msg)
|
||||
{
|
||||
using var span = OtOpcUaTelemetry.StartAddressSpaceRebuildSpan();
|
||||
span?.SetTag("otopcua.correlation_id", msg.Correlation.ToString());
|
||||
|
||||
// Two modes. With an applier wired, do a REAL diff-and-apply pass — driven either by the
|
||||
// artifact bytes already in hand (a per-cluster-mesh driver-only / FetchAndCache node, whose
|
||||
// ConfigDb is admin-only now so _dbFactory is null, hands its fetched/cached bytes in
|
||||
// msg.Artifact) or, when no bytes are carried, by loading the artifact from the ConfigDb (the
|
||||
// Direct path, which requires _dbFactory). Only a node with NO applier at all (the F10b/dev
|
||||
// seam) falls back to the raw-sink rebuild — and that fallback WIPES the served address space
|
||||
// without re-materialising, so it must NEVER be reached by a real driver node that has an
|
||||
// applier but simply has no ConfigDb (that node diff-and-applies from its in-hand bytes below).
|
||||
// Only a node with NO applier at all (the F10b/dev seam) falls back to the raw-sink rebuild — and that
|
||||
// fallback WIPES the served address space without re-materialising, so it must NEVER be reached by a
|
||||
// real driver node that has an applier (that node diff-and-applies from its in-hand bytes below).
|
||||
if (_applier is null)
|
||||
{
|
||||
// Every rebuild is traced, whichever path completes it: the applier path opens its span in ApplyPlan
|
||||
// (the actor-thread half), the two synchronous completions below open their own.
|
||||
using var span = OtOpcUaTelemetry.StartAddressSpaceRebuildSpan();
|
||||
span?.SetTag("otopcua.correlation_id", msg.Correlation.ToString());
|
||||
(bool Degraded, string? Reason) outcome;
|
||||
try
|
||||
{
|
||||
_sink.RebuildAddressSpace();
|
||||
OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair<string, object?>("kind", "rebuild"));
|
||||
outcome = (false, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Error(ex, "OpcUaPublish: sink.RebuildAddressSpace threw (correlation={Correlation})",
|
||||
msg.Correlation);
|
||||
return (true, $"address-space rebuild threw: {ex.Message}");
|
||||
_log.Error(ex, "OpcUaPublish: sink.RebuildAddressSpace threw (correlation={Correlation})", msg.Correlation);
|
||||
outcome = (true, $"address-space rebuild threw: {ex.Message}");
|
||||
}
|
||||
return (false, null);
|
||||
|
||||
FinishRebuild(msg, requester, outcome);
|
||||
return;
|
||||
}
|
||||
|
||||
// An artifact we could not obtain is NOT an empty configuration. Parsing zero bytes yields an empty
|
||||
// composition, which the planner diffs against the live one as a PureRemove and the applier then
|
||||
// faithfully tears down. Nothing legitimate produces a zero-length blob (an operator who really deletes
|
||||
// everything still deploys a JSON document with empty arrays), so the only honest reading of "no bytes"
|
||||
// is "no answer" — hold the last-known-good address space and let the next deploy (or the next boot)
|
||||
// supply a real one. Leaving _lastApplied intact means the retry diffs against what is materialised.
|
||||
if (msg.Artifact is null or { Length: 0 })
|
||||
{
|
||||
using var span = OtOpcUaTelemetry.StartAddressSpaceRebuildSpan();
|
||||
span?.SetTag("otopcua.correlation_id", msg.Correlation.ToString());
|
||||
if (_hasAppliedComposition)
|
||||
_log.Warning(
|
||||
"OpcUaPublish: no usable artifact for deployment {Id} (correlation={Correlation}) — KEEPING the currently served address space rather than tearing it down",
|
||||
msg.DeploymentId, msg.Correlation);
|
||||
else
|
||||
_log.Debug(
|
||||
"OpcUaPublish: no artifact to materialise yet (deployment={Id}, correlation={Correlation})",
|
||||
msg.DeploymentId, msg.Correlation);
|
||||
FinishRebuild(msg, requester, (true, "no usable artifact reached the address-space apply; the previously served address space was kept"));
|
||||
return;
|
||||
}
|
||||
|
||||
_planInFlight = true;
|
||||
var artifact = msg.Artifact;
|
||||
var baseComposition = _lastApplied;
|
||||
var nodeId = _localNode?.Value;
|
||||
var wantAuthority = _dataPlaneAuthority is not null;
|
||||
var incarnation = _incarnation;
|
||||
var hooks = _hooks;
|
||||
Task.Run(() =>
|
||||
{
|
||||
hooks?.BeforePlan?.Invoke();
|
||||
// Open() throws on bytes that are not valid JSON, where the static wrappers swallowed to an EMPTY
|
||||
// result — and an empty composition is not a benign no-op here (see the no-bytes guard above). The
|
||||
// throw lands in PlanFailed: logged at Error, _lastApplied untouched, last-known-good still served.
|
||||
using var parsed = DeploymentArtifact.Open(artifact);
|
||||
// The authority parse is isolated from the address-space plan (review fix I2): before the plan moved
|
||||
// off-thread a throw here was contained by FeedDataPlaneAuthority's own catch; it must not now
|
||||
// become PlanFailed and degrade an apply whose topology is perfectly readable.
|
||||
DataPlaneAuthorityProjection? authority = null;
|
||||
Exception? authorityError = null;
|
||||
if (wantAuthority)
|
||||
{
|
||||
try
|
||||
{
|
||||
authority = hooks?.ParseAuthority is { } parse
|
||||
? parse(parsed, nodeId ?? string.Empty)
|
||||
: parsed.ParseDataPlaneAuthority(nodeId ?? string.Empty);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
authorityError = ex;
|
||||
}
|
||||
}
|
||||
|
||||
var inconsistencies = new List<string>();
|
||||
var composition = nodeId is not null
|
||||
? parsed.ParseComposition(nodeId, inconsistencies.Add)
|
||||
: parsed.ParseComposition();
|
||||
var plan = AddressSpacePlanner.Compute(baseComposition, composition);
|
||||
return new PlanReady(incarnation, msg, requester, artifact, authority, authorityError, composition, plan, inconsistencies);
|
||||
})
|
||||
.PipeTo(Self, failure: ex => new PlanFailed(incarnation, msg, requester, ex));
|
||||
}
|
||||
|
||||
private void HandlePlanReady(PlanReady ready)
|
||||
{
|
||||
if (IsFromAnotherIncarnation(ready.Incarnation, ready.Request, ready.Requester)) return;
|
||||
|
||||
if (ready.AuthorityError is { } authorityError)
|
||||
_log.Error(authorityError,
|
||||
"OpcUaPublish: data-plane authority parse threw (correlation={Correlation}) — the address space is applied; the previously installed keys and grants stay in force",
|
||||
ready.Request.Correlation);
|
||||
|
||||
(bool Degraded, string? Reason) outcome;
|
||||
try
|
||||
{
|
||||
outcome = ApplyPlan(ready);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_planInFlight = false;
|
||||
}
|
||||
|
||||
FinishRebuild(ready.Request, ready.Requester, outcome);
|
||||
AfterPlanSettled();
|
||||
}
|
||||
|
||||
private void HandlePlanFailed(PlanFailed failed)
|
||||
{
|
||||
if (IsFromAnotherIncarnation(failed.Incarnation, failed.Request, failed.Requester)) return;
|
||||
|
||||
_planInFlight = false;
|
||||
_log.Error(failed.Error, "OpcUaPublish: rebuild pipeline threw (correlation={Correlation})", failed.Request.Correlation);
|
||||
FinishRebuild(failed.Request, failed.Requester, (true, $"address-space apply threw: {failed.Error.Message}"));
|
||||
AfterPlanSettled();
|
||||
}
|
||||
|
||||
/// <summary>A plan piped back to a RESTARTED instance (the ActorRef survives a restart) was computed against the
|
||||
/// previous instance's <see cref="_lastApplied"/>: never applied. Its requester is still owed an answer.</summary>
|
||||
private bool IsFromAnotherIncarnation(object incarnation, RebuildAddressSpace request, IActorRef requester)
|
||||
{
|
||||
if (ReferenceEquals(incarnation, _incarnation)) return false;
|
||||
_log.Warning(
|
||||
"OpcUaPublish: ignoring a rebuild plan started by a previous incarnation of this actor (correlation={Correlation})",
|
||||
request.Correlation);
|
||||
if (request.ReportOutcome)
|
||||
requester.Tell(new RebuildCompleted(request.Correlation, request.DeploymentId, true,
|
||||
"the address-space publisher restarted while this rebuild was being planned; it was not applied"));
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>After an apply (or a failed plan) has been answered — its deferred writes already replayed by
|
||||
/// <see cref="FinishRebuild"/> — start what queued behind it, and drop the buffer once nothing is in flight or
|
||||
/// queued (a queued plan may add nodes the buffer names, so it is replayed again after that apply).</summary>
|
||||
private void AfterPlanSettled()
|
||||
{
|
||||
StartNextPendingRebuild();
|
||||
if (!PlanPipelineBusy) _deferred.Clear();
|
||||
}
|
||||
|
||||
/// <summary>Starts queued rebuilds in arrival order until one goes off-thread (or the queue empties) — the
|
||||
/// synchronous completions (no bytes) answer in their turn.</summary>
|
||||
private void StartNextPendingRebuild()
|
||||
{
|
||||
while (!_planInFlight && _pendingRebuilds.TryDequeue(out var next))
|
||||
StartRebuild(next.Request, next.Requester);
|
||||
}
|
||||
|
||||
/// <summary>True while a plan is computing or a rebuild is queued behind one.</summary>
|
||||
private bool PlanPipelineBusy => _planInFlight || _pendingRebuilds.Count > 0;
|
||||
|
||||
private void DeferValue(in ValueWrite write)
|
||||
{
|
||||
if (!_deferred.Value(write)) WarnDeferredOverflow();
|
||||
}
|
||||
|
||||
private void DeferStatus(in StatusWrite write)
|
||||
{
|
||||
if (!_deferred.Status(write)) WarnDeferredOverflow();
|
||||
}
|
||||
|
||||
private void DeferAlarm(AlarmStateUpdate msg)
|
||||
{
|
||||
if (!_deferred.Alarm(msg)) WarnDeferredOverflow();
|
||||
}
|
||||
|
||||
private void DeferAlarmQuality(AlarmQualityUpdate msg)
|
||||
{
|
||||
if (!_deferred.AlarmQuality(msg)) WarnDeferredOverflow();
|
||||
}
|
||||
|
||||
private void WarnDeferredOverflow()
|
||||
{
|
||||
if (_deferred.Dropped == 1)
|
||||
_log.Warning(
|
||||
"OpcUaPublish: more than {Capacity} distinct nodes written while a rebuild plan is in flight — later new nodes are not held for replay (their first value lands only if the driver republishes it)",
|
||||
_deferred.Capacity);
|
||||
}
|
||||
|
||||
/// <summary>Re-applies the deferred writes through the sink: values, then status-only annotations, then alarm
|
||||
/// states and alarm qualities. Each pass isolated like the live handlers; not re-metered (counted on arrival).</summary>
|
||||
private void ReplayDeferredWrites(ReplayScope scope)
|
||||
{
|
||||
if (_deferred.IsEmpty) return;
|
||||
var (allValues, allStatuses) = _deferred.VariableWrites(
|
||||
key => scope.AllVariables || scope.Variables.Contains(key) || scope.Reshaped.ContainsKey(key));
|
||||
var values = new List<ValueWrite>(allValues.Count);
|
||||
var statuses = new List<StatusWrite>(allStatuses.Count);
|
||||
var unfit = 0;
|
||||
foreach (var v in allValues)
|
||||
{
|
||||
if (!scope.Reshaped.TryGetValue((v.Realm, v.NodeId), out var shape)) { values.Add(v); continue; }
|
||||
if (shape.TryFit(v.Value, out var fitted)) { values.Add(v with { Value = fitted }); continue; }
|
||||
unfit++;
|
||||
_log.Debug(
|
||||
"OpcUaPublish: a value deferred for re-shaped {Realm} node {Node} does not fit its new shape ({DataType}, array={IsArray}) — not replayed; the node waits for the driver's next value",
|
||||
v.Realm, v.NodeId, shape.DataType, shape.IsArray);
|
||||
}
|
||||
|
||||
foreach (var st in allStatuses)
|
||||
{
|
||||
// A status-only annotation onto a re-shaped (value-less) node would serve a null value under it; the
|
||||
// node's honest state is BadWaitingForInitialData until a value of the new shape arrives.
|
||||
if (scope.Reshaped.ContainsKey((st.Realm, st.NodeId))) { unfit++; continue; }
|
||||
statuses.Add(st);
|
||||
}
|
||||
if (values.Count > 0)
|
||||
{
|
||||
try { _sink.WriteValues(values); }
|
||||
catch (Exception ex) { _log.Warning(ex, "OpcUaPublish: replaying {Count} deferred value writes threw", values.Count); }
|
||||
}
|
||||
|
||||
if (statuses.Count > 0)
|
||||
{
|
||||
try { _sink.WriteStatuses(statuses); }
|
||||
catch (Exception ex) { _log.Warning(ex, "OpcUaPublish: replaying {Count} deferred status writes threw", statuses.Count); }
|
||||
}
|
||||
|
||||
var alarms = 0;
|
||||
foreach (var alarm in _deferred.Alarms)
|
||||
{
|
||||
if (!scope.Conditions.Contains((alarm.Realm, alarm.AlarmNodeId))) continue;
|
||||
alarms++;
|
||||
try { _sink.WriteAlarmCondition(alarm.AlarmNodeId, alarm.State, alarm.TimestampUtc, alarm.Realm); }
|
||||
catch (Exception ex) { _log.Warning(ex, "OpcUaPublish: replaying a deferred alarm write threw for {Node}", alarm.AlarmNodeId); }
|
||||
}
|
||||
|
||||
var qualities = 0;
|
||||
foreach (var quality in _deferred.AlarmQualities)
|
||||
{
|
||||
if (!scope.Conditions.Contains((quality.Realm, quality.AlarmNodeId))) continue;
|
||||
qualities++;
|
||||
try { _sink.WriteAlarmQuality(quality.AlarmNodeId, quality.StatusCode, quality.TimestampUtc, quality.Realm); }
|
||||
catch (Exception ex) { _log.Warning(ex, "OpcUaPublish: replaying a deferred alarm-quality write threw for {Node}", quality.AlarmNodeId); }
|
||||
}
|
||||
|
||||
_log.Debug(
|
||||
"OpcUaPublish: replayed {Values} value, {Statuses} status, {Alarms} alarm-state and {Qualities} alarm-quality writes (of {Held} held; {Unfit} not fitting a re-shaped node) deferred during a rebuild plan",
|
||||
values.Count, statuses.Count, alarms, qualities, _deferred.Count, unfit);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The latest write per (realm, id) received while a plan is in flight — see <see cref="_deferred"/>. A value
|
||||
/// followed by a status-only write for the same node composes to the value under the new status (exactly what
|
||||
/// the two live writes leave on the node). Bounded by <see cref="Capacity"/> distinct keys across all kinds;
|
||||
/// beyond it a NEW key is not held (an existing key still updates), counted in <see cref="Dropped"/>.
|
||||
/// </summary>
|
||||
private sealed class DeferredWrites(int capacity)
|
||||
{
|
||||
private struct VariableEntry
|
||||
{
|
||||
public bool HasValue;
|
||||
public object? Value;
|
||||
public uint Status;
|
||||
public DateTime TimestampUtc;
|
||||
}
|
||||
|
||||
private readonly Dictionary<(AddressSpaceRealm, string), VariableEntry> _variables = new();
|
||||
private readonly Dictionary<(AddressSpaceRealm, string), AlarmStateUpdate> _alarms = new();
|
||||
private readonly Dictionary<(AddressSpaceRealm, string), AlarmQualityUpdate> _qualities = new();
|
||||
|
||||
public int Capacity { get; } = capacity;
|
||||
public long Dropped { get; private set; }
|
||||
public int Count => _variables.Count + _alarms.Count + _qualities.Count;
|
||||
public bool IsEmpty => Count == 0;
|
||||
public IEnumerable<AlarmStateUpdate> Alarms => _alarms.Values;
|
||||
public IEnumerable<AlarmQualityUpdate> AlarmQualities => _qualities.Values;
|
||||
|
||||
public bool Value(in ValueWrite write)
|
||||
{
|
||||
if (string.IsNullOrEmpty(write.NodeId)) return true;
|
||||
var key = (write.Realm, write.NodeId);
|
||||
if (!_variables.ContainsKey(key) && !HasRoom()) return false;
|
||||
_variables[key] = new VariableEntry
|
||||
{
|
||||
HasValue = true, Value = write.Value, Status = write.StatusCode, TimestampUtc = write.SourceTimestampUtc,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool Status(in StatusWrite write)
|
||||
{
|
||||
if (string.IsNullOrEmpty(write.NodeId)) return true;
|
||||
var key = (write.Realm, write.NodeId);
|
||||
if (_variables.TryGetValue(key, out var entry))
|
||||
{
|
||||
entry.Status = write.StatusCode;
|
||||
entry.TimestampUtc = write.SourceTimestampUtc;
|
||||
_variables[key] = entry;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!HasRoom()) return false;
|
||||
_variables[key] = new VariableEntry { Status = write.StatusCode, TimestampUtc = write.SourceTimestampUtc };
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>A full snapshot also sets the condition's Quality (from its StatusCode), so it SUPERSEDES an
|
||||
/// earlier quality-only write for the same id (review N1): replayed state-then-quality, the stale quality
|
||||
/// would overwrite the snapshot's. A quality arriving AFTER the state is kept and replays after it.</summary>
|
||||
public bool Alarm(AlarmStateUpdate msg)
|
||||
{
|
||||
var key = (msg.Realm, msg.AlarmNodeId);
|
||||
if (!Put(_alarms, key, msg)) return false;
|
||||
_qualities.Remove(key);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool AlarmQuality(AlarmQualityUpdate msg) => Put(_qualities, (msg.Realm, msg.AlarmNodeId), msg);
|
||||
|
||||
public (List<ValueWrite> Values, List<StatusWrite> Statuses) VariableWrites(Func<(AddressSpaceRealm, string), bool> include)
|
||||
{
|
||||
var values = new List<ValueWrite>();
|
||||
var statuses = new List<StatusWrite>();
|
||||
foreach (var ((realm, id), e) in _variables)
|
||||
{
|
||||
if (!include((realm, id))) continue;
|
||||
if (e.HasValue) values.Add(new ValueWrite(id, e.Value, e.Status, e.TimestampUtc, realm));
|
||||
else statuses.Add(new StatusWrite(id, e.Status, e.TimestampUtc, realm));
|
||||
}
|
||||
|
||||
return (values, statuses);
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
_variables.Clear();
|
||||
_alarms.Clear();
|
||||
_qualities.Clear();
|
||||
Dropped = 0;
|
||||
}
|
||||
|
||||
private bool Put<T>(Dictionary<(AddressSpaceRealm, string), T> map, (AddressSpaceRealm, string) key, T value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(key.Item2)) return true;
|
||||
if (!map.ContainsKey(key) && !HasRoom()) return false;
|
||||
map[key] = value;
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool HasRoom()
|
||||
{
|
||||
if (Count < Capacity) return true;
|
||||
Dropped++;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Closes an apply on EVERY exit — early returns and a throwing pass included: the node manager reports
|
||||
/// here the native conditions a rebuild carried over (after the notifier wiring the passes did) and
|
||||
/// discards any captured state no materialise claimed. Skipping it on a throw would leave that state for a
|
||||
/// later surgical re-add of the same id to resurrect. No-op when nothing is pending; never throws. The
|
||||
/// outcome goes out only after the apply (including its completion step) is done.
|
||||
/// </summary>
|
||||
private void FinishRebuild(RebuildAddressSpace msg, IActorRef requester, (bool Degraded, string? Reason) outcome)
|
||||
{
|
||||
try { _sink.CompleteAddressSpaceApply(); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Error(ex, "OpcUaPublish: sink.CompleteAddressSpaceApply threw (correlation={Correlation})", msg.Correlation);
|
||||
}
|
||||
|
||||
// Land what arrived while the plan was in flight on the nodes the apply just created — before the answer,
|
||||
// so "applied" means the deferred first values are on the address space too (review fix C1).
|
||||
ReplayDeferredWrites(_replayScope);
|
||||
_replayScope = ReplayScope.Nothing;
|
||||
|
||||
if (msg.ReportOutcome)
|
||||
requester.Tell(new RebuildCompleted(msg.Correlation, msg.DeploymentId, outcome.Degraded, outcome.Reason));
|
||||
}
|
||||
|
||||
/// <summary>The actor-thread half of a rebuild: install the artifact's authority, apply the plan through the
|
||||
/// sink passes, announce. Returns whether the apply was degraded, and why.</summary>
|
||||
private (bool Degraded, string? Reason) ApplyPlan(PlanReady ready)
|
||||
{
|
||||
var previous = _lastApplied;
|
||||
var recording = _applier!.BeginRecording();
|
||||
try
|
||||
{
|
||||
var result = ApplyPlanCore(ready, out var rebuilt);
|
||||
// What this apply created or reset (see _replayScope). A throwing pass still scopes to what it did.
|
||||
var conditions = AddedConditions(previous, ready.Composition);
|
||||
if (rebuilt)
|
||||
{
|
||||
// Review N3: a full rebuild recreates every SCRIPTED condition fresh — the node manager carries only
|
||||
// NATIVE conditions' state over (OtOpcUaNodeManager.RebuildAddressSpace). The engine's re-assert
|
||||
// (or any transition) handled in the window landed on the discarded instance, so every kept scripted
|
||||
// condition must be replayed onto its new one. Native kept conditions stay "added only".
|
||||
foreach (var a in ready.Composition.EquipmentScriptedAlarms)
|
||||
if (a.Enabled) conditions.Add((AddressSpaceRealm.Uns, a.ScriptedAlarmId));
|
||||
}
|
||||
|
||||
_replayScope = new ReplayScope(rebuilt, recording.Ensured, recording.Reshaped, conditions);
|
||||
return result;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_applier.EndRecording();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The condition ids in <paramref name="next"/> that <paramref name="previous"/> did not materialise:
|
||||
/// enabled scripted alarms (Uns, by ScriptedAlarmId) and native alarms (Raw, at their RawPath).</summary>
|
||||
private static HashSet<(AddressSpaceRealm, string)> AddedConditions(
|
||||
AddressSpaceComposition previous, AddressSpaceComposition next)
|
||||
{
|
||||
var added = ConditionIds(next);
|
||||
added.ExceptWith(ConditionIds(previous));
|
||||
return added;
|
||||
|
||||
static HashSet<(AddressSpaceRealm, string)> ConditionIds(AddressSpaceComposition c)
|
||||
{
|
||||
var ids = new HashSet<(AddressSpaceRealm, string)>();
|
||||
foreach (var a in c.EquipmentScriptedAlarms)
|
||||
if (a.Enabled) ids.Add((AddressSpaceRealm.Uns, a.ScriptedAlarmId));
|
||||
foreach (var t in c.RawTags)
|
||||
if (t.Alarm is not null) ids.Add((AddressSpaceRealm.Raw, t.NodeId));
|
||||
return ids;
|
||||
}
|
||||
}
|
||||
|
||||
private (bool Degraded, string? Reason) ApplyPlanCore(PlanReady ready, out bool rebuilt)
|
||||
{
|
||||
rebuilt = false;
|
||||
var msg = ready.Request;
|
||||
using var span = OtOpcUaTelemetry.StartAddressSpaceRebuildSpan();
|
||||
span?.SetTag("otopcua.correlation_id", msg.Correlation.ToString());
|
||||
|
||||
try
|
||||
{
|
||||
// An in-hand artifact (boot-from-cache / FetchAndCache) wins: the host already has the
|
||||
// cached/fetched bytes, and the ConfigDb read the DeploymentId path would do is exactly what
|
||||
// is unreachable during the outage this exists to survive — and is simply absent on a
|
||||
// driver-only node with no ConfigDb. Otherwise, when a ConfigDb is present (Direct mode),
|
||||
// prefer the artifact of the deployment the host just applied — at apply time it is not yet
|
||||
// Sealed, so LoadLatestArtifact would return the PREVIOUS revision and materialise a stale
|
||||
// composition (variables that don't match the SubscribeBulk refs); fall back to latest-sealed
|
||||
// only for legacy callers that carry neither. With NO bytes AND no ConfigDb there is nothing
|
||||
// to load, so yield Array.Empty<byte>() — the guard below reads that as "no answer" and
|
||||
// abandons the rebuild, holding last-known-good rather than wiping the address space.
|
||||
var artifact = msg.Artifact
|
||||
?? (_dbFactory is not null
|
||||
? (msg.DeploymentId is { } depId ? LoadArtifact(depId) : LoadLatestArtifact())
|
||||
: Array.Empty<byte>());
|
||||
|
||||
// An artifact we could not obtain is NOT an empty configuration. Parsing zero
|
||||
// bytes yields an empty composition, which the planner diffs against the live one as a
|
||||
// PureRemove and the applier then faithfully tears down: a transient ConfigDb blip empties the
|
||||
// served address space. Nothing legitimate produces a zero-length blob (an operator who really
|
||||
// deletes everything still deploys a JSON document with empty arrays), so the only honest
|
||||
// reading of "no bytes" is "no answer" — hold the last-known-good address space and let the
|
||||
// next deploy (or the next boot) supply a real one. Returning here also leaves _lastApplied
|
||||
// intact, so the retry diffs against what is actually materialised.
|
||||
if (artifact is null or { Length: 0 })
|
||||
{
|
||||
if (_hasAppliedComposition)
|
||||
_log.Warning(
|
||||
"OpcUaPublish: no usable artifact for deployment {Id} (correlation={Correlation}) — KEEPING the currently served address space rather than tearing it down",
|
||||
msg.DeploymentId, msg.Correlation);
|
||||
else
|
||||
_log.Debug(
|
||||
"OpcUaPublish: no artifact to materialise yet (deployment={Id}, correlation={Correlation})",
|
||||
msg.DeploymentId, msg.Correlation);
|
||||
return (true, "no usable artifact reached the address-space apply; the previously served address space was kept");
|
||||
}
|
||||
|
||||
// Parse the artifact ONCE for this rebuild. Every consumer below (data-plane authority, then the
|
||||
// composition) reads this one JsonDocument instead of rebuilding it per question — the rebuild
|
||||
// used to pay a full parse-and-throw-away per static call. Disposed when the handler returns.
|
||||
//
|
||||
// Open() throws on bytes that are not valid JSON, where the static wrappers swallowed to an
|
||||
// EMPTY result — and an empty composition is not a benign no-op here: the planner diffs it as a
|
||||
// PureRemove and the applier tears the served address space down. So the throw is the honest
|
||||
// outcome, and it lands in this method's catch below: logged at Error, _lastApplied untouched,
|
||||
// last-known-good still served. Same "no answer ≠ empty configuration" rule as the guard
|
||||
// immediately above, applied to bytes that arrived unreadable rather than absent.
|
||||
using var parsed = DeploymentArtifact.Open(artifact);
|
||||
foreach (var inconsistency in ready.Inconsistencies)
|
||||
_log.Warning("OpcUaPublish {Node}: cross-cluster binding — {Message}", _localNode, inconsistency);
|
||||
|
||||
// install this artifact's API keys + ACL grants BEFORE the address-space work
|
||||
// below, and deliberately ahead of the `plan.IsEmpty` early-return: an artifact that revokes a
|
||||
@@ -595,13 +1122,10 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
// feeding this after that return would leave a revoked key authenticating until some unrelated
|
||||
// structural change happened to redeploy. Authorization and topology change on their own
|
||||
// schedules.
|
||||
FeedDataPlaneAuthority(parsed, artifact);
|
||||
if (ready.Authority is not null) FeedDataPlaneAuthority(ready.Authority, ready.Artifact);
|
||||
|
||||
var composition = _localNode is { } ln
|
||||
? parsed.ParseComposition(ln.Value,
|
||||
inconsistency => _log.Warning("OpcUaPublish {Node}: cross-cluster binding — {Message}", ln, inconsistency))
|
||||
: parsed.ParseComposition();
|
||||
var plan = AddressSpacePlanner.Compute(_lastApplied, composition);
|
||||
var composition = ready.Composition;
|
||||
var plan = ready.Plan;
|
||||
|
||||
if (plan.IsEmpty)
|
||||
{
|
||||
@@ -610,7 +1134,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
// The composition is unchanged but the last apply of it was a degraded
|
||||
// rebuild: re-ensure every node (each Ensure* no-ops on a node that stands) so the
|
||||
// re-deploy actually repairs what that rebuild could not place.
|
||||
var repairFailed = _applier.MaterialiseHierarchy(composition)
|
||||
var repairFailed = _applier!.MaterialiseHierarchy(composition)
|
||||
+ MaterialiseNodes(plan, composition, fullComposition: true);
|
||||
if (repairFailed > 0)
|
||||
{
|
||||
@@ -632,7 +1156,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
return (false, null);
|
||||
}
|
||||
|
||||
var outcome = _applier.Apply(plan);
|
||||
var outcome = _applier!.Apply(plan);
|
||||
rebuilt = outcome.RebuildCalled;
|
||||
|
||||
// Sum swallowed per-node materialise failures across every pass together with Apply's own
|
||||
// removal-pass tally (archreview 01/S-1). A degraded apply used to vanish into per-node
|
||||
@@ -725,8 +1250,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Run the five node-materialising applier passes — Raw subtree, UNS references, scripted alarms,
|
||||
/// equipment tags, equipment VirtualTags — either over the FULL composition (after a rebuild) or over
|
||||
/// Run the four node-materialising applier passes — Raw subtree, UNS references, scripted alarms,
|
||||
/// equipment VirtualTags — either over the FULL composition (after a rebuild) or over
|
||||
/// just this plan's delta (surgical deploys). <c>MaterialiseHierarchy</c> is NOT one of them:
|
||||
/// the caller always runs it in full, because parents must exist before any add.
|
||||
/// </summary>
|
||||
@@ -809,21 +1334,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
if (addedAlarms.Count > 0) failedNodes += applier.MaterialiseScriptedAlarms(composition, addedAlarms);
|
||||
}
|
||||
|
||||
// Equipment-namespace tags get their own pass: ensures each signal's Variable (and any
|
||||
// FolderPath sub-folder) exists under its already-materialised equipment folder so
|
||||
// clients can browse them. Live values are pushed by DriverHostActor.ForwardBatchToMux after
|
||||
// each subscription cycle; variables show BadWaitingForInitialData only until the first
|
||||
// publish interval fires.
|
||||
if (fullComposition)
|
||||
{
|
||||
failedNodes += applier.MaterialiseEquipmentTags(composition);
|
||||
}
|
||||
else if (plan.AddedEquipmentTags.Count > 0)
|
||||
{
|
||||
failedNodes += applier.MaterialiseEquipmentTags(composition, plan.AddedEquipmentTags);
|
||||
}
|
||||
|
||||
// Equipment-namespace VirtualTags get their own pass right after the equipment tags:
|
||||
// Equipment-namespace VirtualTags get their own pass right after the scripted alarms:
|
||||
// ensures each computed signal's Variable (and any FolderPath sub-folder) exists under its
|
||||
// equipment folder with a folder-scoped NodeId. VirtualTagHostActor.OnResult pushes live
|
||||
// values once the first dependency update arrives; until then variables show BadWaitingForInitialData.
|
||||
@@ -881,17 +1392,15 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
/// the safe direction for availability but means a revocation carried by an artifact this node
|
||||
/// could not decode has not taken effect. It is logged at Error for exactly that reason.
|
||||
/// </remarks>
|
||||
/// <param name="parsed">The rebuild's single parse of the artifact, read for the keys + grants.</param>
|
||||
/// <param name="projection">The keys + grants decoded (off-thread) from the rebuild's single parse of the artifact.</param>
|
||||
/// <param name="artifactBytes">The RAW artifact bytes — hashed for the revision id, which must be
|
||||
/// SHA-256 of the exact blob (see below); never re-parsed here.</param>
|
||||
private void FeedDataPlaneAuthority(ParsedDeploymentArtifact parsed, byte[] artifactBytes)
|
||||
private void FeedDataPlaneAuthority(DataPlaneAuthorityProjection projection, byte[] artifactBytes)
|
||||
{
|
||||
if (_dataPlaneAuthority is null) return;
|
||||
|
||||
try
|
||||
{
|
||||
var nodeId = _localNode?.Value ?? string.Empty;
|
||||
var projection = parsed.ParseDataPlaneAuthority(nodeId);
|
||||
// The revision hash is SHA-256 of the artifact bytes — the same derivation ConfigComposer uses,
|
||||
// which is what lets a node recognise a re-applied revision without the hash riding the wire.
|
||||
// Hashed off the RAW bytes for exactly that reason: a re-serialisation of the parsed document
|
||||
@@ -936,46 +1445,6 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Read a specific deployment's artifact blob from ConfigDb (the one just applied,
|
||||
/// which may not be Sealed yet). Empty array on any failure — <see cref="HandleRebuild"/> treats that as
|
||||
/// "no answer" and abandons the rebuild, leaving the served address space untouched.</summary>
|
||||
private byte[] LoadArtifact(DeploymentId deploymentId)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var db = _dbFactory!.CreateDbContext();
|
||||
return db.Deployments.AsNoTracking()
|
||||
.Where(d => d.DeploymentId == deploymentId.Value)
|
||||
.Select(d => d.ArtifactBlob)
|
||||
.FirstOrDefault() ?? Array.Empty<byte>();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Warning(ex, "OpcUaPublish: failed to load artifact for deployment {Id}; the rebuild is abandoned", deploymentId);
|
||||
return Array.Empty<byte>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Read the most recent <c>Sealed</c> deployment's artifact blob from ConfigDb.
|
||||
/// Empty array on any failure — see <see cref="LoadArtifact"/> for how that is handled.</summary>
|
||||
private byte[] LoadLatestArtifact()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var db = _dbFactory!.CreateDbContext();
|
||||
return db.Deployments.AsNoTracking()
|
||||
.Where(d => d.Status == Configuration.Enums.DeploymentStatus.Sealed)
|
||||
.OrderByDescending(d => d.SealedAtUtc)
|
||||
.Select(d => d.ArtifactBlob)
|
||||
.FirstOrDefault() ?? Array.Empty<byte>();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Warning(ex, "OpcUaPublish: failed to load latest deployment artifact; the rebuild is abandoned");
|
||||
return Array.Empty<byte>();
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleServiceLevelChanged(ServiceLevelChanged msg)
|
||||
{
|
||||
// Always publish the FIRST computed level, even if it equals the byte-default 0. Otherwise a
|
||||
|
||||
@@ -86,6 +86,21 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
|
||||
/// <param name="Plans">The desired Equipment-namespace scripted-alarm plans.</param>
|
||||
public sealed record ApplyScriptedAlarms(IReadOnlyList<EquipmentScriptedAlarmPlan> Plans);
|
||||
|
||||
/// <summary>
|
||||
/// Told to the parent from <see cref="PostRestart"/>. A supervision restart is otherwise invisible to the
|
||||
/// parent, and the new incarnation has an EMPTY engine (factory Props) and no mux interest, so until the
|
||||
/// next <see cref="ApplyScriptedAlarms"/> its inputs stop and every operator command is dropped as not
|
||||
/// owned. The parent (<c>DriverHostActor</c>) answers by re-sending the last plans it applied, mirroring
|
||||
/// how a driver child's <c>ReprovisionRequested</c> is answered.
|
||||
/// </summary>
|
||||
public sealed class ScriptedAlarmHostRestarted
|
||||
{
|
||||
/// <summary>The single instance.</summary>
|
||||
public static readonly ScriptedAlarmHostRestarted Instance = new();
|
||||
|
||||
private ScriptedAlarmHostRestarted() { }
|
||||
}
|
||||
|
||||
/// <summary>Marshals an engine emission off the engine's worker thread onto the actor thread.
|
||||
/// Carries the <see cref="ScriptedAlarmEvent"/> the engine raised on <c>OnEvent</c>.</summary>
|
||||
private sealed record EngineEmission(ScriptedAlarmEvent Event);
|
||||
@@ -384,9 +399,10 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
|
||||
// subscriptions exist, so any DependencyValueChanged the mux delivers will be observed by the
|
||||
// engine. RegisterInterest replaces a subscriber's prior interest set (see DependencyMuxActor),
|
||||
// so a re-Apply with a fresh union simply supersedes the old one — no explicit unregister needed.
|
||||
// ReplayRetained: the mux replays the last value it saw for each ref, so a fresh engine (a
|
||||
// supervision restart builds one around an EMPTY upstream) converges immediately instead of
|
||||
// waiting for every input to change again.
|
||||
// ReplayRetained: the mux replays the last value it saw for each ref, so a freshly loaded engine
|
||||
// converges immediately instead of waiting for every input to change again. That includes the engine
|
||||
// a supervision restart builds around an EMPTY upstream, but only once the parent has re-sent
|
||||
// ApplyScriptedAlarms in answer to ScriptedAlarmHostRestarted (PostRestart); nothing reloads it before.
|
||||
_mux?.Tell(new DependencyMuxActor.RegisterInterest(msg.DepRefs, Self, ReplayRetained: true));
|
||||
// Prune the upstream's retained snapshots to the loaded set (R02:L3): a path no alarm references
|
||||
// any more will never be pushed again, so its last value is dead weight for the process lifetime.
|
||||
@@ -791,6 +807,15 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
|
||||
base.PreRestart(reason, message);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>Tells the parent <see cref="ScriptedAlarmHostRestarted"/> so it re-sends the last
|
||||
/// <see cref="ApplyScriptedAlarms"/>: the new incarnation has nothing loaded and no mux interest.</remarks>
|
||||
protected override void PostRestart(Exception reason)
|
||||
{
|
||||
base.PostRestart(reason);
|
||||
Context.Parent.Tell(ScriptedAlarmHostRestarted.Instance);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void PostStop()
|
||||
{
|
||||
|
||||
@@ -609,7 +609,6 @@ public static class ServiceCollectionExtensions
|
||||
healthPublisher: healthPublisher,
|
||||
virtualTagEvaluator: virtualTagEvaluator,
|
||||
historyWriter: historyWriter,
|
||||
loggerFactory: loggerFactory,
|
||||
scriptRootLogger: scriptRootLogger,
|
||||
invokerFactory: invokerFactory,
|
||||
deploymentArtifactCache: deploymentArtifactCache,
|
||||
@@ -638,7 +637,10 @@ public static class ServiceCollectionExtensions
|
||||
sharesAlarmQueueWithLocal: BuildAlarmQueuePeerPredicate(
|
||||
resolver.GetService<IConfiguration>(), roleInfo, replicationPeerHost),
|
||||
// The HistoryUpdate verdict (closed until the first redundancy snapshot).
|
||||
historyUpdateRoleView: resolver.GetService<IHistoryUpdateRoleView>()),
|
||||
historyUpdateRoleView: resolver.GetService<IHistoryUpdateRoleView>(),
|
||||
// The node-wide Scripting section (bound + clamped in Program.cs) for the scripted-alarm
|
||||
// engine. Unbound in test graphs ⇒ null ⇒ the compiled defaults.
|
||||
scripting: resolver.GetService<ScriptingOptions>()),
|
||||
DriverHostActorName);
|
||||
registry.Register<DriverHostActorKey>(driverHost);
|
||||
|
||||
|
||||
@@ -15,11 +15,9 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
|
||||
/// DriverHostActor forwards driver-child value publishes to this mux (one mux per driver-role
|
||||
/// node). The ONLY driver ingress is the batched
|
||||
/// <see cref="DriverInstanceActor.AttributeValueBatchPublished"/> — one envelope per driver
|
||||
/// production burst, which the mux filters per item. The per-value
|
||||
/// <see cref="DriverInstanceActor.AttributeValuePublished"/> receive has <b>no production sender left
|
||||
/// at all</b>: the comms-loss fan (<c>DriverHostActor.FanCommsLossToDependencyMux</c>) was its last
|
||||
/// one and now fabricates an <c>AttributeValueBatchPublished</c> instead. The receive is kept for
|
||||
/// tests and direct senders — treat it as dead in production.
|
||||
/// production burst, which the mux filters per item. The comms-loss fan
|
||||
/// (<c>DriverHostActor.FanCommsLossToDependencyMux</c>) fabricates one too. There is no per-value
|
||||
/// ingress: a single value is a one-item batch (tests build one with <c>ValuePublishes.Single</c>).
|
||||
///
|
||||
/// <b>The mux emits ONLY batches</b>, so a subscriber's
|
||||
/// batch receive is its complete hot path. Every subscriber keeps a per-value
|
||||
@@ -52,11 +50,9 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
|
||||
/// <b>Source removal.</b> The mux records which driver instance last published each retained ref;
|
||||
/// <see cref="SourceRemoved"/> evicts every value that driver produced, so a removed driver's last
|
||||
/// Good is never replayed as current. A comms-loss fan (a Bad batch) already overwrites the retained
|
||||
/// value with Bad. ⚠️ As of fix round 1 no production actor sends <see cref="SourceRemoved"/> yet —
|
||||
/// <c>DriverHostActor</c> (outside the scripting lane) must Tell it when it stops / removes a driver
|
||||
/// child. Until then a removed driver's last value can still be replayed to a replay-opting
|
||||
/// subscriber that re-registers for that ref while another replay-opting subscriber holds it, or
|
||||
/// within the grace window.
|
||||
/// value with Bad. <c>DriverHostActor</c> sends <see cref="SourceRemoved"/> wherever a driver object
|
||||
/// ends (a deploy removal, the stop half of a config-change respawn, an operator Restart), and never
|
||||
/// on a supervision restart, which re-uses the same driver.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class DependencyMuxActor : ReceiveActor, IWithTimers
|
||||
@@ -154,7 +150,6 @@ public sealed class DependencyMuxActor : ReceiveActor, IWithTimers
|
||||
Receive<UnregisterInterest>(msg => RemoveSubscriber(msg.Subscriber));
|
||||
Receive<SourceRemoved>(OnSourceRemoved);
|
||||
Receive<SweepReleased>(_ => OnSweep());
|
||||
Receive<DriverInstanceActor.AttributeValuePublished>(OnAttributeValuePublished);
|
||||
Receive<DriverInstanceActor.AttributeValueBatchPublished>(OnAttributeValueBatchPublished);
|
||||
Receive<Terminated>(msg => RemoveSubscriber(msg.ActorRef));
|
||||
}
|
||||
@@ -265,30 +260,6 @@ public sealed class DependencyMuxActor : ReceiveActor, IWithTimers
|
||||
_log.Debug("DependencyMux: source {Driver} removed — evicted {Count} retained value(s)", msg.DriverInstanceId, evicted);
|
||||
}
|
||||
|
||||
private void OnAttributeValuePublished(DriverInstanceActor.AttributeValuePublished msg)
|
||||
{
|
||||
if (!_byRef.TryGetValue(msg.FullReference, out var entry))
|
||||
{
|
||||
// Nothing is registered for (or retaining) this ref — drop. Common in normal operation; the address
|
||||
// space carries thousands of tags and only a fraction feed virtual-tag expressions.
|
||||
return;
|
||||
}
|
||||
var dep = new VirtualTagActor.DependencyValueChanged(msg.FullReference, msg.Value, msg.TimestampUtc, msg.StatusCode);
|
||||
if (entry.Retains)
|
||||
{
|
||||
entry.Last = dep;
|
||||
entry.LastSource = msg.DriverInstanceId;
|
||||
}
|
||||
foreach (var sub in entry.Subscribers)
|
||||
{
|
||||
// A single-item batch, so the mux emits exactly ONE message shape and every subscriber needs
|
||||
// only the batch receive on its hot path. One list per subscriber: batch ownership transfers
|
||||
// to the receiver, so a shared list must never cross mailboxes. Nothing in production reaches
|
||||
// this path (see the class doc) — the extra allocation is noise on a test-only seam.
|
||||
sub.Tell(new VirtualTagActor.DependencyValueBatch(new[] { dep }));
|
||||
}
|
||||
}
|
||||
|
||||
private void OnAttributeValueBatchPublished(DriverInstanceActor.AttributeValueBatchPublished msg)
|
||||
{
|
||||
// Nothing registered and nothing retained — skip the whole burst rather than probing an empty map per item.
|
||||
|
||||
Reference in New Issue
Block a user