docs(xmldoc): fill missing XML docs + strip tracking-ID comments across src
v2-ci / build (push) Failing after 41s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
v2-ci / build (push) Failing after 41s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
Adds <summary>/<param>/<returns>/<inheritdoc> where missing and removes project bookkeeping IDs (task/tracking refs) from shipped code comments, so the docs read cleanly and CommentChecker is quiet except for known false positives (PLC/protocol terms, event/IEqualityComparer inheritdoc). Doc/comment-only; no logic changed; solution builds clean.
This commit is contained in:
@@ -30,15 +30,6 @@ public abstract class AbCipCommandBase : DriverCommandBase
|
||||
public int TimeoutMs { get; init; } = 5000;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// The getter validates <see cref="TimeoutMs"/> (Driver.AbCip.Cli-004) — a zero or
|
||||
/// negative <c>--timeout-ms</c> would otherwise propagate as a non-positive
|
||||
/// <see cref="TimeSpan"/> into the driver. The <c>init</c> accessor is unreachable
|
||||
/// because CliFx binds <see cref="TimeoutMs"/> rather than <c>Timeout</c>; it throws
|
||||
/// <see cref="NotSupportedException"/> so an object-initializer assignment
|
||||
/// (<c>new ReadCommand { Timeout = ... }</c>) fails fast instead of being silently
|
||||
/// discarded (Driver.AbCip.Cli-006).
|
||||
/// </remarks>
|
||||
public override TimeSpan Timeout
|
||||
{
|
||||
get
|
||||
@@ -60,6 +51,7 @@ public abstract class AbCipCommandBase : DriverCommandBase
|
||||
/// probe loop would race the operator's own reads.
|
||||
/// </summary>
|
||||
/// <param name="tags">The list of tag definitions to include in the options.</param>
|
||||
/// <returns>The constructed <see cref="AbCipDriverOptions"/>.</returns>
|
||||
protected AbCipDriverOptions BuildOptions(IReadOnlyList<AbCipTagDefinition> tags) => new()
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(
|
||||
|
||||
@@ -57,8 +57,8 @@ public sealed class ProbeCommand : AbCipCommandBase
|
||||
finally
|
||||
{
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
// Driver.AbCip.Cli-005 — flush Serilog before process exit so buffered log
|
||||
// output emitted during driver shutdown is not lost.
|
||||
// Flush Serilog before process exit so buffered log output emitted during
|
||||
// driver shutdown is not lost.
|
||||
FlushLogging();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,8 +26,6 @@ public sealed class ReadCommand : AbCipCommandBase
|
||||
"String / Dt (default DInt). UDT / composite types are not supported here — use a full driver config.")]
|
||||
public AbCipDataType DataType { get; init; } = AbCipDataType.DInt;
|
||||
|
||||
/// <summary>Executes the read operation.</summary>
|
||||
/// <param name="console">The console for output and cancellation handling.</param>
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
@@ -54,8 +52,8 @@ public sealed class ReadCommand : AbCipCommandBase
|
||||
finally
|
||||
{
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
// Driver.AbCip.Cli-005 — flush Serilog before process exit so buffered log
|
||||
// output emitted during driver shutdown is not lost.
|
||||
// Flush Serilog before process exit so buffered log output emitted during
|
||||
// driver shutdown is not lost.
|
||||
FlushLogging();
|
||||
}
|
||||
}
|
||||
@@ -66,6 +64,7 @@ public sealed class ReadCommand : AbCipCommandBase
|
||||
/// </summary>
|
||||
/// <param name="tagPath">The symbolic tag path.</param>
|
||||
/// <param name="type">The data type.</param>
|
||||
/// <returns>A synthetic tag name combining the tag path and data type.</returns>
|
||||
internal static string SynthesiseTagName(string tagPath, AbCipDataType type)
|
||||
=> $"{tagPath}:{type}";
|
||||
}
|
||||
|
||||
@@ -36,8 +36,6 @@ public sealed class SubscribeCommand : AbCipCommandBase
|
||||
ConfigureLogging();
|
||||
RejectStructure(DataType);
|
||||
ValidateInterval(IntervalMs);
|
||||
// Touch Timeout to surface the --timeout-ms guard (Driver.AbCip.Cli-004) before
|
||||
// we open a driver — fast-fail with a clean CommandException on bad operator input.
|
||||
_ = Timeout;
|
||||
var ct = console.RegisterCancellationHandler();
|
||||
|
||||
@@ -56,10 +54,6 @@ public sealed class SubscribeCommand : AbCipCommandBase
|
||||
{
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
|
||||
// Driver.AbCip.Cli-003 — emit the banner BEFORE wiring OnDataChange so the
|
||||
// main-thread write cannot interleave with poll-thread change-event writes.
|
||||
// TextWriter.WriteLine is not guaranteed thread-safe; once the handler is
|
||||
// attached and SubscribeAsync starts, change events run on the poll thread.
|
||||
await console.Output.WriteLineAsync(
|
||||
$"Subscribed to {TagPath} @ {IntervalMs}ms. Ctrl+C to stop.");
|
||||
|
||||
@@ -90,14 +84,12 @@ public sealed class SubscribeCommand : AbCipCommandBase
|
||||
catch { /* teardown best-effort */ }
|
||||
}
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
// Driver.AbCip.Cli-005 — flush Serilog before process exit so buffered log
|
||||
// lines emitted just before Ctrl+C are not lost on abrupt termination.
|
||||
FlushLogging();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Guards <c>--interval-ms</c> against zero or negative values (Driver.AbCip.Cli-004).
|
||||
/// Guards <c>--interval-ms</c> against zero or negative values.
|
||||
/// A non-positive interval would produce a non-positive <see cref="TimeSpan"/> into
|
||||
/// <c>SubscribeAsync</c>; the CLI should fail fast with an actionable error rather
|
||||
/// than relying on the downstream <c>PollGroupEngine</c> to clamp the value.
|
||||
|
||||
@@ -64,7 +64,7 @@ public sealed class WriteCommand : AbCipCommandBase
|
||||
finally
|
||||
{
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
// Driver.AbCip.Cli-005 — flush Serilog before process exit so buffered log
|
||||
// Flush Serilog before process exit so buffered log
|
||||
// output emitted during driver shutdown is not lost.
|
||||
FlushLogging();
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ public sealed class ProbeCommand : AbLegacyCommandBase
|
||||
finally
|
||||
{
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
// Driver.AbLegacy.Cli-008 — flush Serilog before process exit so buffered
|
||||
// Flush Serilog before process exit so buffered
|
||||
// log output emitted during driver shutdown is not lost.
|
||||
FlushLogging();
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ public sealed class ReadCommand : AbLegacyCommandBase
|
||||
finally
|
||||
{
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
// Driver.AbLegacy.Cli-008 — flush Serilog before process exit so buffered
|
||||
// Flush Serilog before process exit so buffered
|
||||
// log output emitted during driver shutdown is not lost.
|
||||
FlushLogging();
|
||||
}
|
||||
@@ -61,6 +61,7 @@ public sealed class ReadCommand : AbLegacyCommandBase
|
||||
/// <summary>Tag-name key the driver uses internally. Address+type is already unique.</summary>
|
||||
/// <param name="address">The PCCC file address.</param>
|
||||
/// <param name="type">The data type of the address.</param>
|
||||
/// <returns>The synthesised tag-name key combining <paramref name="address"/> and <paramref name="type"/>.</returns>
|
||||
internal static string SynthesiseTagName(string address, AbLegacyDataType type)
|
||||
=> $"{address}:{type}";
|
||||
}
|
||||
|
||||
@@ -93,10 +93,6 @@ public sealed class SubscribeCommand : AbLegacyCommandBase
|
||||
catch { /* teardown best-effort */ }
|
||||
}
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
// Driver.AbLegacy.Cli-008 — flush Serilog before process exit so buffered
|
||||
// log output emitted during driver shutdown is not lost. Particularly
|
||||
// important here: subscribe runs until Ctrl+C; without this, lines logged
|
||||
// during shutdown can be silently dropped.
|
||||
FlushLogging();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,8 +62,6 @@ public sealed class WriteCommand : AbLegacyCommandBase
|
||||
finally
|
||||
{
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
// Driver.AbLegacy.Cli-008 — flush Serilog before process exit so buffered
|
||||
// log output emitted during driver shutdown is not lost.
|
||||
FlushLogging();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ public static class SnapshotFormatter
|
||||
/// </summary>
|
||||
/// <param name="tagName">The tag name to include in the output.</param>
|
||||
/// <param name="snapshot">The data value snapshot to format.</param>
|
||||
/// <returns>The multi-line rendered snapshot.</returns>
|
||||
public static string Format(string tagName, DataValueSnapshot snapshot)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(snapshot);
|
||||
@@ -42,6 +43,7 @@ public static class SnapshotFormatter
|
||||
/// </summary>
|
||||
/// <param name="tagName">The tag name to include in the output.</param>
|
||||
/// <param name="result">The write result to format.</param>
|
||||
/// <returns>The rendered write-result line.</returns>
|
||||
public static string FormatWrite(string tagName, WriteResult result)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
@@ -54,6 +56,7 @@ public static class SnapshotFormatter
|
||||
/// </summary>
|
||||
/// <param name="tagNames">The list of tag names to include as rows.</param>
|
||||
/// <param name="snapshots">The list of data value snapshots to format.</param>
|
||||
/// <returns>The rendered table as a multi-line string.</returns>
|
||||
public static string FormatTable(
|
||||
IReadOnlyList<string> tagNames, IReadOnlyList<DataValueSnapshot> snapshots)
|
||||
{
|
||||
|
||||
@@ -27,7 +27,6 @@ public sealed class ProbeCommand : FocasCommandBase
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
// Driver.FOCAS.Cli-003: validate numeric option ranges before any driver work.
|
||||
ValidateOptions();
|
||||
var ct = console.RegisterCancellationHandler();
|
||||
|
||||
@@ -39,9 +38,6 @@ public sealed class ProbeCommand : FocasCommandBase
|
||||
Writable: false);
|
||||
var options = BuildOptions([probeTag]);
|
||||
|
||||
// Driver.FOCAS.Cli-004: `await using` is the sole disposal mechanism — FocasDriver.DisposeAsync
|
||||
// already invokes ShutdownAsync, so a redundant explicit ShutdownAsync(CancellationToken.None)
|
||||
// in a finally block ran shutdown twice. The await-using on the next line is enough.
|
||||
await using var driver = new FocasDriver(options, DriverInstanceId);
|
||||
try
|
||||
{
|
||||
@@ -59,9 +55,6 @@ public sealed class ProbeCommand : FocasCommandBase
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Driver.FOCAS.Cli-006: flush Serilog before process exit so buffered log output
|
||||
// emitted during driver shutdown is not silently dropped (matching DriverCommandBase
|
||||
// docs and every sibling CLI — Modbus / AbCip / AbLegacy / TwinCAT).
|
||||
FlushLogging();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,12 +22,11 @@ public sealed class ReadCommand : FocasCommandBase
|
||||
"Bit / Byte / Int16 / Int32 / Float32 / Float64 / String (default Int16).")]
|
||||
public FocasDataType DataType { get; init; } = FocasDataType.Int16;
|
||||
|
||||
/// <summary>Executes the read command against the FOCAS device.</summary>
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
// Driver.FOCAS.Cli-003: validate numeric option ranges before any driver work.
|
||||
// Validate numeric option ranges before any driver work.
|
||||
ValidateOptions();
|
||||
var ct = console.RegisterCancellationHandler();
|
||||
|
||||
@@ -40,7 +39,7 @@ public sealed class ReadCommand : FocasCommandBase
|
||||
Writable: false);
|
||||
var options = BuildOptions([tag]);
|
||||
|
||||
// Driver.FOCAS.Cli-004: `await using` is the sole disposal mechanism — FocasDriver.DisposeAsync
|
||||
// `await using` is the sole disposal mechanism — FocasDriver.DisposeAsync
|
||||
// already invokes ShutdownAsync, so a redundant explicit ShutdownAsync(CancellationToken.None)
|
||||
// in a finally block ran shutdown twice. The await-using on the next line is enough.
|
||||
await using var driver = new FocasDriver(options, DriverInstanceId);
|
||||
@@ -52,7 +51,7 @@ public sealed class ReadCommand : FocasCommandBase
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Driver.FOCAS.Cli-006: flush Serilog before process exit so buffered log output
|
||||
// Flush Serilog before process exit so buffered log output
|
||||
// emitted during driver shutdown is not silently dropped (matching DriverCommandBase
|
||||
// docs and every sibling CLI — Modbus / AbCip / AbLegacy / TwinCAT).
|
||||
FlushLogging();
|
||||
@@ -62,6 +61,7 @@ public sealed class ReadCommand : FocasCommandBase
|
||||
/// <summary>Constructs a tag name from address and data type.</summary>
|
||||
/// <param name="address">The FOCAS address.</param>
|
||||
/// <param name="type">The data type.</param>
|
||||
/// <returns>The synthesised tag name in <c>address:type</c> form.</returns>
|
||||
internal static string SynthesiseTagName(string address, FocasDataType type)
|
||||
=> $"{address}:{type}";
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ public sealed class SubscribeCommand : FocasCommandBase
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
// Driver.FOCAS.Cli-003: validate numeric option ranges (including the subscribe-only
|
||||
// Validate numeric option ranges (including the subscribe-only
|
||||
// --interval-ms) before any driver work so a zero/negative interval surfaces as a
|
||||
// clean CommandException rather than a tight-spinning poll loop.
|
||||
ValidateOptions(IntervalMs);
|
||||
@@ -44,12 +44,12 @@ public sealed class SubscribeCommand : FocasCommandBase
|
||||
Writable: false);
|
||||
var options = BuildOptions([tag]);
|
||||
|
||||
// Driver.FOCAS.Cli-004: `await using` is the sole driver-disposal mechanism — FocasDriver.DisposeAsync
|
||||
// `await using` is the sole driver-disposal mechanism — FocasDriver.DisposeAsync
|
||||
// already invokes ShutdownAsync, so a redundant ShutdownAsync(CancellationToken.None) in finally
|
||||
// ran shutdown twice. Only UnsubscribeAsync stays in the finally block — that's a subscription
|
||||
// lifecycle concern that is not part of driver disposal.
|
||||
await using var driver = new FocasDriver(options, DriverInstanceId);
|
||||
// Driver.FOCAS.Cli-002: serialize console writes from the PollGroupEngine background
|
||||
// Serialize console writes from the PollGroupEngine background
|
||||
// thread so overlapping poll ticks (and the "Subscribed to ..." banner from the CliFx
|
||||
// invocation thread) can't interleave partial lines.
|
||||
var writeLock = new object();
|
||||
@@ -58,7 +58,7 @@ public sealed class SubscribeCommand : FocasCommandBase
|
||||
{
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
|
||||
// Driver.FOCAS.Cli-002: route every data-change event to the CliFx console (not
|
||||
// Route every data-change event to the CliFx console (not
|
||||
// System.Console — the analyzer flags it + IConsole is the testable abstraction).
|
||||
// The handler is synchronous because OnDataChange is raised from a driver
|
||||
// background thread; the IConsole.Output writer is not documented as thread-safe
|
||||
@@ -89,7 +89,7 @@ public sealed class SubscribeCommand : FocasCommandBase
|
||||
|
||||
handle = await driver.SubscribeAsync([tagName], TimeSpan.FromMilliseconds(IntervalMs), ct);
|
||||
|
||||
// Driver.FOCAS.Cli-002: hold the lock around the banner write so the first
|
||||
// Hold the lock around the banner write so the first
|
||||
// poll-driven change line from the driver tick thread can't interleave with
|
||||
// this banner.
|
||||
lock (writeLock)
|
||||
@@ -110,7 +110,7 @@ public sealed class SubscribeCommand : FocasCommandBase
|
||||
{
|
||||
if (handle is not null)
|
||||
{
|
||||
// Driver.FOCAS.Cli-002: stop the subscription before disposal — UnsubscribeAsync
|
||||
// Stop the subscription before disposal — UnsubscribeAsync
|
||||
// halts the poll-group ticker so no further OnDataChange events fire. The
|
||||
// anonymous handler is never explicitly removed via -=; instead, driver disposal
|
||||
// (via `await using` immediately after this finally) tears down the PollGroupEngine,
|
||||
@@ -119,9 +119,9 @@ public sealed class SubscribeCommand : FocasCommandBase
|
||||
try { await driver.UnsubscribeAsync(handle, CancellationToken.None); }
|
||||
catch { /* teardown best-effort */ }
|
||||
}
|
||||
// Driver.FOCAS.Cli-006: flush Serilog before process exit so buffered log output
|
||||
// emitted during driver shutdown is not silently dropped (matching DriverCommandBase
|
||||
// docs and every sibling CLI — Modbus / AbCip / AbLegacy / TwinCAT).
|
||||
// Flush Serilog before process exit so buffered log output emitted during driver
|
||||
// shutdown is not silently dropped (matching DriverCommandBase docs and every
|
||||
// sibling CLI — Modbus / AbCip / AbLegacy / TwinCAT).
|
||||
FlushLogging();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,9 +33,9 @@ public sealed class WriteCommand : FocasCommandBase
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
// Driver.FOCAS.Cli-003: validate numeric option ranges before any driver work so
|
||||
// a zero/negative port/timeout surfaces as a clean CommandException rather than an
|
||||
// opaque downstream exception.
|
||||
// Validate numeric option ranges before any driver work so a zero/negative
|
||||
// port/timeout surfaces as a clean CommandException rather than an opaque
|
||||
// downstream exception.
|
||||
ValidateOptions();
|
||||
var ct = console.RegisterCancellationHandler();
|
||||
|
||||
@@ -50,8 +50,8 @@ public sealed class WriteCommand : FocasCommandBase
|
||||
|
||||
var parsed = ParseValue(Value, DataType);
|
||||
|
||||
// Driver.FOCAS.Cli-004: `await using` is the sole disposal mechanism — FocasDriver.DisposeAsync
|
||||
// already invokes ShutdownAsync, so a redundant explicit ShutdownAsync(CancellationToken.None)
|
||||
// `await using` is the sole disposal mechanism — FocasDriver.DisposeAsync already
|
||||
// invokes ShutdownAsync, so a redundant explicit ShutdownAsync(CancellationToken.None)
|
||||
// in a finally block ran shutdown twice. The await-using on the next line is enough.
|
||||
await using var driver = new FocasDriver(options, DriverInstanceId);
|
||||
try
|
||||
@@ -62,16 +62,16 @@ public sealed class WriteCommand : FocasCommandBase
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Driver.FOCAS.Cli-006: flush Serilog before process exit so buffered log output
|
||||
// emitted during driver shutdown is not silently dropped (matching DriverCommandBase
|
||||
// docs and every sibling CLI — Modbus / AbCip / AbLegacy / TwinCAT).
|
||||
// Flush Serilog before process exit so buffered log output emitted during driver
|
||||
// shutdown is not silently dropped (matching DriverCommandBase docs and every
|
||||
// sibling CLI — Modbus / AbCip / AbLegacy / TwinCAT).
|
||||
FlushLogging();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Parse <c>--value</c> per <see cref="FocasDataType"/>, invariant culture throughout.</summary>
|
||||
/// <remarks>
|
||||
/// Driver.FOCAS.Cli-001: numeric parses are wrapped so that malformed input
|
||||
/// Numeric parses are wrapped so that malformed input
|
||||
/// (<see cref="FormatException"/> / <see cref="OverflowException"/>) surfaces
|
||||
/// as a clean <see cref="CliFx.Exceptions.CommandException"/> rather than a raw
|
||||
/// .NET stack trace — matching the friendly message the Bit path already produces.
|
||||
|
||||
@@ -47,6 +47,7 @@ public abstract class FocasCommandBase : DriverCommandBase
|
||||
/// as <c>BadCommunicationError</c>.
|
||||
/// </summary>
|
||||
/// <param name="tags">The tag definitions to include in the driver options.</param>
|
||||
/// <returns>A <see cref="FocasDriverOptions"/> configured with a single device and the supplied tags.</returns>
|
||||
protected FocasDriverOptions BuildOptions(IReadOnlyList<FocasTagDefinition> tags) => new()
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(
|
||||
@@ -62,7 +63,7 @@ public abstract class FocasCommandBase : DriverCommandBase
|
||||
protected string DriverInstanceId => $"focas-cli-{CncHost}:{CncPort}";
|
||||
|
||||
/// <summary>
|
||||
/// Driver.FOCAS.Cli-003: validate numeric option ranges at the CLI boundary so a
|
||||
/// Validates numeric option ranges at the CLI boundary so a
|
||||
/// zero/negative <c>--cnc-port</c>, <c>--timeout-ms</c>, or <c>--interval-ms</c>
|
||||
/// surfaces as a clean <see cref="CliFx.Exceptions.CommandException"/> rather than
|
||||
/// either an opaque downstream exception (invalid <c>focas://host:<n></c> /
|
||||
|
||||
@@ -43,7 +43,7 @@ public sealed class ProbeCommand : ModbusCommandBase
|
||||
var snapshot = await driver.ReadAsync(["__probe"], ct);
|
||||
var health = driver.GetHealth();
|
||||
|
||||
// Driver.Modbus.Cli-006: derive the headline verdict from BOTH the driver state
|
||||
// Derive the headline verdict from BOTH the driver state
|
||||
// AND the probe-read StatusCode so the operator never sees the previous
|
||||
// contradictory pair (`Health: Healthy` over a Bad snapshot line). The bare
|
||||
// driver state is still printed below for diagnostics, but the verdict is what
|
||||
@@ -61,21 +61,21 @@ public sealed class ProbeCommand : ModbusCommandBase
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
// Driver.Modbus.Cli-005: Ctrl+C during InitializeAsync — exit quietly so CliFx
|
||||
// Ctrl+C during InitializeAsync — exit quietly so CliFx
|
||||
// does not render a full stack trace for a user-initiated cancellation.
|
||||
await console.Output.WriteLineAsync("Cancelled.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
// Driver.Modbus.Cli-009: flush Serilog before process exit so buffered log lines
|
||||
// Flush Serilog before process exit so buffered log lines
|
||||
// emitted during driver shutdown are not lost. Matches Driver.AbCip.Cli pattern.
|
||||
FlushLogging();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Driver.Modbus.Cli-006: combine the driver-side <see cref="DriverState"/> with the
|
||||
/// Combine the driver-side <see cref="DriverState"/> with the
|
||||
/// probe snapshot's OPC UA <c>StatusCode</c> into a single headline verdict. Order
|
||||
/// of precedence:
|
||||
/// <list type="number">
|
||||
|
||||
@@ -79,15 +79,15 @@ public sealed class ReadCommand : ModbusCommandBase
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
// Driver.Modbus.Cli-005: Ctrl+C during driver connect/read — exit quietly so
|
||||
// CliFx does not render a full stack trace for a user-initiated cancellation.
|
||||
// Ctrl+C during driver connect/read — exit quietly so CliFx does not render a
|
||||
// full stack trace for a user-initiated cancellation.
|
||||
await console.Output.WriteLineAsync("Cancelled.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
// Driver.Modbus.Cli-009: flush Serilog before process exit so buffered log lines
|
||||
// emitted during driver shutdown are not lost. Matches Driver.AbCip.Cli pattern.
|
||||
// Flush Serilog before process exit so buffered log lines emitted during driver
|
||||
// shutdown are not lost. Matches Driver.AbCip.Cli pattern.
|
||||
FlushLogging();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ public sealed class SubscribeCommand : ModbusCommandBase
|
||||
"BigEndian (default) or WordSwap.")]
|
||||
public ModbusByteOrder ByteOrder { get; init; } = ModbusByteOrder.BigEndian;
|
||||
|
||||
// Driver.Modbus.Cli-001: subscribe previously lacked these three options that read and
|
||||
// subscribe previously lacked these three options that read and
|
||||
// write both expose. Without them, BitInRegister always watches bit 0 and String runs with
|
||||
// StringLength=0, silently producing wrong results for any subscriber using those types.
|
||||
/// <summary>Gets or sets the bit index for type=BitInRegister (0-15, LSB-first).</summary>
|
||||
@@ -63,7 +63,7 @@ public sealed class SubscribeCommand : ModbusCommandBase
|
||||
{
|
||||
ConfigureLogging();
|
||||
ValidateEndpoint();
|
||||
// Driver.Modbus.Cli-010: reject non-positive interval before opening the driver so
|
||||
// Reject non-positive interval before opening the driver so
|
||||
// the operator gets a clean error instead of a confusing PollGroupEngine failure.
|
||||
ValidateInterval(IntervalMs);
|
||||
var ct = console.RegisterCancellationHandler();
|
||||
@@ -82,7 +82,7 @@ public sealed class SubscribeCommand : ModbusCommandBase
|
||||
var options = BuildOptions([tag]);
|
||||
|
||||
await using var driver = new ModbusDriver(options, DriverInstanceId);
|
||||
// Driver.Modbus.Cli-004: serialize console writes from the PollGroupEngine background
|
||||
// Serialize console writes from the PollGroupEngine background
|
||||
// thread so overlapping poll ticks can't interleave partial lines.
|
||||
var writeLock = new object();
|
||||
ISubscriptionHandle? handle = null;
|
||||
@@ -90,7 +90,7 @@ public sealed class SubscribeCommand : ModbusCommandBase
|
||||
{
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
|
||||
// Driver.Modbus.Cli-011: emit the banner BEFORE wiring OnDataChange so the
|
||||
// Emit the banner BEFORE wiring OnDataChange so the
|
||||
// main-thread WriteLineAsync cannot interleave with poll-thread change-event
|
||||
// writes. TextWriter.WriteLine is not guaranteed thread-safe; once the handler
|
||||
// is attached and SubscribeAsync starts, change events run on the poll thread.
|
||||
@@ -102,7 +102,7 @@ public sealed class SubscribeCommand : ModbusCommandBase
|
||||
// analyzer flags it + IConsole is the testable abstraction).
|
||||
driver.OnDataChange += (_, e) =>
|
||||
{
|
||||
// Driver.Modbus.Cli-004: swallow + log write failures so a transient stdout
|
||||
// Swallow + log write failures so a transient stdout
|
||||
// error (closed pipe, IO exception on a redirected stream) cannot tear down
|
||||
// the poll-engine background loop. Without this guard the unhandled
|
||||
// exception would fault the long-running subscribe.
|
||||
@@ -142,7 +142,7 @@ public sealed class SubscribeCommand : ModbusCommandBase
|
||||
catch { /* teardown best-effort */ }
|
||||
}
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
// Driver.Modbus.Cli-009: flush Serilog before process exit so buffered log lines
|
||||
// Flush Serilog before process exit so buffered log lines
|
||||
// emitted just before Ctrl+C (e.g. during UnsubscribeAsync / ShutdownAsync) are
|
||||
// not lost. Matches the pattern in Driver.AbCip.Cli commands.
|
||||
FlushLogging();
|
||||
@@ -150,7 +150,7 @@ public sealed class SubscribeCommand : ModbusCommandBase
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Driver.Modbus.Cli-010: guards <c>--interval-ms</c> against zero or negative values.
|
||||
/// Guards <c>--interval-ms</c> against zero or negative values.
|
||||
/// A non-positive interval would produce a non-positive <see cref="TimeSpan"/> into
|
||||
/// <c>SubscribeAsync</c>; the CLI should fail fast with an actionable error rather
|
||||
/// than relying on the downstream <c>PollGroupEngine</c> to clamp the value.
|
||||
|
||||
@@ -59,7 +59,6 @@ public sealed class WriteCommand : ModbusCommandBase
|
||||
"For type=String: HighByteFirst (standard) or LowByteFirst (DirectLOGIC).")]
|
||||
public ModbusStringByteOrder StringByteOrder { get; init; } = ModbusStringByteOrder.HighByteFirst;
|
||||
|
||||
/// <summary>Executes the write command.</summary>
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
@@ -71,11 +70,6 @@ public sealed class WriteCommand : ModbusCommandBase
|
||||
throw new CliFx.Exceptions.CommandException(
|
||||
$"Region '{Region}' is read-only in the Modbus spec; writes require Coils or HoldingRegisters.");
|
||||
|
||||
// Driver.Modbus.Cli-002: coils are single-bit outputs — only Bool makes sense. A
|
||||
// non-boolean type (e.g. --region Coils --type UInt16) would silently coerce the value
|
||||
// to a boolean via Convert.ToBoolean, landing as ON for any non-zero value, with no
|
||||
// diagnostic. Reject it early so the operator sees a clear error rather than a silent
|
||||
// type-mismatch coerce.
|
||||
if (Region == ModbusRegion.Coils && DataType != ModbusDataType.Bool)
|
||||
throw new CliFx.Exceptions.CommandException(
|
||||
$"Region 'Coils' only supports boolean values (--type Bool). " +
|
||||
@@ -105,15 +99,11 @@ public sealed class WriteCommand : ModbusCommandBase
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
// Driver.Modbus.Cli-005: Ctrl+C during driver connect/write — exit quietly so
|
||||
// CliFx does not render a full stack trace for a user-initiated cancellation.
|
||||
await console.Output.WriteLineAsync("Cancelled.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
// Driver.Modbus.Cli-009: flush Serilog before process exit so buffered log lines
|
||||
// emitted during driver shutdown are not lost. Matches Driver.AbCip.Cli pattern.
|
||||
FlushLogging();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ public abstract class ModbusCommandBase : DriverCommandBase
|
||||
/// command against its own keep-alive reads.
|
||||
/// </summary>
|
||||
/// <param name="tags">The tag definitions to include in the options.</param>
|
||||
/// <returns>A <see cref="ModbusDriverOptions"/> ready to hand to the driver constructor.</returns>
|
||||
protected ModbusDriverOptions BuildOptions(IReadOnlyList<ModbusTagDefinition> tags) => new()
|
||||
{
|
||||
Host = Host,
|
||||
@@ -66,7 +67,7 @@ public abstract class ModbusCommandBase : DriverCommandBase
|
||||
protected string DriverInstanceId => $"modbus-cli-{Host}:{Port}";
|
||||
|
||||
/// <summary>
|
||||
/// Driver.Modbus.Cli-003: validate the endpoint flags at parse time so the operator
|
||||
/// Validate the endpoint flags at parse time so the operator
|
||||
/// gets a clear CliFx error instead of an opaque socket / argument exception thrown
|
||||
/// deep inside the driver. Ranges:
|
||||
/// <list type="bullet">
|
||||
|
||||
@@ -37,11 +37,11 @@ public sealed class ProbeCommand : S7CommandBase
|
||||
Writable: false);
|
||||
var options = BuildOptions([probeTag]);
|
||||
|
||||
// Driver.S7.Cli-004: `await using` is the sole disposal mechanism — S7Driver.DisposeAsync
|
||||
// `await using` is the sole disposal mechanism — S7Driver.DisposeAsync
|
||||
// already invokes ShutdownAsync, so the previous explicit ShutdownAsync(CancellationToken.None)
|
||||
// call in a finally block ran shutdown twice. The await-using on the next line is enough.
|
||||
await using var driver = new S7Driver(options, DriverInstanceId);
|
||||
// Driver.S7.Cli-003: wrap the entire probe sequence so that a refused/unreachable TCP
|
||||
// Wrap the entire probe sequence so that a refused/unreachable TCP
|
||||
// connect still prints the structured Host/CPU/Health lines instead of crashing with a
|
||||
// full .NET stack trace. InitializeAsync sets health to Faulted with the exception
|
||||
// message before re-throwing, so GetHealth() always has something to report.
|
||||
|
||||
@@ -20,7 +20,6 @@ public sealed class ReadCommand : S7CommandBase
|
||||
IsRequired = true)]
|
||||
public string Address { get; init; } = default!;
|
||||
|
||||
// Driver.S7.Cli-002: help text reflects the types the driver currently implements.
|
||||
/// <summary>Gets the data type to interpret the address as.</summary>
|
||||
[CommandOption("type", 't', Description =
|
||||
"Bool / Byte / Int16 / UInt16 / Int32 / UInt32 / Float32 / Int64 / UInt64 / Float64 / String / DateTime (default Int16). " +
|
||||
@@ -50,9 +49,9 @@ public sealed class ReadCommand : S7CommandBase
|
||||
StringLength: StringLength);
|
||||
var options = BuildOptions([tag]);
|
||||
|
||||
// Driver.S7.Cli-004: `await using` is the sole disposal mechanism — S7Driver.DisposeAsync
|
||||
// already invokes ShutdownAsync, so a redundant explicit ShutdownAsync(CancellationToken.None)
|
||||
// in a finally block ran shutdown twice. The await-using on the next line is enough.
|
||||
// `await using` is the sole disposal mechanism — S7Driver.DisposeAsync already invokes
|
||||
// ShutdownAsync, so a redundant explicit ShutdownAsync(CancellationToken.None) in a
|
||||
// finally block would run shutdown twice.
|
||||
await using var driver = new S7Driver(options, DriverInstanceId);
|
||||
try
|
||||
{
|
||||
@@ -62,8 +61,8 @@ public sealed class ReadCommand : S7CommandBase
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
// Driver.S7.Cli-009: Ctrl+C during driver connect/read — exit quietly so
|
||||
// CliFx does not render a full stack trace for a user-initiated cancellation.
|
||||
// Ctrl+C during driver connect/read — exit quietly so CliFx does not render a
|
||||
// full stack trace for a user-initiated cancellation.
|
||||
await console.Output.WriteLineAsync("Cancelled.");
|
||||
}
|
||||
}
|
||||
@@ -71,6 +70,7 @@ public sealed class ReadCommand : S7CommandBase
|
||||
/// <summary>Tag-name key used internally. Address + type is already unique.</summary>
|
||||
/// <param name="address">The S7 address to encode in the tag name.</param>
|
||||
/// <param name="type">The data type to encode in the tag name.</param>
|
||||
/// <returns>A synthetic tag name combining the address and data type.</returns>
|
||||
internal static string SynthesiseTagName(string address, S7DataType type)
|
||||
=> $"{address}:{type}";
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ public sealed class SubscribeCommand : S7CommandBase
|
||||
public string Address { get; init; } = default!;
|
||||
|
||||
/// <summary>Gets the data type of the address.</summary>
|
||||
// Driver.S7.Cli-002: help text reflects the types the driver currently implements.
|
||||
[CommandOption("type", 't', Description =
|
||||
"Bool / Byte / Int16 / UInt16 / Int32 / UInt32 / Float32 / Int64 / UInt64 / Float64 / String / DateTime (default Int16). " +
|
||||
"Wide types (Int64, UInt64, Float64/LReal, String, DateTime) are byte-anchored: address must use the B suffix " +
|
||||
@@ -44,23 +43,13 @@ public sealed class SubscribeCommand : S7CommandBase
|
||||
Writable: false);
|
||||
var options = BuildOptions([tag]);
|
||||
|
||||
// Driver.S7.Cli-004: `await using` is the sole driver-disposal mechanism — S7Driver.DisposeAsync
|
||||
// already invokes ShutdownAsync, so a redundant ShutdownAsync(CancellationToken.None) in finally
|
||||
// ran shutdown twice. Only UnsubscribeAsync stays in the finally block — that's a subscription
|
||||
// lifecycle concern that is not part of driver disposal.
|
||||
await using var driver = new S7Driver(options, DriverInstanceId);
|
||||
// Driver.S7.Cli-011: serialize console writes from the PollGroupEngine background
|
||||
// thread so overlapping poll ticks cannot interleave partial lines on the output.
|
||||
var writeLock = new object();
|
||||
ISubscriptionHandle? handle = null;
|
||||
try
|
||||
{
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
|
||||
// Driver.S7.Cli-007: route every data-change event to the CliFx console (not
|
||||
// System.Console — the analyzer flags it + IConsole is the testable abstraction).
|
||||
// The handler is synchronous because OnDataChange is raised from a driver
|
||||
// background thread; the IConsole.Output writer is thread-safe for line writes.
|
||||
driver.OnDataChange += (_, e) =>
|
||||
{
|
||||
var line = $"[{DateTime.UtcNow:HH:mm:ss.fff}] " +
|
||||
@@ -92,10 +81,6 @@ public sealed class SubscribeCommand : S7CommandBase
|
||||
try { await driver.UnsubscribeAsync(handle, CancellationToken.None); }
|
||||
catch { /* teardown best-effort */ }
|
||||
}
|
||||
// Driver.S7.Cli-010: flush Serilog before process exit so buffered log lines
|
||||
// emitted just before Ctrl+C (e.g. reconnect warnings from the PollGroupEngine)
|
||||
// are not lost on abrupt termination. DriverCommandBase.ConfigureLogging() docs
|
||||
// require this call in a finally block.
|
||||
FlushLogging();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ public sealed class WriteCommand : S7CommandBase
|
||||
public string Address { get; init; } = default!;
|
||||
|
||||
/// <summary>Gets or sets the data type of the value to write.</summary>
|
||||
// Driver.S7.Cli-002: help text reflects the types the driver currently implements.
|
||||
// Help text reflects the types the driver currently implements.
|
||||
[CommandOption("type", 't', Description =
|
||||
"Bool / Byte / Int16 / UInt16 / Int32 / UInt32 / Float32 / Int64 / UInt64 / Float64 / String / DateTime (default Int16). " +
|
||||
"Wide types (Int64, UInt64, Float64/LReal, String, DateTime) are byte-anchored: address must use the B suffix " +
|
||||
@@ -57,7 +57,7 @@ public sealed class WriteCommand : S7CommandBase
|
||||
|
||||
var parsed = ParseValue(Value, DataType);
|
||||
|
||||
// Driver.S7.Cli-004: `await using` is the sole disposal mechanism — S7Driver.DisposeAsync
|
||||
// `await using` is the sole disposal mechanism — S7Driver.DisposeAsync
|
||||
// already invokes ShutdownAsync, so a redundant explicit ShutdownAsync(CancellationToken.None)
|
||||
// in a finally block ran shutdown twice. The await-using on the next line is enough.
|
||||
await using var driver = new S7Driver(options, DriverInstanceId);
|
||||
@@ -69,7 +69,7 @@ public sealed class WriteCommand : S7CommandBase
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
// Driver.S7.Cli-009: Ctrl+C during driver connect/write — exit quietly so
|
||||
// Ctrl+C during driver connect/write — exit quietly so
|
||||
// CliFx does not render a full stack trace for a user-initiated cancellation.
|
||||
await console.Output.WriteLineAsync("Cancelled.");
|
||||
}
|
||||
@@ -77,7 +77,7 @@ public sealed class WriteCommand : S7CommandBase
|
||||
|
||||
/// <summary>Parse <c>--value</c> per <see cref="S7DataType"/>, invariant culture throughout.</summary>
|
||||
/// <remarks>
|
||||
/// Driver.S7.Cli-001: numeric and <see cref="DateTime"/> parses are wrapped so that
|
||||
/// Numeric and <see cref="DateTime"/> parses are wrapped so that
|
||||
/// malformed input (<see cref="FormatException"/> / <see cref="OverflowException"/>)
|
||||
/// surfaces as a clean <see cref="CliFx.Exceptions.CommandException"/> rather than a
|
||||
/// raw .NET stack trace — matching the friendly message the Bool path already produces.
|
||||
|
||||
@@ -52,6 +52,7 @@ public abstract class S7CommandBase : DriverCommandBase
|
||||
/// disabled — CLI runs are one-shot.
|
||||
/// </summary>
|
||||
/// <param name="tags">The tag definitions to include in the options.</param>
|
||||
/// <returns>The constructed <see cref="S7DriverOptions"/>.</returns>
|
||||
protected S7DriverOptions BuildOptions(IReadOnlyList<S7TagDefinition> tags) => new()
|
||||
{
|
||||
Host = Host,
|
||||
@@ -68,7 +69,7 @@ public abstract class S7CommandBase : DriverCommandBase
|
||||
protected string DriverInstanceId => $"s7-cli-{Host}:{Port}";
|
||||
|
||||
/// <summary>
|
||||
/// Driver.S7.Cli-008: validate the endpoint flags at parse time so the operator
|
||||
/// Validate the endpoint flags at parse time so the operator
|
||||
/// gets a clear <see cref="CommandException"/> instead of an opaque socket or
|
||||
/// argument exception thrown deep inside the S7.Net stack.
|
||||
/// <list type="bullet">
|
||||
|
||||
@@ -14,7 +14,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Commands;
|
||||
/// Inherits from <see cref="TwinCATCommandBase"/> rather than
|
||||
/// <see cref="TwinCATTagCommandBase"/> so the <c>--poll-only</c> flag does NOT surface in
|
||||
/// <c>browse --help</c>: browse never subscribes, the flag would be a no-op, and the help
|
||||
/// text would mislead users (Driver.TwinCAT.Cli-004).
|
||||
/// text would mislead users.
|
||||
/// </remarks>
|
||||
[Command("browse", Description = "Enumerate controller symbols via the driver's DiscoverAsync walk.")]
|
||||
public sealed class BrowseCommand : TwinCATCommandBase
|
||||
@@ -91,6 +91,7 @@ public sealed class BrowseCommand : TwinCATCommandBase
|
||||
/// </summary>
|
||||
/// <param name="source">The source collection to filter.</param>
|
||||
/// <param name="prefix">The prefix to filter on, or null to keep everything.</param>
|
||||
/// <returns>The subset of <paramref name="source"/> whose browse name starts with <paramref name="prefix"/>.</returns>
|
||||
internal static List<(string BrowseName, DriverAttributeInfo Info)> FilterByPrefix(
|
||||
IReadOnlyList<(string BrowseName, DriverAttributeInfo Info)> source, string? prefix)
|
||||
=> source
|
||||
@@ -103,6 +104,8 @@ public sealed class BrowseCommand : TwinCATCommandBase
|
||||
/// </summary>
|
||||
/// <param name="matchedCount">The number of matched items.</param>
|
||||
/// <param name="max">The maximum number to show, or 0 for unbounded.</param>
|
||||
/// <returns>The number of items to print — <paramref name="matchedCount"/> if <paramref name="max"/>
|
||||
/// is unbounded, otherwise the smaller of the two.</returns>
|
||||
internal static int PrintLimit(int matchedCount, int max)
|
||||
=> max <= 0 ? matchedCount : Math.Min(max, matchedCount);
|
||||
|
||||
@@ -113,6 +116,7 @@ public sealed class BrowseCommand : TwinCATCommandBase
|
||||
/// authorization is enforced server-side.
|
||||
/// </summary>
|
||||
/// <param name="info">The attribute info to label.</param>
|
||||
/// <returns><c>"RO"</c> when the attribute is view-only; otherwise <c>"RW"</c>.</returns>
|
||||
internal static string AccessTag(DriverAttributeInfo info)
|
||||
=> info.SecurityClass == SecurityClassification.ViewOnly ? "RO" : "RW";
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ public sealed class SubscribeCommand : TwinCATTagCommandBase
|
||||
// write below and with subsequent change events if the PLC pushes faster than a
|
||||
// single console write completes. A TextWriter is not guaranteed thread-safe, so
|
||||
// we serialise every write through a lock to keep output clean for
|
||||
// screen-recorded bug-report timelines (Driver.TwinCAT.Cli-002).
|
||||
// screen-recorded bug-report timelines.
|
||||
var writeLock = new object();
|
||||
|
||||
driver.OnDataChange += (_, e) =>
|
||||
@@ -80,7 +80,7 @@ public sealed class SubscribeCommand : TwinCATTagCommandBase
|
||||
|
||||
handle = await driver.SubscribeAsync([tagName], TimeSpan.FromMilliseconds(IntervalMs), ct);
|
||||
|
||||
// Driver.TwinCAT.Cli-003: derive the banner mechanism from the actual subscription
|
||||
// Derive the banner mechanism from the actual subscription
|
||||
// handle the driver returned, not from --poll-only. The native ADS path tags its
|
||||
// handle with a "twincat-native-sub-*" DiagnosticId; anything else means we landed
|
||||
// on the shared PollGroupEngine. That way the line cannot disagree with what the
|
||||
|
||||
@@ -71,10 +71,11 @@ public sealed class WriteCommand : TwinCATTagCommandBase
|
||||
/// Parse <c>--value</c> per <see cref="TwinCATDataType"/>, invariant culture. Wraps
|
||||
/// <see cref="FormatException"/> and <see cref="OverflowException"/> in a
|
||||
/// <see cref="CliFx.Exceptions.CommandException"/> so the operator sees a clean one-line
|
||||
/// error instead of a raw stack trace (Driver.TwinCAT.Cli-008).
|
||||
/// error instead of a raw stack trace.
|
||||
/// </summary>
|
||||
/// <param name="raw">The raw string value to parse.</param>
|
||||
/// <param name="type">The target TwinCAT data type.</param>
|
||||
/// <returns>The parsed value boxed as an <see cref="object"/> matching the given <paramref name="type"/>.</returns>
|
||||
internal static object ParseValue(string raw, TwinCATDataType type)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -28,20 +28,16 @@ public abstract class TwinCATCommandBase : DriverCommandBase
|
||||
[CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 5000).")]
|
||||
public int TimeoutMs { get; init; } = 5000;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the per-operation timeout, projected from <see cref="TimeoutMs"/>. The CliFx
|
||||
/// <c>init</c> accessor required by the abstract base property is intentionally a
|
||||
/// no-op: <see cref="TimeoutMs"/> is the only source of truth, so any value an
|
||||
/// `init` initialiser supplies to this property directly is silently
|
||||
/// dropped. Do NOT add a backing field "fixing" the empty body — it would diverge
|
||||
/// from <see cref="TimeoutMs"/> and the two would drift on every refactor
|
||||
/// (Driver.TwinCAT.Cli-007).
|
||||
/// </summary>
|
||||
/// <inheritdoc />
|
||||
// Projected from TimeoutMs. The CliFx init accessor required by the abstract base property
|
||||
// is intentionally a no-op: TimeoutMs is the only source of truth, so any value an init
|
||||
// initialiser supplies to this property directly is silently dropped. Do NOT add a backing
|
||||
// field "fixing" the empty body — it would diverge from TimeoutMs and the two would drift on
|
||||
// every refactor.
|
||||
public override TimeSpan Timeout
|
||||
{
|
||||
get => TimeSpan.FromMilliseconds(TimeoutMs);
|
||||
init { /* see XML summary — driven by TimeoutMs */ }
|
||||
init { /* see comment above — driven by TimeoutMs */ }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -57,7 +53,7 @@ public abstract class TwinCATCommandBase : DriverCommandBase
|
||||
/// Validates the numeric options every TwinCAT CLI command shares (timeout + AMS port).
|
||||
/// Subclasses override and call <c>base.Validate()</c> first to add their own range
|
||||
/// checks. Throwing here surfaces a clean CliFx one-line error before the driver gets
|
||||
/// a chance to fail with an opaque transport error (Driver.TwinCAT.Cli-001).
|
||||
/// a chance to fail with an opaque transport error.
|
||||
/// </summary>
|
||||
protected virtual void Validate()
|
||||
{
|
||||
@@ -72,7 +68,7 @@ public abstract class TwinCATCommandBase : DriverCommandBase
|
||||
// ---- Test hooks ----
|
||||
// Protected members are exposed to the test assembly through these internal accessors so the
|
||||
// test project can cover Gateway / DriverInstanceId composition + range validation without
|
||||
// needing reflection on every assertion (Driver.TwinCAT.Cli-006).
|
||||
// needing reflection on every assertion.
|
||||
/// <summary>Gets the gateway string for testing.</summary>
|
||||
internal string GatewayForTest => Gateway;
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli;
|
||||
/// single-tag <see cref="TwinCATDriverOptions"/> — <c>probe</c>, <c>read</c>, <c>write</c>,
|
||||
/// <c>subscribe</c>. Adds the <c>--poll-only</c> flag (relevant only when the driver is
|
||||
/// about to register native ADS notifications, which is why it does NOT live on the
|
||||
/// <c>browse</c> command — Driver.TwinCAT.Cli-004) and the <c>BuildOptions</c> helper that
|
||||
/// <c>browse</c> command) and the <c>BuildOptions</c> helper that
|
||||
/// assembles the driver-side options record.
|
||||
/// </summary>
|
||||
public abstract class TwinCATTagCommandBase : TwinCATCommandBase
|
||||
@@ -24,6 +24,7 @@ public abstract class TwinCATTagCommandBase : TwinCATCommandBase
|
||||
/// native notifications toggled by <see cref="PollOnly"/>.
|
||||
/// </summary>
|
||||
/// <param name="tags">Tag definitions for the driver.</param>
|
||||
/// <returns>The built <see cref="TwinCATDriverOptions"/>.</returns>
|
||||
protected TwinCATDriverOptions BuildOptions(IReadOnlyList<TwinCATTagDefinition> tags) => new()
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions(
|
||||
@@ -39,6 +40,7 @@ public abstract class TwinCATTagCommandBase : TwinCATCommandBase
|
||||
// ---- Test hook ----
|
||||
/// <summary>Test hook that exposes BuildOptions for unit testing.</summary>
|
||||
/// <param name="tags">Tag definitions for the driver.</param>
|
||||
/// <returns>The built <see cref="TwinCATDriverOptions"/>.</returns>
|
||||
internal TwinCATDriverOptions BuildOptionsForTest(IReadOnlyList<TwinCATTagDefinition> tags)
|
||||
=> BuildOptions(tags);
|
||||
}
|
||||
|
||||
@@ -6,10 +6,6 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
/// the same driver; per-device routing is keyed on <see cref="AbCipDeviceOptions.HostAddress"/>
|
||||
/// via <c>IPerCallHostResolver</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Per v2 plan decisions #11 (libplctag), #41 (AbCip vs AbLegacy split), #143–144 (per-call
|
||||
/// host resolver + resilience keys), #144 (bulkhead keyed on <c>(DriverInstanceId, HostName)</c>).
|
||||
/// </remarks>
|
||||
public sealed class AbCipDriverOptions
|
||||
{
|
||||
/// <summary>
|
||||
@@ -44,7 +40,7 @@ public sealed class AbCipDriverOptions
|
||||
public bool EnableControllerBrowse { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Task #177 — when <c>true</c>, declared ALMD tags are surfaced as alarm conditions
|
||||
/// When <c>true</c>, declared ALMD tags are surfaced as alarm conditions
|
||||
/// via <c>IAlarmSource</c>; the driver polls each subscribed
|
||||
/// alarm's <c>InFaulted</c> + <c>Severity</c> members + fires <c>OnAlarmEvent</c> on
|
||||
/// state transitions. Default <c>false</c> — operators explicitly opt in because
|
||||
@@ -94,11 +90,11 @@ public sealed class AbCipDriverOptions
|
||||
/// other quirks; per-device overrides via <see cref="AllowPacking"/> and
|
||||
/// <see cref="ConnectionSize"/> take precedence when set.</param>
|
||||
/// <param name="DeviceName">Optional display label for Admin UI. Falls back to <see cref="HostAddress"/>.</param>
|
||||
/// <param name="AllowPacking">Driver.AbCip-013 — per-device override for CIP request-packing
|
||||
/// <param name="AllowPacking">Per-device override for CIP request-packing
|
||||
/// (firmware 20+). <c>null</c> (the default) inherits the family profile's
|
||||
/// <c>SupportsRequestPacking</c>; set explicitly to opt a single device in or out without
|
||||
/// touching every other device on the same family.</param>
|
||||
/// <param name="ConnectionSize">Driver.AbCip-013 — per-device override for the Forward Open
|
||||
/// <param name="ConnectionSize">Per-device override for the Forward Open
|
||||
/// ConnectionSize (Large Forward Open packet size in bytes). <c>null</c> inherits the family
|
||||
/// profile's <c>DefaultConnectionSize</c>. Honoured by the driver layer; the underlying
|
||||
/// libplctag 1.5.2 wrapper has no direct <c>ConnectionSize</c> property, so the value is
|
||||
@@ -120,7 +116,7 @@ public sealed record AbCipDeviceOptions(
|
||||
/// <param name="TagPath">Logix symbolic path (controller or program scope).</param>
|
||||
/// <param name="DataType">Logix atomic type, or <see cref="AbCipDataType.Structure"/> for UDT-typed tags.</param>
|
||||
/// <param name="Writable">When <c>true</c> and the tag's ExternalAccess permits writes, IWritable routes writes here.</param>
|
||||
/// <param name="WriteIdempotent">Per plan decisions #44–#45, #143 — safe to replay on write timeout. Default <c>false</c>.</param>
|
||||
/// <param name="WriteIdempotent">Safe to replay on write timeout. Default <c>false</c>.</param>
|
||||
/// <param name="Members">For <see cref="AbCipDataType.Structure"/>-typed tags, the declared UDT
|
||||
/// member layout. When supplied, discovery fans out the UDT into a folder + one Variable per
|
||||
/// member (member TagPath = <c>{tag.TagPath}.{member.Name}</c>). When <c>null</c> on a Structure
|
||||
@@ -199,7 +195,7 @@ public sealed class AbCipProbeOptions
|
||||
|
||||
/// <summary>
|
||||
/// Tag path used for the probe. When <see cref="Enabled"/> is <c>true</c> but this is
|
||||
/// <c>null</c>/blank, the driver logs a warning and runs no probe loops (Driver.AbCip-011);
|
||||
/// <c>null</c>/blank, the driver logs a warning and runs no probe loops;
|
||||
/// <c>GetHostStatuses()</c> will then report every device as <c>Unknown</c>. A family-default
|
||||
/// system-tag fallback (e.g. <c>@raw_cpu_type</c> on ControlLogix) is a deferred follow-up;
|
||||
/// today an operator opting into the probe must supply a tag path explicitly.
|
||||
|
||||
@@ -5,7 +5,7 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// Task #177 — projects AB Logix ALMD alarm instructions onto the OPC UA alarm surface by
|
||||
/// Projects AB Logix ALMD alarm instructions onto the OPC UA alarm surface by
|
||||
/// polling the ALMD UDT's <c>InFaulted</c> / <c>Acked</c> / <c>Severity</c> members at a
|
||||
/// configurable interval + translating state transitions into <c>OnAlarmEvent</c>
|
||||
/// callbacks on the owning <see cref="AbCipDriver"/>. Feature-flagged off by default via
|
||||
@@ -18,7 +18,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
/// the operator acked" shape maps cleanly onto the driver-agnostic
|
||||
/// <see cref="IAlarmSource"/> contract without concessions.</para>
|
||||
///
|
||||
/// <para>Polling reuses <see cref="AbCipDriver.ReadAsync"/>, so ALMD reads get the #194
|
||||
/// <para>Polling reuses <see cref="AbCipDriver.ReadAsync"/>, so ALMD reads get the
|
||||
/// whole-UDT optimization for free when the ALMD is declared with its standard members.
|
||||
/// One poll loop per subscription call; the loop batches every
|
||||
/// member read across the full source-node set into a single ReadAsync per tick.</para>
|
||||
@@ -185,7 +185,7 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable
|
||||
// ToInt(null) returns 0 and MapSeverity buckets it as Low. This is acceptable because
|
||||
// InFaulted and Severity are members of the same ALMD UDT read in one batch, so a Good
|
||||
// InFaulted almost always implies a Good Severity. The "unknown severity → Low" fallback
|
||||
// is intentional and matches the behaviour documented on Driver.AbCip-017.
|
||||
// is intentional and matches the documented behaviour.
|
||||
var severity = ToInt(severityDv.Value);
|
||||
|
||||
var wasFaulted = sub.LastInFaulted.GetValueOrDefault(nodeId, false);
|
||||
@@ -302,7 +302,7 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable
|
||||
/// <summary>Handle returned by <see cref="AbCipAlarmProjection.SubscribeAsync"/>.</summary>
|
||||
public sealed record AbCipAlarmSubscriptionHandle(long Id) : IAlarmSubscriptionHandle
|
||||
{
|
||||
/// <summary>Gets a diagnostic identifier for this subscription.</summary>
|
||||
/// <inheritdoc />
|
||||
public string DiagnosticId => $"abcip-alarm-sub-{Id}";
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ public static class AbCipDataTypeExtensions
|
||||
/// <list type="bullet">
|
||||
/// <item>LInt (signed 64-bit) → Int64; ULInt (unsigned 64-bit) → UInt64.</item>
|
||||
/// <item>UDInt (unsigned 32-bit) → UInt32 so values above Int32.MaxValue are not
|
||||
/// wrapped to negative (Driver.AbCip-004).</item>
|
||||
/// wrapped to negative.</item>
|
||||
/// <item>USInt / UInt widen into Int32; they can never overflow it.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
|
||||
@@ -11,12 +11,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
/// capabilities via the libplctag.NET wrapper.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Wire layer is libplctag 1.6.x (plan decision #11). Per-device host addresses use
|
||||
/// <para>Wire layer is libplctag 1.6.x. Per-device host addresses use
|
||||
/// the <c>ab://gateway[:port]/cip-path</c> canonical form parsed via
|
||||
/// <see cref="AbCipHostAddress.TryParse"/>; those strings become the <c>hostName</c> key
|
||||
/// for Polly bulkhead + circuit-breaker isolation per plan decision #144.</para>
|
||||
/// for Polly bulkhead + circuit-breaker isolation.</para>
|
||||
///
|
||||
/// <para>Tier A per plan decisions #143–145 — in-process, shares server lifetime, no
|
||||
/// <para>Tier A — in-process, shares server lifetime, no
|
||||
/// sidecar. <see cref="ReinitializeAsync"/> is the Tier-B escape hatch for recovering
|
||||
/// from native-heap growth that the CLR allocator can't see; it tears down the
|
||||
/// libplctag.NET <c>Tag</c> instances held in <c>DeviceState.Runtimes</c> and reconnects
|
||||
@@ -214,26 +214,13 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// </summary>
|
||||
internal AbCipTemplateCache TemplateCache => _templateCache;
|
||||
|
||||
/// <summary>Gets the unique identifier for this driver instance.</summary>
|
||||
/// <inheritdoc />
|
||||
public string DriverInstanceId => _driverInstanceId;
|
||||
|
||||
/// <summary>Gets the driver type identifier.</summary>
|
||||
/// <inheritdoc />
|
||||
public string DriverType => "AbCip";
|
||||
|
||||
/// <summary>
|
||||
/// Initialize the driver from its <c>DriverConfig</c> JSON. When
|
||||
/// <paramref name="driverConfigJson"/> carries a real configuration (any device or tag),
|
||||
/// it is parsed via <see cref="AbCipDriverFactoryExtensions.ParseOptions"/> and the
|
||||
/// parsed options REPLACE the construction-time options — this is what makes
|
||||
/// <see cref="ReinitializeAsync"/> pick up a changed config (new device, new tag,
|
||||
/// changed timeout). A blank or empty-object JSON (<c>"{}"</c>) is treated as "no
|
||||
/// override" so callers that constructed the driver with explicit options — chiefly
|
||||
/// unit tests — keep those options. The driver's address-space + runtime state is then
|
||||
/// built from the effective <see cref="_options"/>.
|
||||
/// </summary>
|
||||
/// <param name="driverConfigJson">The driver configuration as JSON; empty or "{}" means no override.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A task representing the asynchronous initialization.</returns>
|
||||
/// <inheritdoc />
|
||||
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Initializing, null, null);
|
||||
@@ -261,7 +248,6 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
{
|
||||
// Duplicate-key check: a collision means two configured tags have the same name.
|
||||
// Fail fast at init time with a diagnostic rather than silently clobbering.
|
||||
// (Driver.AbCip-005)
|
||||
if (_tagsByName.TryGetValue(tag.Name, out var existingTag))
|
||||
throw new InvalidOperationException(
|
||||
$"AbCip tag name collision: '{tag.Name}' is declared more than once. " +
|
||||
@@ -280,7 +266,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
DataType: member.DataType,
|
||||
Writable: member.Writable,
|
||||
WriteIdempotent: member.WriteIdempotent,
|
||||
// Driver.AbCip-016 — carry the member's array shape into the fanned-out
|
||||
// Carry the member's array shape into the fanned-out
|
||||
// runtime definition. Discovery already emits an array node for an array
|
||||
// member (member.IsArray || member.ElementCount > 1); without these the
|
||||
// runtime def defaulted to scalar and the read returned a single element
|
||||
@@ -308,13 +294,13 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
state.ProbeCts = new CancellationTokenSource();
|
||||
var ct = state.ProbeCts.Token;
|
||||
// Keep the loop Task so ShutdownAsync can await its clean exit before
|
||||
// disposing the CTS / handles the loop is still using (Driver.AbCip-008).
|
||||
// disposing the CTS / handles the loop is still using.
|
||||
state.ProbeTask = Task.Run(() => ProbeLoopAsync(state, ct), ct);
|
||||
}
|
||||
}
|
||||
else if (_options.Probe.Enabled && _devices.Count > 0)
|
||||
{
|
||||
// Driver.AbCip-011: probe is Enabled but no ProbeTagPath is configured. Without a
|
||||
// Probe is Enabled but no ProbeTagPath is configured. Without a
|
||||
// tag path the loop has nothing to read, so HostState would stay Unknown forever
|
||||
// and GetHostStatuses() would report every device as Unknown with no warning.
|
||||
// Log a warning so the misconfiguration is visible in the rolling Serilog file.
|
||||
@@ -335,26 +321,14 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Reinitialize the driver by shutting down and reinitializing with new configuration.</summary>
|
||||
/// <param name="driverConfigJson">The new driver configuration as JSON.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A task representing the asynchronous reinitialization.</returns>
|
||||
/// <inheritdoc />
|
||||
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
await ShutdownAsync(cancellationToken).ConfigureAwait(false);
|
||||
await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tear the driver down: stop the alarm projection + poll engine, then for each device
|
||||
/// cancel its probe loop, <em>await the loop's clean exit</em>, and only then dispose
|
||||
/// the probe CTS + runtime handles. Awaiting the probe Task before disposing closes the
|
||||
/// race where a still-running loop touches a disposed CTS or a cleared runtime
|
||||
/// dictionary (Driver.AbCip-008). Idempotent — safe to call twice (e.g. ShutdownAsync
|
||||
/// from ReinitializeAsync followed by DisposeAsync).
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A task representing the asynchronous shutdown.</returns>
|
||||
/// <inheritdoc />
|
||||
public async Task ShutdownAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _alarmProjection.DisposeAsync().ConfigureAwait(false);
|
||||
@@ -397,39 +371,21 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
|
||||
// ---- ISubscribable (polling overlay via shared engine) ----
|
||||
|
||||
/// <summary>Subscribe to value changes for the specified tag references.</summary>
|
||||
/// <param name="fullReferences">The tag references to subscribe to.</param>
|
||||
/// <param name="publishingInterval">The interval at which to publish changes.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A handle representing the subscription.</returns>
|
||||
/// <inheritdoc />
|
||||
public Task<ISubscriptionHandle> SubscribeAsync(
|
||||
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken) =>
|
||||
Task.FromResult(_poll.Subscribe(fullReferences, publishingInterval));
|
||||
|
||||
/// <summary>Unsubscribe from value changes using a subscription handle.</summary>
|
||||
/// <param name="handle">The subscription handle to unsubscribe.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A completed task.</returns>
|
||||
/// <inheritdoc />
|
||||
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
|
||||
{
|
||||
_poll.Unsubscribe(handle);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ---- IAlarmSource (ALMD projection, #177) ----
|
||||
// ---- IAlarmSource (ALMD projection) ----
|
||||
|
||||
/// <summary>
|
||||
/// Subscribe to ALMD alarm transitions on <paramref name="sourceNodeIds"/>. Each id
|
||||
/// names a declared ALMD UDT tag; the projection polls the tag's <c>InFaulted</c> +
|
||||
/// <c>Severity</c> members at <see cref="AbCipDriverOptions.AlarmPollInterval"/> and
|
||||
/// fires <see cref="OnAlarmEvent"/> on 0→1 (raise) + 1→0 (clear) transitions.
|
||||
/// Feature-gated — when <see cref="AbCipDriverOptions.EnableAlarmProjection"/> is
|
||||
/// <c>false</c> (the default), returns a handle wrapping a no-op subscription so
|
||||
/// capability negotiation still works; <see cref="OnAlarmEvent"/> never fires.
|
||||
/// </summary>
|
||||
/// <param name="sourceNodeIds">The node IDs of alarm sources to subscribe to.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A handle representing the alarm subscription.</returns>
|
||||
/// <inheritdoc />
|
||||
public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
|
||||
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -441,19 +397,13 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
return _alarmProjection.SubscribeAsync(sourceNodeIds, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Unsubscribe from alarm events.</summary>
|
||||
/// <param name="handle">The alarm subscription handle.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A completed task.</returns>
|
||||
/// <inheritdoc />
|
||||
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken) =>
|
||||
_options.EnableAlarmProjection
|
||||
? _alarmProjection.UnsubscribeAsync(handle, cancellationToken)
|
||||
: Task.CompletedTask;
|
||||
|
||||
/// <summary>Acknowledge alarms.</summary>
|
||||
/// <param name="acknowledgements">The alarm acknowledgements to process.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A completed task.</returns>
|
||||
/// <inheritdoc />
|
||||
public Task AcknowledgeAsync(
|
||||
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken) =>
|
||||
_options.EnableAlarmProjection
|
||||
@@ -462,8 +412,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
|
||||
// ---- IHostConnectivityProbe ----
|
||||
|
||||
/// <summary>Gets the connectivity status of all configured devices.</summary>
|
||||
/// <returns>A read-only list of host connectivity statuses.</returns>
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses() =>
|
||||
[.. _devices.Values.Select(s => new HostConnectivityStatus(s.Options.HostAddress, s.HostState, s.HostStateChangedUtc))];
|
||||
|
||||
@@ -530,16 +479,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
|
||||
// ---- IPerCallHostResolver ----
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the device host address for a given tag full-reference. Per plan decision #144
|
||||
/// the Phase 6.1 resilience pipeline keys its bulkhead + breaker on
|
||||
/// <c>(DriverInstanceId, hostName)</c> so multi-PLC drivers get per-device isolation —
|
||||
/// one dead PLC trips only its own breaker. Unknown references fall back to the
|
||||
/// first configured device's host address rather than throwing — the invoker handles the
|
||||
/// mislookup at the capability level when the actual read returns BadNodeIdUnknown.
|
||||
/// </summary>
|
||||
/// <param name="fullReference">The full tag reference to resolve.</param>
|
||||
/// <returns>The device host address for the tag.</returns>
|
||||
/// <inheritdoc />
|
||||
public string ResolveHost(string fullReference)
|
||||
{
|
||||
if (_tagsByName.TryGetValue(fullReference, out var def))
|
||||
@@ -549,16 +489,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
|
||||
// ---- IReadable ----
|
||||
|
||||
/// <summary>
|
||||
/// Read each <c>fullReference</c> in order. Unknown tags surface as
|
||||
/// <c>BadNodeIdUnknown</c>; libplctag-layer failures map through
|
||||
/// <see cref="AbCipStatusMapper.MapLibplctagStatus(int)"/>; any other exception becomes
|
||||
/// <c>BadCommunicationError</c>. The driver health surface is updated per-call so the
|
||||
/// Admin UI sees a tight feedback loop between read failures + the driver's state.
|
||||
/// </summary>
|
||||
/// <param name="fullReferences">The tag references to read.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A read-only list of data value snapshots.</returns>
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -566,14 +497,13 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
var now = DateTime.UtcNow;
|
||||
var results = new DataValueSnapshot[fullReferences.Count];
|
||||
|
||||
// Task #194 — plan the batch: members of the same parent UDT get collapsed into one
|
||||
// Plan the batch: members of the same parent UDT get collapsed into one
|
||||
// whole-UDT read + in-memory member decode; every other reference falls back to the
|
||||
// per-tag read path. Planner is a pure function over the
|
||||
// current tag map; BOOL/String/Structure members stay on the fallback path because
|
||||
// declaration-only offsets can't place them under Logix alignment rules. Whole-UDT
|
||||
// grouping is itself gated behind EnableDeclarationOnlyUdtGrouping — Studio 5000 may
|
||||
// reorder UDT members vs declaration order, so the fast path is opt-in only (see
|
||||
// Driver.AbCip-003 / AbCipUdtMemberLayout remarks).
|
||||
// reorder UDT members vs declaration order, so the fast path is opt-in only.
|
||||
var plan = AbCipUdtReadPlanner.Build(
|
||||
fullReferences, _tagsByName, _options.EnableDeclarationOnlyUdtGrouping);
|
||||
|
||||
@@ -594,7 +524,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNodeIdUnknown, null, now);
|
||||
return;
|
||||
}
|
||||
// Driver.AbCip-005: a Structure tag whose Members are declared is a container —
|
||||
// A Structure tag whose Members are declared is a container —
|
||||
// its bare name is readable via the whole-UDT grouping path (ReadGroupAsync), not the
|
||||
// per-tag path. Reading it here returns BadNotSupported rather than Good/null so the
|
||||
// caller knows to address individual member paths (e.g. "Motor.Speed").
|
||||
@@ -617,7 +547,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
var status = runtime.GetStatus();
|
||||
if (status != 0)
|
||||
{
|
||||
// Evict the stale handle so the next call re-creates it (Driver.AbCip-010).
|
||||
// Evict the stale handle so the next call re-creates it.
|
||||
// A non-zero status can mean the controller dropped the connection or the tag
|
||||
// handle became permanently invalid (e.g. after a PLC download). Evicting
|
||||
// mirrors the probe loop's recreate-on-failure behaviour.
|
||||
@@ -662,7 +592,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Task #194 — perform one whole-UDT read on the parent tag, then decode each
|
||||
/// Perform one whole-UDT read on the parent tag, then decode each
|
||||
/// grouped member from the runtime's buffer at its computed byte offset. A per-group
|
||||
/// failure (parent read raised, non-zero libplctag status, or missing device) stamps
|
||||
/// the mapped fault across every grouped member only — sibling groups + the
|
||||
@@ -687,7 +617,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
var status = runtime.GetStatus();
|
||||
if (status != 0)
|
||||
{
|
||||
EvictRuntime(device, parent.Name); // Driver.AbCip-010
|
||||
EvictRuntime(device, parent.Name);
|
||||
var mapped = AbCipStatusMapper.MapLibplctagStatus(status);
|
||||
StampGroupStatus(group, results, now, mapped);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
|
||||
@@ -712,7 +642,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
EvictRuntime(device, parent.Name); // Driver.AbCip-010
|
||||
EvictRuntime(device, parent.Name);
|
||||
StampGroupStatus(group, results, now, AbCipStatusMapper.BadCommunicationError);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||
_logger.LogWarning(ex,
|
||||
@@ -730,16 +660,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
|
||||
// ---- IWritable ----
|
||||
|
||||
/// <summary>
|
||||
/// Write each request in order. Writes are NOT auto-retried by the driver — per plan
|
||||
/// decisions #44, #45, #143 the caller opts in via <see cref="AbCipTagDefinition.WriteIdempotent"/>
|
||||
/// and the resilience pipeline (layered above the driver) decides whether to replay.
|
||||
/// Non-writable configurations surface as <c>BadNotWritable</c>; type-conversion failures
|
||||
/// as <c>BadTypeMismatch</c>; transport errors as <c>BadCommunicationError</c>.
|
||||
/// </summary>
|
||||
/// <param name="writes">The write requests to execute.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A read-only list of write results.</returns>
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
|
||||
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -770,7 +691,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
{
|
||||
var parsedPath = AbCipTagPath.TryParse(def.TagPath);
|
||||
|
||||
// BOOL-within-DINT writes — per task #181, RMW against a parallel parent-DINT
|
||||
// BOOL-within-DINT writes — RMW against a parallel parent-DINT
|
||||
// runtime. Dispatching here keeps the normal EncodeValue path clean; the
|
||||
// per-parent lock prevents two concurrent bit writes to the same DINT from
|
||||
// losing one another's update.
|
||||
@@ -791,7 +712,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
var status = runtime.GetStatus();
|
||||
if (status != 0)
|
||||
{
|
||||
EvictRuntime(device, def.Name); // Driver.AbCip-010
|
||||
EvictRuntime(device, def.Name);
|
||||
results[i] = new WriteResult(AbCipStatusMapper.MapLibplctagStatus(status));
|
||||
_logger.LogWarning(
|
||||
"AbCip write returned non-zero libplctag status {LibplctagStatus} for tag {Tag} on device {Device}; " +
|
||||
@@ -845,7 +766,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Transport / wire error — evict so the next write creates a fresh handle.
|
||||
EvictRuntime(device, def.Name); // Driver.AbCip-010
|
||||
EvictRuntime(device, def.Name);
|
||||
results[i] = new WriteResult(AbCipStatusMapper.BadCommunicationError);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||
_logger.LogWarning(ex,
|
||||
@@ -861,7 +782,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// Read-modify-write one bit within a DINT parent. Creates / reuses a parallel
|
||||
/// parent-DINT runtime (distinct from the bit-selector handle) + serialises concurrent
|
||||
/// writers against the same parent via a per-parent <see cref="SemaphoreSlim"/>.
|
||||
/// Matches the Modbus BitInRegister + FOCAS PMC Bit pattern shipped in pass 1 of task #181.
|
||||
/// Matches the Modbus BitInRegister + FOCAS PMC Bit pattern.
|
||||
/// </summary>
|
||||
private async Task<uint> WriteBitInDIntAsync(
|
||||
DeviceState device, AbCipTagPath bitPath, int bit, object? value, CancellationToken ct)
|
||||
@@ -965,7 +886,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// it so the next read/write call re-creates and re-initializes a fresh handle.
|
||||
/// Called from <see cref="ReadSingleAsync"/>, <see cref="ReadGroupAsync"/>, and
|
||||
/// <see cref="WriteAsync"/> after a non-zero libplctag status or transport exception —
|
||||
/// mirroring the probe loop's recreate-on-failure behaviour (Driver.AbCip-010).
|
||||
/// mirroring the probe loop's recreate-on-failure behaviour.
|
||||
/// </summary>
|
||||
private static void EvictRuntime(DeviceState device, string tagName)
|
||||
{
|
||||
@@ -975,21 +896,13 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Gets the current health status of the driver.</summary>
|
||||
/// <returns>The driver health information.</returns>
|
||||
/// <inheritdoc />
|
||||
public DriverHealth GetHealth() => _health;
|
||||
|
||||
/// <summary>
|
||||
/// CLR-visible allocation footprint only — libplctag's native heap is invisible to the
|
||||
/// GC. driver-specs.md §3 flags this: operators must watch whole-process RSS for the
|
||||
/// full picture, and <see cref="ReinitializeAsync"/> is the Tier-B remediation.
|
||||
/// </summary>
|
||||
/// <returns>The memory footprint in bytes.</returns>
|
||||
/// <inheritdoc />
|
||||
public long GetMemoryFootprint() => 0;
|
||||
|
||||
/// <summary>Flushes optional caches to free memory.</summary>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A completed task.</returns>
|
||||
/// <inheritdoc />
|
||||
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_templateCache.Clear();
|
||||
@@ -998,24 +911,10 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
|
||||
// ---- ITagDiscovery ----
|
||||
|
||||
/// <summary>
|
||||
/// Run-once: <see cref="DiscoverAsync"/> emits pre-declared tags and (when
|
||||
/// EnableControllerBrowse is set) fully awaits the @tags symbol-table walk + UDT-shape
|
||||
/// resolution within the single call, streaming the complete node set in one pass —
|
||||
/// nothing fills in asynchronously after connect, so a single discovery pass is sufficient.
|
||||
/// </summary>
|
||||
/// <inheritdoc />
|
||||
public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.Once;
|
||||
|
||||
/// <summary>
|
||||
/// Stream the driver's tag set into the builder. Pre-declared tags from
|
||||
/// <see cref="AbCipDriverOptions.Tags"/> emit first; optionally, the
|
||||
/// <see cref="IAbCipTagEnumerator"/> walks each device's symbol table and adds
|
||||
/// controller-discovered tags under a <c>Discovered/</c> sub-folder. System / module /
|
||||
/// routine / task tags are hidden via <see cref="AbCipSystemTagFilter"/>.
|
||||
/// </summary>
|
||||
/// <param name="builder">The address space builder to populate with discovered tags.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A task representing the asynchronous discovery.</returns>
|
||||
/// <inheritdoc />
|
||||
public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
@@ -1065,7 +964,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
}
|
||||
|
||||
// Controller-discovered tags — opt-in via EnableControllerBrowse. The real @tags
|
||||
// walker (LibplctagTagEnumerator) is the factory default since task #178 shipped,
|
||||
// walker (LibplctagTagEnumerator) is the factory default,
|
||||
// so leaving the flag off keeps the strict-config path for deployments where only
|
||||
// declared tags should appear.
|
||||
if (_options.EnableControllerBrowse && _devices.TryGetValue(device.HostAddress, out var state))
|
||||
@@ -1283,7 +1182,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// live libplctag.NET <see cref="IAbCipTagRuntime"/> instances keyed by tag name.
|
||||
/// Native tag lifetime is owned by the <c>Tag.Dispose()</c> inside each
|
||||
/// <see cref="LibplctagTagRuntime"/>; libplctag.NET's own finalizer covers GC-collected
|
||||
/// instances so no separate SafeHandle wrapper is needed here (Driver.AbCip-006).
|
||||
/// instances so no separate SafeHandle wrapper is needed here.
|
||||
/// </summary>
|
||||
internal sealed class DeviceState(
|
||||
AbCipHostAddress parsedAddress,
|
||||
@@ -1313,7 +1212,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// <see cref="AbCipDriver.ShutdownAsync"/> can await the loop's clean exit after
|
||||
/// cancelling <see cref="ProbeCts"/> and BEFORE disposing the CTS or the runtime
|
||||
/// handles — otherwise the still-running loop can touch a disposed CTS or a cleared
|
||||
/// runtime dictionary (Driver.AbCip-008).
|
||||
/// runtime dictionary.
|
||||
/// </summary>
|
||||
public Task? ProbeTask { get; set; }
|
||||
|
||||
@@ -1345,7 +1244,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
_rmwLocks.GetOrAdd(parentTagName, _ => new SemaphoreSlim(1, 1));
|
||||
|
||||
/// <summary>
|
||||
/// Driver.AbCip-013 — compute the effective <see cref="AbCipTagCreateParams"/> for a
|
||||
/// Compute the effective <see cref="AbCipTagCreateParams"/> for a
|
||||
/// tag on this device. Combines the per-device options
|
||||
/// (<see cref="AbCipDeviceOptions.AllowPacking"/>,
|
||||
/// <see cref="AbCipDeviceOptions.ConnectionSize"/>) with the family profile defaults
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// Static factory registration helper for <see cref="AbCipDriver"/>. Server's Program.cs
|
||||
/// calls <see cref="Register"/> once at startup; the bootstrapper (task #248) then
|
||||
/// calls <see cref="Register"/> once at startup; the bootstrapper then
|
||||
/// materialises AB CIP DriverInstance rows from the central config DB into live driver
|
||||
/// instances. Mirrors <c>GalaxyProxyDriverFactoryExtensions</c>.
|
||||
/// </summary>
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
/// Parsed <c>ab://gateway[:port]/cip-path</c> host-address string used by the AbCip driver
|
||||
/// as the <c>hostName</c> key across <see cref="Core.Abstractions.IHostConnectivityProbe"/>,
|
||||
/// <see cref="Core.Abstractions.IPerCallHostResolver"/>, and the Polly bulkhead key
|
||||
/// <c>(DriverInstanceId, hostName)</c> per v2 plan decision #144.
|
||||
/// <c>(DriverInstanceId, hostName)</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Format matches what libplctag's <c>gateway=...</c> + <c>path=...</c> attributes
|
||||
|
||||
@@ -48,6 +48,7 @@ public static class AbCipStatusMapper
|
||||
|
||||
/// <summary>Map a CIP general-status byte to an OPC UA StatusCode.</summary>
|
||||
/// <param name="status">The CIP general-status byte value.</param>
|
||||
/// <returns>The mapped OPC UA StatusCode value.</returns>
|
||||
public static uint MapCipGeneralStatus(byte status) => status switch
|
||||
{
|
||||
0x00 => Good,
|
||||
@@ -72,6 +73,7 @@ public static class AbCipStatusMapper
|
||||
/// operation; every other (negative) member is an error.
|
||||
/// </summary>
|
||||
/// <param name="status">The libplctag status code as an integer.</param>
|
||||
/// <returns>The mapped OPC UA StatusCode value.</returns>
|
||||
public static uint MapLibplctagStatus(int status) => MapLibplctagStatus((Status)status);
|
||||
|
||||
/// <summary>
|
||||
@@ -80,6 +82,7 @@ public static class AbCipStatusMapper
|
||||
/// <see cref="IAbCipTagRuntime.GetStatus"/> seam, which returns the boxed-as-int value.
|
||||
/// </summary>
|
||||
/// <param name="status">The libplctag Status enum value.</param>
|
||||
/// <returns>The mapped OPC UA StatusCode value.</returns>
|
||||
public static uint MapLibplctagStatus(Status status) => status switch
|
||||
{
|
||||
Status.Ok => Good,
|
||||
|
||||
@@ -6,10 +6,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
/// pointers, and <c>__DEFVAL_*</c> stubs that are noise for the OPC UA address space.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Lifted from the filter conventions documented across Rockwell Knowledgebase article
|
||||
/// IC-12345 and the Logix 5000 Controllers General Instructions Reference. The list is
|
||||
/// conservative — when in doubt, a tag is surfaced rather than hidden so operators can
|
||||
/// see it and the config flow can explicitly hide it via UnsArea ACL.
|
||||
/// Lifted from the filter conventions documented in the Logix 5000 Controllers General
|
||||
/// Instructions Reference. The list is conservative — when in doubt, a tag is surfaced
|
||||
/// rather than hidden so operators can see it and the config flow can explicitly hide it
|
||||
/// via UnsArea ACL.
|
||||
/// </remarks>
|
||||
public static class AbCipSystemTagFilter
|
||||
{
|
||||
@@ -19,6 +19,7 @@ public static class AbCipSystemTagFilter
|
||||
/// always preserved case and the system-tag prefixes are uppercase by convention.
|
||||
/// </summary>
|
||||
/// <param name="tagName">The tag name to check.</param>
|
||||
/// <returns><c>true</c> when the tag name matches a known system-tag pattern; otherwise <c>false</c>.</returns>
|
||||
public static bool IsSystemTag(string tagName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tagName)) return true;
|
||||
|
||||
@@ -24,6 +24,7 @@ public sealed record AbCipTagPath(
|
||||
int? BitIndex)
|
||||
{
|
||||
/// <summary>Rebuild the canonical Logix tag string.</summary>
|
||||
/// <returns>The canonical Logix tag string.</returns>
|
||||
public string ToLibplctagName()
|
||||
{
|
||||
var buf = new System.Text.StringBuilder();
|
||||
|
||||
@@ -23,6 +23,7 @@ public sealed class AbCipTemplateCache
|
||||
/// </summary>
|
||||
/// <param name="deviceHostAddress">The device host address and port.</param>
|
||||
/// <param name="templateInstanceId">The template instance ID.</param>
|
||||
/// <returns>The cached UDT shape, or <c>null</c> if not yet read.</returns>
|
||||
public AbCipUdtShape? TryGet(string deviceHostAddress, uint templateInstanceId) =>
|
||||
_shapes.TryGetValue((deviceHostAddress, templateInstanceId), out var shape) ? shape : null;
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// Computes byte offsets for declared UDT members under Logix natural-alignment rules so
|
||||
/// a single whole-UDT read (task #194) can decode each member from one buffer without
|
||||
/// a single whole-UDT read can decode each member from one buffer without
|
||||
/// re-reading per member. Declaration-driven — the caller supplies
|
||||
/// <see cref="AbCipStructureMember"/> rows; this helper produces the offset each member
|
||||
/// sits at in the parent tag's read buffer.
|
||||
@@ -30,6 +30,7 @@ public static class AbCipUdtMemberLayout
|
||||
/// if any member type is unsupported for declaration-only layout.
|
||||
/// </summary>
|
||||
/// <param name="members">The list of UDT member declarations.</param>
|
||||
/// <returns>A dictionary of member offsets keyed by name, or <c>null</c> if any member type is unsupported.</returns>
|
||||
public static IReadOnlyDictionary<string, int>? TryBuild(
|
||||
IReadOnlyList<AbCipStructureMember> members)
|
||||
{
|
||||
@@ -44,7 +45,7 @@ public static class AbCipUdtMemberLayout
|
||||
if (!TryGetSizeAlign(member.DataType, out var size, out var align))
|
||||
return null;
|
||||
|
||||
// Driver.AbCip-016 — an array member can't be placed by declaration-only layout: the
|
||||
// An array member can't be placed by declaration-only layout: the
|
||||
// whole-UDT grouped read decodes one scalar per member at its offset and can't return
|
||||
// an array, and advancing the cursor by the scalar size (not size * count) would
|
||||
// mis-place every member after it. Opt the whole group out so array members fall back
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// Task #194 — groups a ReadAsync batch of full-references into whole-UDT reads where
|
||||
/// Groups a ReadAsync batch of full-references into whole-UDT reads where
|
||||
/// possible. A group is emitted for every parent UDT tag whose declared
|
||||
/// <see cref="AbCipStructureMember"/>s produced a valid offset map AND at least two of
|
||||
/// its members appear in the batch; every other reference stays in the per-tag fallback
|
||||
@@ -28,6 +28,7 @@ public static class AbCipUdtReadPlanner
|
||||
/// <param name="requests">The list of tag references to read.</param>
|
||||
/// <param name="tagsByName">Dictionary mapping tag names to their definitions.</param>
|
||||
/// <param name="enableDeclarationOnlyGrouping">Whether to enable UDT member grouping based on declaration order.</param>
|
||||
/// <returns>The resulting plan, containing whole-UDT read groups and per-tag fallback reads.</returns>
|
||||
public static AbCipUdtReadPlan Build(
|
||||
IReadOnlyList<string> requests,
|
||||
IReadOnlyDictionary<string, AbCipTagDefinition> tagsByName,
|
||||
|
||||
@@ -15,6 +15,7 @@ public interface IAbCipTagEnumerator : IDisposable
|
||||
/// </summary>
|
||||
/// <param name="deviceParams">Parameters for creating device tags.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>An asynchronous sequence of the tags discovered for the device.</returns>
|
||||
IAsyncEnumerable<AbCipDiscoveredTag> EnumerateAsync(
|
||||
AbCipTagCreateParams deviceParams,
|
||||
CancellationToken cancellationToken);
|
||||
@@ -26,6 +27,7 @@ public interface IAbCipTagEnumeratorFactory
|
||||
/// <summary>
|
||||
/// Creates a new tag enumerator instance.
|
||||
/// </summary>
|
||||
/// <returns>A new <see cref="IAbCipTagEnumerator"/> instance.</returns>
|
||||
IAbCipTagEnumerator Create();
|
||||
}
|
||||
|
||||
@@ -66,11 +68,7 @@ public sealed record AbCipDiscoveredTag(
|
||||
/// </summary>
|
||||
internal sealed class EmptyAbCipTagEnumerator : IAbCipTagEnumerator
|
||||
{
|
||||
/// <summary>
|
||||
/// Enumerates an empty sequence of tags.
|
||||
/// </summary>
|
||||
/// <param name="deviceParams">Parameters for creating device tags.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<AbCipDiscoveredTag> EnumerateAsync(
|
||||
AbCipTagCreateParams deviceParams,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
@@ -88,8 +86,6 @@ internal sealed class EmptyAbCipTagEnumerator : IAbCipTagEnumerator
|
||||
/// <summary>Factory for <see cref="EmptyAbCipTagEnumerator"/>.</summary>
|
||||
internal sealed class EmptyAbCipTagEnumeratorFactory : IAbCipTagEnumeratorFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new empty tag enumerator.
|
||||
/// </summary>
|
||||
/// <inheritdoc />
|
||||
public IAbCipTagEnumerator Create() => new EmptyAbCipTagEnumerator();
|
||||
}
|
||||
|
||||
@@ -11,20 +11,24 @@ public interface IAbCipTagRuntime : IDisposable
|
||||
{
|
||||
/// <summary>Create the underlying native tag (equivalent to libplctag's <c>plc_tag_create</c>).</summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
Task InitializeAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Issue a read; on completion the local buffer holds the current PLC value.</summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
Task ReadAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Flush the local buffer to the PLC.</summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
Task WriteAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Raw libplctag status code — mapped to an OPC UA StatusCode via
|
||||
/// <see cref="AbCipStatusMapper.MapLibplctagStatus(int)"/>. Zero on success, negative on error.
|
||||
/// </summary>
|
||||
/// <returns>The raw libplctag status code (zero on success, negative on error).</returns>
|
||||
int GetStatus();
|
||||
|
||||
/// <summary>
|
||||
@@ -34,11 +38,12 @@ public interface IAbCipTagRuntime : IDisposable
|
||||
/// </summary>
|
||||
/// <param name="type">CIP data type to decode.</param>
|
||||
/// <param name="bitIndex">Bit index for BOOL-within-DINT extraction, or null.</param>
|
||||
/// <returns>The decoded value, or <c>null</c> if the type is unsupported.</returns>
|
||||
object? DecodeValue(AbCipDataType type, int? bitIndex);
|
||||
|
||||
/// <summary>
|
||||
/// Decode a value at an arbitrary byte offset in the local buffer. Task #194 —
|
||||
/// whole-UDT reads perform one <see cref="ReadAsync"/> on the parent UDT tag then
|
||||
/// Decode a value at an arbitrary byte offset in the local buffer. Whole-UDT reads
|
||||
/// perform one <see cref="ReadAsync"/> on the parent UDT tag then
|
||||
/// call this per declared member with its computed offset, avoiding one libplctag
|
||||
/// round-trip per member. Implementations that do not support offset-aware decoding
|
||||
/// may fall back to <see cref="DecodeValue"/> when <paramref name="offset"/> is zero;
|
||||
@@ -48,6 +53,7 @@ public interface IAbCipTagRuntime : IDisposable
|
||||
/// <param name="type">CIP data type to decode.</param>
|
||||
/// <param name="offset">Byte offset in the buffer.</param>
|
||||
/// <param name="bitIndex">Bit index for BOOL-within-DINT extraction, or null.</param>
|
||||
/// <returns>The decoded value, or <c>null</c> if the type is unsupported.</returns>
|
||||
object? DecodeValueAt(AbCipDataType type, int offset, int? bitIndex);
|
||||
|
||||
/// <summary>
|
||||
@@ -61,6 +67,7 @@ public interface IAbCipTagRuntime : IDisposable
|
||||
/// </summary>
|
||||
/// <param name="type">CIP element data type to decode.</param>
|
||||
/// <param name="count">Number of array elements to decode.</param>
|
||||
/// <returns>A boxed element-typed CLR array, or <c>null</c> if the type is unsupported.</returns>
|
||||
object? DecodeArray(AbCipDataType type, int count);
|
||||
|
||||
/// <summary>
|
||||
@@ -81,6 +88,7 @@ public interface IAbCipTagFactory
|
||||
{
|
||||
/// <summary>Creates a tag runtime handle from the specified creation parameters.</summary>
|
||||
/// <param name="createParams">Parameters needed to create the tag runtime.</param>
|
||||
/// <returns>The created tag runtime handle.</returns>
|
||||
IAbCipTagRuntime Create(AbCipTagCreateParams createParams);
|
||||
}
|
||||
|
||||
@@ -93,11 +101,11 @@ public interface IAbCipTagFactory
|
||||
/// <param name="Timeout">libplctag operation timeout (applies to Initialize / Read / Write).</param>
|
||||
/// <param name="AllowPacking">CIP request-packing flag — combines the per-device override (if
|
||||
/// any) with the family profile's <c>SupportsRequestPacking</c>. Forwarded to the libplctag
|
||||
/// <c>Tag.AllowPacking</c> property (Driver.AbCip-013).</param>
|
||||
/// <c>Tag.AllowPacking</c> property.</param>
|
||||
/// <param name="ConnectionSize">Forward Open ConnectionSize — combines the per-device override
|
||||
/// (if any) with the family profile's <c>DefaultConnectionSize</c>. libplctag 1.5.2 has no
|
||||
/// direct <c>ConnectionSize</c> property; the value is plumbed for forward-compat with future
|
||||
/// wrappers / a custom tag-attribute path (Driver.AbCip-013).</param>
|
||||
/// wrappers / a custom tag-attribute path.</param>
|
||||
/// <param name="ElementCount">Phase 4c — libplctag <c>elem_count</c>. Forwarded to the
|
||||
/// libplctag <c>Tag.ElementCount</c> property so a 1-D array tag pulls all elements in one
|
||||
/// CIP transaction. Defaults to 1 (scalar); the driver sets it from the tag definition's
|
||||
|
||||
@@ -16,6 +16,7 @@ public interface IAbCipTemplateReader : IDisposable
|
||||
/// <param name="deviceParams">The device connection parameters.</param>
|
||||
/// <param name="templateInstanceId">The template instance ID to read.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>The raw Template Object blob bytes.</returns>
|
||||
Task<byte[]> ReadAsync(
|
||||
AbCipTagCreateParams deviceParams,
|
||||
uint templateInstanceId,
|
||||
@@ -26,5 +27,6 @@ public interface IAbCipTemplateReader : IDisposable
|
||||
public interface IAbCipTemplateReaderFactory
|
||||
{
|
||||
/// <summary>Creates a new template reader instance.</summary>
|
||||
/// <returns>A new <see cref="IAbCipTemplateReader"/> instance.</returns>
|
||||
IAbCipTemplateReader Create();
|
||||
}
|
||||
|
||||
@@ -21,10 +21,7 @@ internal sealed class LibplctagTagEnumerator : IAbCipTagEnumerator
|
||||
{
|
||||
private Tag? _tag;
|
||||
|
||||
/// <summary>Enumerates all tags in the controller symbol table.</summary>
|
||||
/// <param name="deviceParams">Device connection parameters including gateway and path.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the enumeration.</param>
|
||||
/// <returns>An async enumerable of discovered tags.</returns>
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<AbCipDiscoveredTag> EnumerateAsync(
|
||||
AbCipTagCreateParams deviceParams,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
@@ -67,7 +64,6 @@ internal sealed class LibplctagTagEnumerator : IAbCipTagEnumerator
|
||||
/// <summary>Factory for <see cref="LibplctagTagEnumerator"/>.</summary>
|
||||
internal sealed class LibplctagTagEnumeratorFactory : IAbCipTagEnumeratorFactory
|
||||
{
|
||||
/// <summary>Creates a new libplctag-based tag enumerator.</summary>
|
||||
/// <returns>A new tag enumerator instance.</returns>
|
||||
/// <inheritdoc />
|
||||
public IAbCipTagEnumerator Create() => new LibplctagTagEnumerator();
|
||||
}
|
||||
|
||||
@@ -25,50 +25,29 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
|
||||
Protocol = Protocol.ab_eip,
|
||||
Name = p.TagName,
|
||||
Timeout = p.Timeout,
|
||||
// Driver.AbCip-013 — honour the per-device or family-default AllowPacking knob so
|
||||
// operators can disable CIP request-packing for older firmware or a single device.
|
||||
AllowPacking = p.AllowPacking,
|
||||
// Phase 4c — libplctag elem_count. For a 1-D array tag the driver passes the element
|
||||
// count so libplctag pulls every element in one CIP read; scalar tags pass 1.
|
||||
ElementCount = p.ElementCount > 1 ? p.ElementCount : 1,
|
||||
};
|
||||
// ConnectionSize is captured on AbCipTagCreateParams for forward-compat (driver-specs.md
|
||||
// exposes it as a per-device option) but libplctag.NET 1.5.2 has no direct Tag property
|
||||
// for it. Until the wrapper exposes one (or we ship a custom tag-attribute path), the
|
||||
// family profile DefaultConnectionSize is what the underlying CIP Forward Open
|
||||
// negotiates with — Driver.AbCip-013.
|
||||
}
|
||||
|
||||
/// <summary>Initializes the tag asynchronously.</summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A task representing the asynchronous initialization.</returns>
|
||||
/// <inheritdoc />
|
||||
public Task InitializeAsync(CancellationToken cancellationToken) => _tag.InitializeAsync(cancellationToken);
|
||||
|
||||
/// <summary>Reads the tag value asynchronously.</summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A task representing the asynchronous read operation.</returns>
|
||||
/// <inheritdoc />
|
||||
public Task ReadAsync(CancellationToken cancellationToken) => _tag.ReadAsync(cancellationToken);
|
||||
|
||||
/// <summary>Writes the tag value asynchronously.</summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A task representing the asynchronous write operation.</returns>
|
||||
/// <inheritdoc />
|
||||
public Task WriteAsync(CancellationToken cancellationToken) => _tag.WriteAsync(cancellationToken);
|
||||
|
||||
/// <summary>Gets the current status of the tag.</summary>
|
||||
/// <returns>The tag status as an integer.</returns>
|
||||
/// <inheritdoc />
|
||||
public int GetStatus() => (int)_tag.GetStatus();
|
||||
|
||||
/// <summary>Decodes the tag value with the specified data type.</summary>
|
||||
/// <param name="type">The data type to decode.</param>
|
||||
/// <param name="bitIndex">The bit index for bit-level access, if applicable.</param>
|
||||
/// <returns>The decoded value.</returns>
|
||||
/// <inheritdoc />
|
||||
public object? DecodeValue(AbCipDataType type, int? bitIndex) => DecodeValueAt(type, 0, bitIndex);
|
||||
|
||||
/// <summary>Decodes the tag value at the specified offset with the specified data type.</summary>
|
||||
/// <param name="type">The data type to decode.</param>
|
||||
/// <param name="offset">The byte offset within the tag buffer.</param>
|
||||
/// <param name="bitIndex">The bit index for bit-level access, if applicable.</param>
|
||||
/// <returns>The decoded value.</returns>
|
||||
/// <inheritdoc />
|
||||
public object? DecodeValueAt(AbCipDataType type, int offset, int? bitIndex) => type switch
|
||||
{
|
||||
AbCipDataType.Bool => bitIndex is int bit
|
||||
@@ -79,9 +58,9 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
|
||||
AbCipDataType.Int => (int)_tag.GetInt16(offset),
|
||||
AbCipDataType.UInt => (int)_tag.GetUInt16(offset),
|
||||
AbCipDataType.DInt => _tag.GetInt32(offset),
|
||||
AbCipDataType.UDInt => _tag.GetUInt32(offset), // UInt32 to match ToDriverDataType (Driver.AbCip-004)
|
||||
AbCipDataType.LInt => _tag.GetInt64(offset), // Int64 to match ToDriverDataType (Driver.AbCip-004)
|
||||
AbCipDataType.ULInt => _tag.GetUInt64(offset), // UInt64 to match ToDriverDataType (Driver.AbCip-004)
|
||||
AbCipDataType.UDInt => _tag.GetUInt32(offset),
|
||||
AbCipDataType.LInt => _tag.GetInt64(offset),
|
||||
AbCipDataType.ULInt => _tag.GetUInt64(offset),
|
||||
AbCipDataType.Real => _tag.GetFloat32(offset),
|
||||
AbCipDataType.LReal => _tag.GetFloat64(offset),
|
||||
AbCipDataType.String => _tag.GetString(offset),
|
||||
@@ -90,18 +69,7 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Phase 4c — decode <paramref name="count"/> array elements from the post-read buffer
|
||||
/// into an element-typed CLR array. libplctag has already pulled all elements (the tag
|
||||
/// was created with <c>elem_count = count</c>); we slice the local buffer per element at
|
||||
/// byte stride <see cref="Tag.ElementSize"/> using the same per-offset decoders the
|
||||
/// scalar / UDT-member paths use. The boxed result is a strongly-typed array
|
||||
/// (<c>int[]</c>, <c>float[]</c>, <c>bool[]</c>, <c>string[]</c>, …) so the OPC UA layer
|
||||
/// materialises a 1-D array variant.
|
||||
/// </summary>
|
||||
/// <param name="type">The element data type to decode.</param>
|
||||
/// <param name="count">The number of elements to decode.</param>
|
||||
/// <returns>A boxed element-typed CLR array, or <c>null</c> for an unsupported element type.</returns>
|
||||
/// <inheritdoc />
|
||||
public object? DecodeArray(AbCipDataType type, int count)
|
||||
{
|
||||
if (count < 1) count = 1;
|
||||
@@ -163,10 +131,7 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
|
||||
_ => 4,
|
||||
};
|
||||
|
||||
/// <summary>Encodes the specified value to the tag with the specified data type.</summary>
|
||||
/// <param name="type">The data type to encode.</param>
|
||||
/// <param name="bitIndex">The bit index for bit-level access, if applicable.</param>
|
||||
/// <param name="value">The value to encode.</param>
|
||||
/// <inheritdoc />
|
||||
public void EncodeValue(AbCipDataType type, int? bitIndex, object? value)
|
||||
{
|
||||
switch (type)
|
||||
@@ -253,9 +218,7 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
|
||||
/// <summary>Default implementation of IAbCipTagFactory that creates LibplctagTagRuntime instances.</summary>
|
||||
internal sealed class LibplctagTagFactory : IAbCipTagFactory
|
||||
{
|
||||
/// <summary>Creates a new tag runtime with the specified creation parameters.</summary>
|
||||
/// <param name="createParams">The parameters for creating the tag.</param>
|
||||
/// <returns>A new IAbCipTagRuntime instance.</returns>
|
||||
/// <inheritdoc />
|
||||
public IAbCipTagRuntime Create(AbCipTagCreateParams createParams) =>
|
||||
new LibplctagTagRuntime(createParams);
|
||||
}
|
||||
|
||||
@@ -8,28 +8,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
/// internally via a normal read call, + returns the raw byte buffer so
|
||||
/// <see cref="CipTemplateObjectDecoder"/> can decode it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Driver.AbCip-012 — by design each <c>FetchUdtShapeAsync</c> call creates one fresh
|
||||
/// <see cref="Tag"/>, pays one CIP connection handshake, reads, and disposes. Per-type
|
||||
/// connect cost is accepted because (a) template reads are a low-frequency discovery path
|
||||
/// (one-shot per UDT type, then the decoded shape is cached in
|
||||
/// <see cref="AbCipTemplateCache"/>), (b) libplctag pools its underlying CIP connections per
|
||||
/// gateway+path so the underlying TCP/EIP session is reused even when individual
|
||||
/// <see cref="Tag"/> instances are torn down, and (c) pooling at the wrapper layer here would
|
||||
/// buy a single Forward Open per device per discovery run — small relative to the rest of a
|
||||
/// bulk-tag-walk discovery. If telemetry ever shows discovery latency dominated by
|
||||
/// template-read connects, revisit by holding one <c>@udt</c>-capable <see cref="Tag"/> per
|
||||
/// device for the duration of a discovery run.
|
||||
/// </remarks>
|
||||
internal sealed class LibplctagTemplateReader : IAbCipTemplateReader
|
||||
{
|
||||
private Tag? _tag;
|
||||
|
||||
/// <summary>Reads a template object from the PLC asynchronously.</summary>
|
||||
/// <param name="deviceParams">The device connection parameters.</param>
|
||||
/// <param name="templateInstanceId">The template instance ID to read.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A task representing the asynchronous read operation.</returns>
|
||||
/// <inheritdoc />
|
||||
public async Task<byte[]> ReadAsync(
|
||||
AbCipTagCreateParams deviceParams,
|
||||
uint templateInstanceId,
|
||||
@@ -50,7 +33,7 @@ internal sealed class LibplctagTemplateReader : IAbCipTemplateReader
|
||||
return _tag.GetBuffer();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Disposes the template reader and releases the underlying libplctag tag.</summary>
|
||||
public void Dispose() => _tag?.Dispose();
|
||||
|
||||
private static PlcType MapPlcType(string attribute) => attribute switch
|
||||
@@ -64,7 +47,6 @@ internal sealed class LibplctagTemplateReader : IAbCipTemplateReader
|
||||
|
||||
internal sealed class LibplctagTemplateReaderFactory : IAbCipTemplateReaderFactory
|
||||
{
|
||||
/// <summary>Creates a new instance of the libplctag template reader.</summary>
|
||||
/// <returns>A new instance of <see cref="LibplctagTemplateReader"/>.</returns>
|
||||
/// <inheritdoc />
|
||||
public IAbCipTemplateReader Create() => new LibplctagTemplateReader();
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ public sealed record AbCipPlcFamilyProfile(
|
||||
{
|
||||
/// <summary>Look up the profile for a configured family.</summary>
|
||||
/// <param name="family">The PLC family to look up the profile for.</param>
|
||||
/// <returns>The matching profile, or <see cref="ControlLogix"/> if the family is unrecognized.</returns>
|
||||
public static AbCipPlcFamilyProfile ForFamily(AbCipPlcFamily family) => family switch
|
||||
{
|
||||
AbCipPlcFamily.ControlLogix => ControlLogix,
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
/// <summary>
|
||||
/// AB Legacy (PCCC) driver configuration. One instance supports N devices (SLC 500 /
|
||||
/// MicroLogix / PLC-5 / LogixPccc). Per plan decision #41 AbLegacy ships separately from
|
||||
/// MicroLogix / PLC-5 / LogixPccc). AbLegacy ships separately from
|
||||
/// AbCip because PCCC's file-based addressing (<c>N7:0</c>) and Logix's symbolic addressing
|
||||
/// (<c>Motor1.Speed</c>) pull the abstraction in different directions.
|
||||
/// </summary>
|
||||
|
||||
@@ -40,14 +40,6 @@ public static class AbLegacyEquipmentTagParser
|
||||
if (string.IsNullOrWhiteSpace(address)) return false;
|
||||
var dataType = ReadEnum(root, "dataType", AbLegacyDataType.Int);
|
||||
var deviceHostAddress = ReadString(root, "deviceHostAddress");
|
||||
// Phase 4c #137 — thread the equipment tag's array element count. The canonical
|
||||
// foundation contract: a tag is an ARRAY ⟺ isArray:true. arrayLength (the element
|
||||
// count, ≥1) is honoured ONLY when isArray is the JSON literal true, so a stale
|
||||
// length behind a cleared / absent isArray never produces an orphan array tag that
|
||||
// mismatches its scalar OPC UA node (review C-2). A 1-element array (isArray:true,
|
||||
// arrayLength:1) is a valid [1] array. Clamp to the PCCC file maximum
|
||||
// (AbLegacyArray.MaxElements = 256) so a fat-fingered count can never request a span
|
||||
// larger than a single data file holds. isArray:false / absent → null (scalar).
|
||||
int? arrayLength = null;
|
||||
if (IsArrayFlag(root))
|
||||
{
|
||||
|
||||
@@ -26,6 +26,7 @@ public sealed record AbLegacyPlcFamilyProfile(
|
||||
/// forward-compatibility for device configs authored against a build that predates a new
|
||||
/// family enum member, preferring a safe default over a startup exception.
|
||||
/// </remarks>
|
||||
/// <returns>The libplctag profile defaults for <paramref name="family"/>.</returns>
|
||||
public static AbLegacyPlcFamilyProfile ForFamily(AbLegacyPlcFamily family) => family switch
|
||||
{
|
||||
AbLegacyPlcFamily.Slc500 => Slc500,
|
||||
|
||||
@@ -33,6 +33,7 @@ public sealed record AbLegacyAddress(
|
||||
string? SubElement)
|
||||
{
|
||||
/// <summary>Converts the address to the libplctag library address format.</summary>
|
||||
/// <returns>The libplctag <c>name=...</c> attribute value for this address.</returns>
|
||||
public string ToLibplctagName()
|
||||
{
|
||||
var file = FileNumber is null ? FileLetter : $"{FileLetter}{FileNumber}";
|
||||
@@ -44,6 +45,7 @@ public sealed record AbLegacyAddress(
|
||||
|
||||
/// <summary>Attempts to parse a string into an AB legacy address.</summary>
|
||||
/// <param name="value">The address string to parse.</param>
|
||||
/// <returns>The parsed address, or <see langword="null"/> when <paramref name="value"/> is not a valid PCCC address.</returns>
|
||||
public static AbLegacyAddress? TryParse(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
|
||||
@@ -68,22 +68,13 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the driver instance identifier.
|
||||
/// </summary>
|
||||
/// <inheritdoc />
|
||||
public string DriverInstanceId => _driverInstanceId;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the driver type.
|
||||
/// </summary>
|
||||
/// <inheritdoc />
|
||||
public string DriverType => "AbLegacy";
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the driver asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="driverConfigJson">The driver configuration JSON.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A task representing the asynchronous operation.</returns>
|
||||
/// <inheritdoc />
|
||||
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Initializing, null, null);
|
||||
@@ -132,9 +123,6 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
catch (Exception ex)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Faulted, null, ex.Message);
|
||||
// Driver.AbLegacy-005 — structured log of the init failure so a field operator sees
|
||||
// the exception in the rolling Serilog file rather than only as a transient Detail
|
||||
// string on DriverHealth.
|
||||
_logger.LogError(ex,
|
||||
"AbLegacy driver initialise failed. Driver={DriverInstanceId}", _driverInstanceId);
|
||||
// Tear down any probe loops and cached state that were created before the failure so
|
||||
@@ -157,23 +145,14 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reinitializes the driver asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="driverConfigJson">The driver configuration JSON.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A task representing the asynchronous operation.</returns>
|
||||
/// <inheritdoc />
|
||||
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
await ShutdownAsync(cancellationToken).ConfigureAwait(false);
|
||||
await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shuts down the driver asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A task representing the asynchronous operation.</returns>
|
||||
/// <inheritdoc />
|
||||
public async Task ShutdownAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _poll.DisposeAsync().ConfigureAwait(false);
|
||||
@@ -190,23 +169,13 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the driver health status.
|
||||
/// </summary>
|
||||
/// <returns>The driver health status.</returns>
|
||||
/// <inheritdoc />
|
||||
public DriverHealth GetHealth() => _health;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the memory footprint of the driver.
|
||||
/// </summary>
|
||||
/// <returns>The memory footprint in bytes.</returns>
|
||||
/// <inheritdoc />
|
||||
public long GetMemoryFootprint() => 0;
|
||||
|
||||
/// <summary>
|
||||
/// Flushes optional caches asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A task representing the asynchronous operation.</returns>
|
||||
/// <inheritdoc />
|
||||
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
/// <summary>
|
||||
@@ -224,12 +193,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
|
||||
// ---- IReadable ----
|
||||
|
||||
/// <summary>
|
||||
/// Reads data values asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="fullReferences">The full references to read.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A list of data value snapshots.</returns>
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -267,11 +231,6 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
await runtime.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
status = runtime.GetStatus();
|
||||
var parsed = AbLegacyAddress.TryParse(def.Address);
|
||||
// Phase 4c #137 — an ARRAY tag (non-null ArrayLength, ≥1) decodes the whole
|
||||
// contiguous read into a typed CLR array of that count, INCLUDING a 1-element
|
||||
// array (review I-3); a SCALAR tag (null ArrayLength) decodes a single value.
|
||||
// The runtime was created with a matching ElementCount in EnsureTagRuntimeAsync
|
||||
// so its buffer holds all the elements.
|
||||
if (status != 0)
|
||||
value = null;
|
||||
else if (IsArrayTag(def))
|
||||
@@ -290,11 +249,6 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
AbLegacyStatusMapper.MapLibplctagStatus(status), null, now);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
|
||||
$"libplctag status {status} reading {reference}");
|
||||
// Driver.AbLegacy-005 — log the FIRST non-zero libplctag status per device so
|
||||
// a field operator can correlate a comms problem with a structured log
|
||||
// entry. Detail on DriverHealth is overwritten by the very next read; the
|
||||
// log entry persists. Subsequent occurrences on the same device stay quiet so
|
||||
// a permanently-bad PLC doesn't flood the rolling file.
|
||||
if (!device.FirstNonZeroStatusLogged)
|
||||
{
|
||||
device.FirstNonZeroStatusLogged = true;
|
||||
@@ -325,12 +279,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
|
||||
// ---- IWritable ----
|
||||
|
||||
/// <summary>
|
||||
/// Writes data values asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="writes">The write requests.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A list of write results.</returns>
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
|
||||
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -422,19 +371,10 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
|
||||
// ---- ITagDiscovery ----
|
||||
|
||||
/// <summary>
|
||||
/// Run-once: <see cref="DiscoverAsync"/> emits the complete node set synchronously from
|
||||
/// the configured device/tag tables within a single pass — there is no shape that fills
|
||||
/// in asynchronously after connect, so a single discovery pass is sufficient.
|
||||
/// </summary>
|
||||
/// <inheritdoc />
|
||||
public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.Once;
|
||||
|
||||
/// <summary>
|
||||
/// Discovers tags and populates the address space asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="builder">The address space builder.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A task representing the asynchronous operation.</returns>
|
||||
/// <inheritdoc />
|
||||
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
@@ -447,14 +387,6 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase));
|
||||
foreach (var tag in tagsForDevice)
|
||||
{
|
||||
// Phase 4c #137 — PCCC data files are inherently arrays of elements (a single N7
|
||||
// file is up to 256 words). The canonical contract: a tag is an ARRAY ⟺ its
|
||||
// ArrayLength is non-null (≥1, set by the parser only when isArray:true). A tag
|
||||
// with a non-null ArrayLength materialises a 1-D array OPC UA node, INCLUDING a
|
||||
// 1-element array (ArrayLength:1 → a [1] node — review I-3). ArrayDim is clamped
|
||||
// to the PCCC file maximum (AbLegacyArray.MaxElements = 256) so the declared
|
||||
// dimension can never exceed what a single data file holds; ArrayLength == null
|
||||
// (scalar) stays scalar.
|
||||
var isArray = tag.ArrayLength is int len && len >= 1;
|
||||
var arrayDim = isArray
|
||||
? (uint)Math.Min(tag.ArrayLength!.Value, AbLegacyArray.MaxElements)
|
||||
@@ -477,23 +409,12 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
|
||||
// ---- ISubscribable (polling overlay via shared engine) ----
|
||||
|
||||
/// <summary>
|
||||
/// Subscribes to data changes asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="fullReferences">The full references to subscribe to.</param>
|
||||
/// <param name="publishingInterval">The publishing interval.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A subscription handle.</returns>
|
||||
/// <inheritdoc />
|
||||
public Task<ISubscriptionHandle> SubscribeAsync(
|
||||
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken) =>
|
||||
Task.FromResult(_poll.Subscribe(fullReferences, publishingInterval));
|
||||
|
||||
/// <summary>
|
||||
/// Unsubscribes from data changes asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="handle">The subscription handle.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A task representing the asynchronous operation.</returns>
|
||||
/// <inheritdoc />
|
||||
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
|
||||
{
|
||||
_poll.Unsubscribe(handle);
|
||||
@@ -502,10 +423,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
|
||||
// ---- IHostConnectivityProbe ----
|
||||
|
||||
/// <summary>
|
||||
/// Gets the host connectivity statuses.
|
||||
/// </summary>
|
||||
/// <returns>A list of host connectivity statuses.</returns>
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses() =>
|
||||
[.. _devices.Values.Select(s => new HostConnectivityStatus(s.Options.HostAddress, s.HostState, s.HostStateChangedUtc))];
|
||||
|
||||
@@ -560,9 +478,6 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
state.HostState = newState;
|
||||
state.HostStateChangedUtc = DateTime.UtcNow;
|
||||
}
|
||||
// Driver.AbLegacy-005 — structured log of every probe-driven transition. Operators can
|
||||
// grep the rolling Serilog file for the device address to see when a PLC was last
|
||||
// reachable. Downgrades to Stopped log as Warning; recoveries log as Information.
|
||||
if (newState == HostState.Stopped)
|
||||
_logger.LogWarning(
|
||||
"AbLegacy probe transition. Driver={DriverInstanceId} Device={DeviceHostAddress} From={Old} To={New}",
|
||||
@@ -577,23 +492,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
|
||||
// ---- IPerCallHostResolver ----
|
||||
|
||||
/// <summary>
|
||||
/// Map a full reference to the host string used as the resilience-pipeline breaker key.
|
||||
/// Driver.AbLegacy-013 — the contract on <see cref="IPerCallHostResolver"/> requires that
|
||||
/// implementations never throw on an unknown reference. The fallback chain is therefore:
|
||||
/// <list type="number">
|
||||
/// <item>Known tag → its <c>DeviceHostAddress</c>.</item>
|
||||
/// <item>Unknown reference but devices configured → the first device's host address
|
||||
/// (multi-device drivers degrade to single-host behaviour rather than failing).</item>
|
||||
/// <item>Unknown reference and no devices configured → the driver instance id, which
|
||||
/// the dispatch layer treats as the single-host key per the interface
|
||||
/// documentation. Reaching this branch indicates a misconfigured driver (no
|
||||
/// devices) so callers that want to surface that should validate
|
||||
/// <see cref="DeviceCount"/> before relying on per-tag routing.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
/// <param name="fullReference">The full reference to resolve.</param>
|
||||
/// <returns>The host address for the reference.</returns>
|
||||
/// <inheritdoc />
|
||||
public string ResolveHost(string fullReference)
|
||||
{
|
||||
if (_tagsByName.TryGetValue(fullReference, out var def))
|
||||
@@ -698,7 +597,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 4c #137 — whether a tag definition is an ARRAY. The canonical contract: a tag is
|
||||
/// Whether a tag definition is an ARRAY. The canonical contract: a tag is
|
||||
/// an array ⟺ its <see cref="AbLegacyTagDefinition.ArrayLength"/> is non-null (the parser
|
||||
/// sets it ≥1 only when isArray:true), so a 1-element array (ArrayLength:1) IS an array
|
||||
/// (review I-3). <c>null</c> ArrayLength ⇒ scalar.
|
||||
@@ -706,7 +605,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
private static bool IsArrayTag(AbLegacyTagDefinition def) => def.ArrayLength is int len && len >= 1;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 4c #137 — the effective libplctag element count for a tag definition: the tag's
|
||||
/// The effective libplctag element count for a tag definition: the tag's
|
||||
/// <see cref="AbLegacyTagDefinition.ArrayLength"/> clamped to the PCCC file maximum
|
||||
/// (<see cref="AbLegacyArray.MaxElements"/> = 256) when it is an array (≥1, INCLUDING 1),
|
||||
/// or <c>1</c> when the tag is scalar (null ArrayLength). Used both to size the runtime at
|
||||
@@ -740,9 +639,6 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
|
||||
TagName: parsed.ToLibplctagName(),
|
||||
Timeout: _options.Timeout,
|
||||
// Phase 4c #137 — multi-element PCCC file read. A multi-element span (ArrayLength
|
||||
// > 1) creates the libplctag tag with that element count so a single read fetches
|
||||
// the whole array from the base address; scalar tags pass 1 and read unchanged.
|
||||
ElementCount: EffectiveArrayLength(def)));
|
||||
try
|
||||
{
|
||||
@@ -763,7 +659,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Driver.AbLegacy-011 — synchronous teardown. Mirrors the body of
|
||||
/// Synchronous teardown. Mirrors the body of
|
||||
/// <see cref="ShutdownAsync"/> but never wraps the async path in
|
||||
/// <c>.AsTask().GetAwaiter().GetResult()</c>. The poll engine's <c>DisposeAsync</c> is
|
||||
/// drained with a <c>ConfigureAwait(false)</c> awaiter so a captured single-threaded
|
||||
@@ -841,7 +737,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Parent-word runtimes for bit-within-word RMW writes (task #181). Keyed by the
|
||||
/// Parent-word runtimes for bit-within-word RMW writes. Keyed by the
|
||||
/// parent address (bit suffix stripped) — e.g. writes to N7:0/3 + N7:0/5 share a
|
||||
/// single parent runtime for N7:0.
|
||||
/// </summary>
|
||||
@@ -916,7 +812,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
public bool ProbeInitialized { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Driver.AbLegacy-005 — per-device latch for the structured "first non-zero
|
||||
/// Per-device latch for the structured "first non-zero
|
||||
/// libplctag status" log. Reset to <see langword="false"/> on a successful read so a
|
||||
/// future outage re-fires the warning rather than being suppressed by a stale flag.
|
||||
/// Concurrent readers on the same device may race the unlatched check + set, but the
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
/// <summary>
|
||||
/// Static factory registration helper for <see cref="AbLegacyDriver"/>. Server's Program.cs
|
||||
/// calls <see cref="Register"/> once at startup; the bootstrapper (task #248) then
|
||||
/// calls <see cref="Register"/> once at startup; the bootstrapper then
|
||||
/// materialises AB Legacy DriverInstance rows from the central config DB into live
|
||||
/// driver instances. Mirrors <c>GalaxyProxyDriverFactoryExtensions</c>.
|
||||
/// </summary>
|
||||
|
||||
@@ -9,22 +9,27 @@ public interface IAbLegacyTagRuntime : IDisposable
|
||||
{
|
||||
/// <summary>Initializes the tag runtime.</summary>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
Task InitializeAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Reads the current value of the tag.</summary>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
Task ReadAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Writes the encoded value to the tag.</summary>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
Task WriteAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Gets the current status of the tag operation.</summary>
|
||||
/// <returns>The PCCC/libplctag status code of the last operation.</returns>
|
||||
int GetStatus();
|
||||
|
||||
/// <summary>Decodes the tag value according to the specified data type.</summary>
|
||||
/// <param name="type">The data type to decode.</param>
|
||||
/// <param name="bitIndex">Optional bit index for bit-level access.</param>
|
||||
/// <returns>The decoded scalar value, boxed as <see cref="object"/>.</returns>
|
||||
object? DecodeValue(AbLegacyDataType type, int? bitIndex);
|
||||
|
||||
/// <summary>
|
||||
@@ -37,6 +42,7 @@ public interface IAbLegacyTagRuntime : IDisposable
|
||||
/// </summary>
|
||||
/// <param name="type">The PCCC element data type.</param>
|
||||
/// <param name="count">The number of elements to decode.</param>
|
||||
/// <returns>The decoded elements as a typed CLR array, boxed as <see cref="object"/>.</returns>
|
||||
object? DecodeArray(AbLegacyDataType type, int count);
|
||||
|
||||
/// <summary>Encodes a value for writing to the tag.</summary>
|
||||
@@ -50,6 +56,7 @@ public interface IAbLegacyTagFactory
|
||||
{
|
||||
/// <summary>Creates a tag runtime instance with the specified parameters.</summary>
|
||||
/// <param name="createParams">The tag creation parameters.</param>
|
||||
/// <returns>The created tag runtime instance.</returns>
|
||||
IAbLegacyTagRuntime Create(AbLegacyTagCreateParams createParams);
|
||||
}
|
||||
|
||||
|
||||
@@ -24,13 +24,6 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
|
||||
Protocol = Protocol.ab_eip, // PCCC-over-EIP; libplctag routes via the PlcType-specific PCCC layer
|
||||
Name = p.TagName,
|
||||
Timeout = p.Timeout,
|
||||
// Phase 4c #137 — multi-element PCCC file read. ElementCount tells libplctag how many
|
||||
// consecutive elements to fetch from the base address (e.g. N7:0 with ElementCount=5
|
||||
// reads N7:0..N7:4 in one PCCC transaction). ElementCount=1 (the scalar default) leaves
|
||||
// libplctag's own per-name element inference untouched, so scalar tags read exactly as
|
||||
// before. ASSUMPTION: libplctag's ab_pccc layer honours elem_count for SLC/PLC-5 data
|
||||
// files and lays the elements out contiguously in the tag buffer (verified against the
|
||||
// libplctag.NET API surface; not live-proven — no PCCC fixture on this build host).
|
||||
ElementCount = p.ElementCount > 1 ? p.ElementCount : null,
|
||||
};
|
||||
}
|
||||
@@ -142,10 +135,6 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
|
||||
// silently clobbering the whole word.
|
||||
throw new NotSupportedException(
|
||||
"Bit-with-bitIndex writes must go through AbLegacyDriver.WriteBitInWordAsync.");
|
||||
// Driver.AbLegacy-014 — use SetInt16 to match DecodeValue's GetInt16(0) decode.
|
||||
// A PCCC B-file element is a 16-bit word; SetInt8 only writes 1 byte, leaving
|
||||
// bits 8-15 from the previous read in the tag buffer and creating a
|
||||
// decode/encode asymmetry. SetInt16(0, 0/1) writes the full 16-bit word.
|
||||
_tag.SetInt16(0, Convert.ToBoolean(value) ? (short)1 : (short)0);
|
||||
break;
|
||||
case AbLegacyDataType.Int:
|
||||
@@ -171,7 +160,7 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Disposes the tag and releases its resources.</summary>
|
||||
public void Dispose() => _tag.Dispose();
|
||||
|
||||
private static PlcType MapPlcType(string attribute) => attribute switch
|
||||
@@ -186,9 +175,7 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
|
||||
|
||||
internal sealed class LibplctagLegacyTagFactory : IAbLegacyTagFactory
|
||||
{
|
||||
/// <summary>Creates a new libplctag-backed tag runtime instance.</summary>
|
||||
/// <param name="createParams">The parameters for tag creation.</param>
|
||||
/// <returns>A new tag runtime instance.</returns>
|
||||
/// <inheritdoc />
|
||||
public IAbLegacyTagRuntime Create(AbLegacyTagCreateParams createParams) =>
|
||||
new LibplctagLegacyTagRuntime(createParams);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,8 @@ using System.ComponentModel.DataAnnotations;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
/// <summary>
|
||||
/// FOCAS driver configuration. One instance supports N CNC devices. Per plan decision #144
|
||||
/// each device gets its own <c>(DriverInstanceId, HostAddress)</c> bulkhead key at the
|
||||
/// Phase 6.1 resilience layer.
|
||||
/// FOCAS driver configuration. One instance supports N CNC devices. Each device gets its own
|
||||
/// <c>(DriverInstanceId, HostAddress)</c> bulkhead key at the Phase 6.1 resilience layer.
|
||||
/// </summary>
|
||||
public sealed class FocasDriverOptions
|
||||
{
|
||||
|
||||
@@ -277,6 +277,6 @@ internal sealed class FocasAlarmProjection : IAsyncDisposable
|
||||
/// <summary>Handle returned by <see cref="FocasAlarmProjection.SubscribeAsync"/>.</summary>
|
||||
public sealed record FocasAlarmSubscriptionHandle(long Id) : IAlarmSubscriptionHandle
|
||||
{
|
||||
/// <summary>Gets the diagnostic identifier for this subscription.</summary>
|
||||
/// <inheritdoc />
|
||||
public string DiagnosticId => $"focas-alarm-sub-{Id}";
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
private readonly EquipmentTagRefResolver<FocasTagDefinition> _resolver;
|
||||
// Per-tag-name cache of the FocasAddress parsed once at InitializeAsync. ReadAsync /
|
||||
// WriteAsync look up the pre-parsed value instead of re-parsing tag.Address on every hot
|
||||
// call — resolves Driver.FOCAS-008. ConcurrentDictionary is required because the poll loop
|
||||
// call. ConcurrentDictionary is required because the poll loop
|
||||
// (ReadAsync) and the host write thread (WriteAsync) both mutate this cache when resolver-
|
||||
// produced equipment tags are encountered for the first time.
|
||||
private readonly ConcurrentDictionary<string, FocasAddress> _parsedAddressesByTagName =
|
||||
@@ -75,15 +75,12 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)));
|
||||
}
|
||||
|
||||
/// <summary>Gets the driver instance identifier.</summary>
|
||||
/// <inheritdoc />
|
||||
public string DriverInstanceId => _driverInstanceId;
|
||||
/// <summary>Gets the driver type name.</summary>
|
||||
/// <inheritdoc />
|
||||
public string DriverType => "FOCAS";
|
||||
|
||||
/// <summary>Initializes the driver with configuration and prepares device connections and polling.</summary>
|
||||
/// <param name="driverConfigJson">JSON configuration string for the driver.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A task representing the asynchronous initialization operation.</returns>
|
||||
/// <inheritdoc />
|
||||
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
Volatile.Write(ref _health, new DriverHealth(DriverState.Initializing, null, null));
|
||||
@@ -91,7 +88,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
{
|
||||
// Fail fast if the factory is a stub/unimplemented backend — the operator must
|
||||
// see an actionable error at init rather than a phantom-Healthy driver that fails
|
||||
// every read/write/subscribe silently (Driver.FOCAS-009).
|
||||
// every read/write/subscribe silently.
|
||||
_clientFactory.EnsureUsable();
|
||||
|
||||
foreach (var device in _options.Devices)
|
||||
@@ -122,7 +119,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
}
|
||||
_tagsByName[tag.Name] = tag;
|
||||
// Cache the parsed FocasAddress so ReadAsync / WriteAsync don't re-parse on every
|
||||
// hot-path call (Driver.FOCAS-008). The address string has already been validated
|
||||
// hot-path call. The address string has already been validated
|
||||
// by FocasAddress.TryParse above; reusing the parsed record avoids per-tick allocs
|
||||
// on subscription pollers.
|
||||
_parsedAddressesByTagName[tag.Name] = parsed;
|
||||
@@ -171,19 +168,14 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Reinitializes the driver by shutting down and restarting with new configuration.</summary>
|
||||
/// <param name="driverConfigJson">JSON configuration string for the driver.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A task representing the asynchronous reinitialization operation.</returns>
|
||||
/// <inheritdoc />
|
||||
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
await ShutdownAsync(cancellationToken).ConfigureAwait(false);
|
||||
await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Shuts down the driver, cancelling all running operations and releasing resources.</summary>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A task representing the asynchronous shutdown operation.</returns>
|
||||
/// <inheritdoc />
|
||||
public async Task ShutdownAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _poll.DisposeAsync().ConfigureAwait(false);
|
||||
@@ -195,8 +187,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
foreach (var state in _devices.Values)
|
||||
{
|
||||
// Cancel-then-dispose can race in tight shutdown loops; swallowing is intentional
|
||||
// but we now log the cause so a noisy shutdown leaves a Debug trace
|
||||
// (Driver.FOCAS-007).
|
||||
// but we now log the cause so a noisy shutdown leaves a Debug trace.
|
||||
try { state.ProbeCts?.Cancel(); }
|
||||
catch (Exception ex) { _logger.LogDebug(ex, "Cancelling probe CTS for {Host} failed", state.Options.HostAddress); }
|
||||
state.ProbeCts?.Dispose();
|
||||
@@ -218,13 +209,11 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
Volatile.Write(ref _health, new DriverHealth(DriverState.Unknown, Volatile.Read(ref _health).LastSuccessfulRead, null));
|
||||
}
|
||||
|
||||
/// <summary>Gets the current health status of the driver.</summary>
|
||||
/// <inheritdoc />
|
||||
public DriverHealth GetHealth() => Volatile.Read(ref _health);
|
||||
/// <summary>Gets the current memory footprint of the driver.</summary>
|
||||
/// <inheritdoc />
|
||||
public long GetMemoryFootprint() => 0;
|
||||
/// <summary>Flushes optional internal caches.</summary>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A task representing the asynchronous cache flush operation.</returns>
|
||||
/// <inheritdoc />
|
||||
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
/// <summary>Gets the number of configured devices.</summary>
|
||||
@@ -240,12 +229,14 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
|
||||
/// <summary>Test seam — returns true when a parsed <see cref="FocasAddress"/> for the given
|
||||
/// reference is present in the address cache (both authored and equipment-tag paths).</summary>
|
||||
/// <param name="reference">The tag or equipment-tag reference to check.</param>
|
||||
/// <returns><see langword="true"/> if a parsed address is cached for the reference; otherwise <see langword="false"/>.</returns>
|
||||
internal bool IsParsedAddressCached(string reference) =>
|
||||
_parsedAddressesByTagName.ContainsKey(reference);
|
||||
|
||||
// Resolves a tag definition to its parsed FocasAddress, caching the result so that
|
||||
// equipment tags (resolver-produced, not seeded at InitializeAsync) don't re-parse the
|
||||
// address string on every ReadAsync / WriteAsync hot-path call (Driver.FOCAS-008).
|
||||
// address string on every ReadAsync / WriteAsync hot-path call.
|
||||
// Throwing inside the GetOrAdd factory propagates the exception to the caller and does
|
||||
// NOT store anything in the dictionary — consistent with the existing "fail fast on a
|
||||
// malformed address" behaviour from the init-time validation of authored tags.
|
||||
@@ -257,10 +248,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
|
||||
// ---- IReadable ----
|
||||
|
||||
/// <summary>Reads values from one or more tags asynchronously.</summary>
|
||||
/// <param name="fullReferences">A read-only list of tag references to read.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A task representing the asynchronous read operation.</returns>
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -329,10 +317,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
|
||||
// ---- IWritable ----
|
||||
|
||||
/// <summary>Writes values to one or more tags asynchronously.</summary>
|
||||
/// <param name="writes">A read-only list of write requests.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A task representing the asynchronous write operation.</returns>
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
|
||||
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -401,18 +386,14 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
|
||||
// ---- ITagDiscovery ----
|
||||
|
||||
/// <summary>
|
||||
/// Retry-until-stable: the FixedTree subtree is filled in asynchronously by
|
||||
/// <see cref="FixedTreeLoopAsync"/> a couple of seconds AFTER connect, so the first
|
||||
/// post-connect <see cref="DiscoverAsync"/> pass would miss it — the host must re-run
|
||||
/// discovery until the captured node set is non-empty and stable.
|
||||
/// </summary>
|
||||
/// <inheritdoc />
|
||||
// Retry-until-stable: the FixedTree subtree is filled in asynchronously by
|
||||
// FixedTreeLoopAsync a couple of seconds AFTER connect, so the first post-connect
|
||||
// DiscoverAsync pass would miss it — the host must re-run discovery until the captured
|
||||
// node set is non-empty and stable.
|
||||
public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.UntilStable;
|
||||
|
||||
/// <summary>Discovers tags and builds the OPC UA address space asynchronously.</summary>
|
||||
/// <param name="builder">The address space builder for constructing the OPC UA namespace.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A task representing the asynchronous discovery operation.</returns>
|
||||
/// <inheritdoc />
|
||||
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
@@ -589,19 +570,12 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
|
||||
// ---- ISubscribable (polling overlay via shared engine) ----
|
||||
|
||||
/// <summary>Subscribes to data changes on one or more tags.</summary>
|
||||
/// <param name="fullReferences">A read-only list of tag references to subscribe to.</param>
|
||||
/// <param name="publishingInterval">The interval at which to publish data changes.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A task representing the asynchronous subscription operation.</returns>
|
||||
/// <inheritdoc />
|
||||
public Task<ISubscriptionHandle> SubscribeAsync(
|
||||
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken) =>
|
||||
Task.FromResult(_poll.Subscribe(fullReferences, publishingInterval));
|
||||
|
||||
/// <summary>Unsubscribes from a previous subscription.</summary>
|
||||
/// <param name="handle">The subscription handle to unsubscribe from.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A task representing the asynchronous unsubscription operation.</returns>
|
||||
/// <inheritdoc />
|
||||
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
|
||||
{
|
||||
_poll.Unsubscribe(handle);
|
||||
@@ -610,7 +584,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
|
||||
// ---- IHostConnectivityProbe ----
|
||||
|
||||
/// <summary>Gets the connectivity status of all configured devices.</summary>
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses() =>
|
||||
[.. _devices.Values.Select(s => new HostConnectivityStatus(s.Options.HostAddress, s.HostState, s.HostStateChangedUtc))];
|
||||
|
||||
@@ -623,7 +597,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
{
|
||||
var client = await EnsureConnectedAsync(state, ct).ConfigureAwait(false);
|
||||
// Apply Probe.Timeout so a hung CNC socket gets cancelled at the configured
|
||||
// budget rather than blocking until the OS TCP timeout (Driver.FOCAS-009).
|
||||
// budget rather than blocking until the OS TCP timeout.
|
||||
// TimeSpan.Zero / negative means "no per-probe timeout" — fall back to the loop
|
||||
// cancellation token unmodified.
|
||||
var probeTimeout = _options.Probe.Timeout;
|
||||
@@ -1048,10 +1022,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
|
||||
// ---- IAlarmSource ----
|
||||
|
||||
/// <summary>Subscribes to alarm events from the driver.</summary>
|
||||
/// <param name="sourceNodeIds">A read-only list of source node IDs to subscribe to.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A task representing the asynchronous subscription operation.</returns>
|
||||
/// <inheritdoc />
|
||||
public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
|
||||
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -1061,17 +1032,11 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
return _alarmProjection.SubscribeAsync(sourceNodeIds, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Unsubscribes from a previous alarm subscription.</summary>
|
||||
/// <param name="handle">The alarm subscription handle to unsubscribe from.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A task representing the asynchronous unsubscription operation.</returns>
|
||||
/// <inheritdoc />
|
||||
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken) =>
|
||||
_alarmProjection is { } p ? p.UnsubscribeAsync(handle, cancellationToken) : Task.CompletedTask;
|
||||
|
||||
/// <summary>Acknowledges one or more alarms.</summary>
|
||||
/// <param name="acknowledgements">A read-only list of alarm acknowledgement requests.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A task representing the asynchronous acknowledgement operation.</returns>
|
||||
/// <inheritdoc />
|
||||
public Task AcknowledgeAsync(
|
||||
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken) =>
|
||||
_alarmProjection is { } p ? p.AcknowledgeAsync(acknowledgements, cancellationToken) : Task.CompletedTask;
|
||||
@@ -1113,9 +1078,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
|
||||
// ---- IPerCallHostResolver ----
|
||||
|
||||
/// <summary>Resolves the host address for a given tag reference.</summary>
|
||||
/// <param name="fullReference">The full reference of the tag.</param>
|
||||
/// <returns>The host address for the tag reference.</returns>
|
||||
/// <inheritdoc />
|
||||
public string ResolveHost(string fullReference)
|
||||
{
|
||||
if (_tagsByName.TryGetValue(fullReference, out var def))
|
||||
@@ -1159,6 +1122,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// <summary>Disposes the driver and releases all resources synchronously.</summary>
|
||||
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
/// <summary>Disposes the driver and releases all resources asynchronously.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -205,6 +205,7 @@ public static class FocasDriverFactoryExtensions
|
||||
/// </summary>
|
||||
internal sealed class FlexibleStringConverter : JsonConverter<string?>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override string? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
|
||||
reader.TokenType switch
|
||||
{
|
||||
@@ -216,6 +217,7 @@ public static class FocasDriverFactoryExtensions
|
||||
_ => throw new JsonException($"Expected string, number, or null but got {reader.TokenType}."),
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Write(Utf8JsonWriter writer, string? value, JsonSerializerOptions options)
|
||||
{
|
||||
if (value is null) writer.WriteNullValue();
|
||||
|
||||
@@ -20,6 +20,7 @@ public interface IFocasClient : IDisposable
|
||||
/// <param name="address">The CNC host address and port.</param>
|
||||
/// <param name="timeout">The connection timeout duration.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>True when the FWLIB handle is valid + the socket is up.</summary>
|
||||
@@ -33,6 +34,7 @@ public interface IFocasClient : IDisposable
|
||||
/// <param name="address">The CNC memory address to read from.</param>
|
||||
/// <param name="type">The FOCAS data type to read.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A tuple of the boxed value read and the mapped OPC UA status code.</returns>
|
||||
Task<(object? value, uint status)> ReadAsync(
|
||||
FocasAddress address,
|
||||
FocasDataType type,
|
||||
@@ -46,6 +48,7 @@ public interface IFocasClient : IDisposable
|
||||
/// <param name="type">The FOCAS data type to write.</param>
|
||||
/// <param name="value">The value to write.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The mapped OPC UA status code (0 = Good).</returns>
|
||||
Task<uint> WriteAsync(
|
||||
FocasAddress address,
|
||||
FocasDataType type,
|
||||
@@ -57,6 +60,7 @@ public interface IFocasClient : IDisposable
|
||||
/// responds with any valid status.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns><c>true</c> when the CNC responds with a valid status; otherwise <c>false</c>.</returns>
|
||||
Task<bool> ProbeAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
@@ -66,6 +70,7 @@ public interface IFocasClient : IDisposable
|
||||
/// emits transitions (raise / clear) on the driver's <c>OnAlarmEvent</c>.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The list of currently active alarms; empty when none are active.</returns>
|
||||
Task<IReadOnlyList<FocasActiveAlarm>> ReadAlarmsAsync(CancellationToken cancellationToken);
|
||||
|
||||
// ---- Fixed-tree T1 (identity + axis discovery + fast-poll dynamic bundle) ----
|
||||
@@ -76,6 +81,7 @@ public interface IFocasClient : IDisposable
|
||||
/// values don't change across the session.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The CNC identity snapshot.</returns>
|
||||
Task<FocasSysInfo> GetSysInfoAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
@@ -84,6 +90,7 @@ public interface IFocasClient : IDisposable
|
||||
/// <see cref="ReadDynamicAsync"/> calls.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The CNC's configured axis names.</returns>
|
||||
Task<IReadOnlyList<FocasAxisName>> GetAxisNamesAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
@@ -91,6 +98,7 @@ public interface IFocasClient : IDisposable
|
||||
/// the <c>Spindle/{name}/</c> subtree.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The CNC's configured spindle names.</returns>
|
||||
Task<IReadOnlyList<FocasSpindleName>> GetSpindleNamesAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
@@ -101,6 +109,7 @@ public interface IFocasClient : IDisposable
|
||||
/// </summary>
|
||||
/// <param name="axisIndex">The axis index to read dynamics for.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The fast-poll dynamic bundle for the requested axis.</returns>
|
||||
Task<FocasDynamicSnapshot> ReadDynamicAsync(int axisIndex, CancellationToken cancellationToken);
|
||||
|
||||
// ---- Fixed-tree T2 (program + operation mode) ----
|
||||
@@ -113,6 +122,7 @@ public interface IFocasClient : IDisposable
|
||||
/// on human-operator timescales.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The aggregate program + operation-mode snapshot.</returns>
|
||||
Task<FocasProgramInfo> GetProgramInfoAsync(CancellationToken cancellationToken);
|
||||
|
||||
// ---- Fixed-tree T3 (timers) ----
|
||||
@@ -124,6 +134,7 @@ public interface IFocasClient : IDisposable
|
||||
/// </summary>
|
||||
/// <param name="kind">The timer kind to read.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The requested cumulative timer reading.</returns>
|
||||
Task<FocasTimer> GetTimerAsync(FocasTimerKind kind, CancellationToken cancellationToken);
|
||||
|
||||
// ---- Fixed-tree T3.5 (servo meters) ----
|
||||
@@ -134,6 +145,7 @@ public interface IFocasClient : IDisposable
|
||||
/// disconnected session or unsupported CNC.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The servo-load meter percentages across all configured axes.</returns>
|
||||
Task<IReadOnlyList<FocasServoLoad>> GetServoLoadsAsync(CancellationToken cancellationToken);
|
||||
|
||||
// ---- Fixed-tree T3.6 (spindle meters) ----
|
||||
@@ -145,6 +157,7 @@ public interface IFocasClient : IDisposable
|
||||
/// series like 16i may return EW_FUNC).
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>Per-spindle load percentages, indexed as per <see cref="GetSpindleNamesAsync"/>.</returns>
|
||||
Task<IReadOnlyList<int>> GetSpindleLoadsAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
@@ -152,6 +165,7 @@ public interface IFocasClient : IDisposable
|
||||
/// bootstrap. Index alignment as per <see cref="GetSpindleLoadsAsync"/>.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>Per-spindle maximum RPM values, indexed as per <see cref="GetSpindleLoadsAsync"/>.</returns>
|
||||
Task<IReadOnlyList<int>> GetSpindleMaxRpmsAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
@@ -161,6 +175,7 @@ public interface IFocasClient : IDisposable
|
||||
/// <c>PositionDecimalPlaces</c>. Values are clamped non-negative.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>Per-axis position decimal-place figures; empty when the CNC/backend doesn't report them.</returns>
|
||||
Task<IReadOnlyList<int>> GetPositionFiguresAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -317,17 +332,10 @@ public sealed class UnimplementedFocasClientFactory : IFocasClientFactory
|
||||
"FOCAS driver backend is 'unimplemented'. Switch to 'Backend: \"wire\"' in driver config " +
|
||||
"once the CNC is provisioned — see docs/drivers/FOCAS.md.";
|
||||
|
||||
/// <summary>
|
||||
/// Config-time probe — throws immediately so <see cref="FocasDriver.InitializeAsync"/>
|
||||
/// faults the driver before any background loops start. This prevents the footgun where
|
||||
/// the driver appears Healthy at init but every read/write/subscribe fails.
|
||||
/// </summary>
|
||||
/// <exception cref="NotSupportedException">Always thrown.</exception>
|
||||
/// <inheritdoc />
|
||||
public void EnsureUsable() => throw new NotSupportedException(Message);
|
||||
|
||||
/// <summary>Creates a new client instance (always throws NotSupportedException).</summary>
|
||||
/// <returns>Never returns; always throws NotSupportedException.</returns>
|
||||
/// <exception cref="NotSupportedException">Always thrown to indicate backend is not yet provisioned.</exception>
|
||||
/// <inheritdoc />
|
||||
public IFocasClient Create() => throw new NotSupportedException(Message);
|
||||
}
|
||||
|
||||
@@ -352,5 +360,5 @@ public static class FocasAlarmType
|
||||
public const short Servo = 4; // ALM_S
|
||||
public const short DataIo = 5; // ALM_T
|
||||
public const short MemoryCheck = 6; // ALM_M
|
||||
public const short MacroAlarm = 13; // ALM_MC — used by #3006 etc.
|
||||
public const short MacroAlarm = 13; // ALM_MC
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ public sealed class SynchronizedFocasClient : IFocasClient
|
||||
public Task<IReadOnlyList<int>> GetPositionFiguresAsync(CancellationToken cancellationToken) =>
|
||||
RunBoundedAsync(ct => _inner.GetPositionFiguresAsync(ct), cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Disposes the inner client and the call gate.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
_inner.Dispose();
|
||||
|
||||
@@ -61,7 +61,7 @@ public static class FocasOperationModeExtensions
|
||||
/// <c>"EDIT"</c>). Delegates to <see cref="FocasOpMode.ToText"/> so the wire layer
|
||||
/// and the fixed-tree projection render identical labels — historically these two
|
||||
/// surfaces diverged ("TJOG" vs "T-JOG", "TEACH_IN_HANDLE" vs "TEACH-IN-HANDLE",
|
||||
/// and different unknown-code fallbacks). Resolved by Driver.FOCAS-010.
|
||||
/// and different unknown-code fallbacks).
|
||||
/// </summary>
|
||||
/// <param name="mode">The operation mode.</param>
|
||||
/// <returns>The operator-facing label.</returns>
|
||||
|
||||
@@ -70,6 +70,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
/// <param name="port">The FOCAS/2 TCP port (typically 8193).</param>
|
||||
/// <param name="timeoutSeconds">Connection timeout in seconds; zero or negative disables the timeout.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the connect operation.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public Task ConnectAsync(
|
||||
string host,
|
||||
int port,
|
||||
@@ -90,6 +91,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
/// <param name="port">The FOCAS/2 TCP port (typically 8193).</param>
|
||||
/// <param name="timeout">Connection timeout duration; <see cref="TimeSpan.Zero"/> disables the timeout.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the connect operation.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public Task ConnectAsync(
|
||||
string host,
|
||||
int port,
|
||||
@@ -183,6 +185,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
/// Async dispose — sends the close PDU when connected and tears down both sockets.
|
||||
/// Idempotent.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _lifetimeGate.WaitAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
@@ -218,6 +221,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
|
||||
/// <param name="timeout">Optional per-call timeout override.</param>
|
||||
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
|
||||
/// <returns>The CNC identity/sysinfo, wrapped in a <see cref="FocasResult{T}"/>.</returns>
|
||||
public async Task<FocasResult<WireSysInfo>> ReadSysInfoAsync(
|
||||
CancellationToken cancellationToken = default,
|
||||
TimeSpan? timeout = null,
|
||||
@@ -233,6 +237,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
|
||||
/// <param name="timeout">Optional per-call timeout override.</param>
|
||||
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
|
||||
/// <returns>The aggregated CNC status bits, wrapped in a <see cref="FocasResult{T}"/>.</returns>
|
||||
public async Task<FocasResult<WireStatus>> ReadStatusAsync(
|
||||
CancellationToken cancellationToken = default,
|
||||
TimeSpan? timeout = null,
|
||||
@@ -272,6 +277,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
|
||||
/// <param name="timeout">Optional per-call timeout override.</param>
|
||||
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
|
||||
/// <returns>The configured axis name records, wrapped in a <see cref="FocasResult{T}"/>.</returns>
|
||||
public async Task<FocasResult<IReadOnlyList<WireAxisRecord>>> ReadAxisNamesAsync(
|
||||
short maxCount = 32,
|
||||
CancellationToken cancellationToken = default,
|
||||
@@ -288,6 +294,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
|
||||
/// <param name="timeout">Optional per-call timeout override.</param>
|
||||
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
|
||||
/// <returns>The configured spindle name records, wrapped in a <see cref="FocasResult{T}"/>.</returns>
|
||||
public async Task<FocasResult<IReadOnlyList<WireSpindleRecord>>> ReadSpindleNamesAsync(
|
||||
short maxCount = 8,
|
||||
CancellationToken cancellationToken = default,
|
||||
@@ -308,6 +315,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
|
||||
/// <param name="timeout">Optional per-call timeout override.</param>
|
||||
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
|
||||
/// <returns>The fast-poll dynamic snapshot for the axis, wrapped in a <see cref="FocasResult{T}"/>.</returns>
|
||||
public async Task<FocasResult<WireDynamic>> ReadDynamic2Async(
|
||||
short axis = 1,
|
||||
CancellationToken cancellationToken = default,
|
||||
@@ -353,6 +361,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
|
||||
/// <param name="timeout">Optional per-call timeout override.</param>
|
||||
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
|
||||
/// <returns>The servo-meter load percentages, wrapped in a <see cref="FocasResult{T}"/>.</returns>
|
||||
public async Task<FocasResult<IReadOnlyList<WireServoMeter>>> ReadServoMeterAsync(
|
||||
short maxCount = 32,
|
||||
CancellationToken cancellationToken = default,
|
||||
@@ -387,6 +396,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
|
||||
/// <param name="timeout">Optional per-call timeout override.</param>
|
||||
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
|
||||
/// <returns>The per-axis position decimal-place figures, wrapped in a <see cref="FocasResult{T}"/>.</returns>
|
||||
public async Task<FocasResult<IReadOnlyList<int>>> ReadPositionFiguresAsync(
|
||||
CancellationToken cancellationToken = default,
|
||||
TimeSpan? timeout = null,
|
||||
@@ -413,6 +423,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
|
||||
/// <param name="timeout">Optional per-call timeout override.</param>
|
||||
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
|
||||
/// <returns>The per-spindle load percentages, wrapped in a <see cref="FocasResult{T}"/>.</returns>
|
||||
public Task<FocasResult<IReadOnlyList<WireSpindleMetric>>> ReadSpindleLoadAsync(
|
||||
short spindleSelector = -1,
|
||||
CancellationToken cancellationToken = default,
|
||||
@@ -425,6 +436,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
|
||||
/// <param name="timeout">Optional per-call timeout override.</param>
|
||||
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
|
||||
/// <returns>The per-spindle maximum RPMs, wrapped in a <see cref="FocasResult{T}"/>.</returns>
|
||||
public Task<FocasResult<IReadOnlyList<WireSpindleMetric>>> ReadSpindleMaxRpmAsync(
|
||||
short spindleSelector = -1,
|
||||
CancellationToken cancellationToken = default,
|
||||
@@ -442,6 +454,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
|
||||
/// <param name="timeout">Optional per-call timeout override.</param>
|
||||
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
|
||||
/// <returns>The raw parameter payload bytes, wrapped in a <see cref="FocasResult{T}"/>.</returns>
|
||||
public async Task<FocasResult<byte[]>> ReadParameterBytesAsync(
|
||||
short dataNumber,
|
||||
short axis = 0,
|
||||
@@ -461,6 +474,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
|
||||
/// <param name="timeout">Optional per-call timeout override.</param>
|
||||
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
|
||||
/// <returns>The typed parameter value, wrapped in a <see cref="FocasResult{T}"/>.</returns>
|
||||
public async Task<FocasResult<WireParameter>> ReadParameterAsync(
|
||||
short dataNumber,
|
||||
short type = 0,
|
||||
@@ -481,6 +495,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
|
||||
/// <param name="timeout">Optional per-call timeout override.</param>
|
||||
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
|
||||
/// <returns>The typed 8-bit parameter value, wrapped in a <see cref="FocasResult{T}"/>.</returns>
|
||||
public async Task<FocasResult<byte>> ReadParameterByteAsync(short dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null)
|
||||
{
|
||||
var result = await ReadParameterBytesAsync(dataNumber, axis, cancellationToken, timeout, pathId).ConfigureAwait(false);
|
||||
@@ -495,6 +510,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
|
||||
/// <param name="timeout">Optional per-call timeout override.</param>
|
||||
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
|
||||
/// <returns>The typed 16-bit parameter value, wrapped in a <see cref="FocasResult{T}"/>.</returns>
|
||||
public async Task<FocasResult<short>> ReadParameterInt16Async(short dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null)
|
||||
{
|
||||
var result = await ReadParameterBytesAsync(dataNumber, axis, cancellationToken, timeout, pathId).ConfigureAwait(false);
|
||||
@@ -509,6 +525,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
|
||||
/// <param name="timeout">Optional per-call timeout override.</param>
|
||||
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
|
||||
/// <returns>The typed 32-bit parameter value, wrapped in a <see cref="FocasResult{T}"/>.</returns>
|
||||
public async Task<FocasResult<int>> ReadParameterInt32Async(short dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null)
|
||||
{
|
||||
var result = await ReadParameterBytesAsync(dataNumber, axis, cancellationToken, timeout, pathId).ConfigureAwait(false);
|
||||
@@ -523,6 +540,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
|
||||
/// <param name="timeout">Optional per-call timeout override.</param>
|
||||
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
|
||||
/// <returns>The typed single-precision parameter value, wrapped in a <see cref="FocasResult{T}"/>.</returns>
|
||||
public async Task<FocasResult<float>> ReadParameterFloat32Async(short dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null)
|
||||
{
|
||||
var result = await ReadParameterBytesAsync(dataNumber, axis, cancellationToken, timeout, pathId).ConfigureAwait(false);
|
||||
@@ -537,6 +555,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
|
||||
/// <param name="timeout">Optional per-call timeout override.</param>
|
||||
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
|
||||
/// <returns>The typed double-precision parameter value, wrapped in a <see cref="FocasResult{T}"/>.</returns>
|
||||
public async Task<FocasResult<double>> ReadParameterFloat64Async(short dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null)
|
||||
{
|
||||
var result = await ReadParameterBytesAsync(dataNumber, axis, cancellationToken, timeout, pathId).ConfigureAwait(false);
|
||||
@@ -550,6 +569,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
|
||||
/// <param name="timeout">Optional per-call timeout override.</param>
|
||||
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
|
||||
/// <returns>The macro variable value, wrapped in a <see cref="FocasResult{T}"/>.</returns>
|
||||
public Task<FocasResult<WireMacro>> ReadMacroAsync(
|
||||
short number,
|
||||
CancellationToken cancellationToken = default,
|
||||
@@ -573,6 +593,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
|
||||
/// <param name="timeout">Optional per-call timeout override.</param>
|
||||
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
|
||||
/// <returns>The decoded PMC range values, wrapped in a <see cref="FocasResult{T}"/>.</returns>
|
||||
public async Task<FocasResult<WirePmcRange>> ReadPmcRangeAsync(
|
||||
short area,
|
||||
short dataType,
|
||||
@@ -601,6 +622,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
|
||||
/// <param name="timeout">Optional per-call timeout override.</param>
|
||||
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
|
||||
/// <returns>The decoded PMC range values, wrapped in a <see cref="FocasResult{T}"/>.</returns>
|
||||
public Task<FocasResult<WirePmcRange>> ReadPmcRangeAsync(
|
||||
FocasPmcArea area,
|
||||
FocasPmcDataType dataType,
|
||||
@@ -621,6 +643,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
|
||||
/// <param name="timeout">Optional per-call timeout override.</param>
|
||||
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
|
||||
/// <returns>The active alarm list, wrapped in a <see cref="FocasResult{T}"/>.</returns>
|
||||
public async Task<FocasResult<IReadOnlyList<WireAlarm>>> ReadAlarmsAsync(
|
||||
short type = -1,
|
||||
short count = 32,
|
||||
@@ -640,6 +663,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
|
||||
/// <param name="timeout">Optional per-call timeout override.</param>
|
||||
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
|
||||
/// <returns>The current operation mode, wrapped in a <see cref="FocasResult{T}"/>.</returns>
|
||||
public Task<FocasResult<FocasOperationMode>> ReadOperationModeAsync(
|
||||
CancellationToken cancellationToken = default,
|
||||
TimeSpan? timeout = null,
|
||||
@@ -657,6 +681,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
|
||||
/// <param name="timeout">Optional per-call timeout override.</param>
|
||||
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
|
||||
/// <returns>The raw operation mode code, wrapped in a <see cref="FocasResult{T}"/>.</returns>
|
||||
public Task<FocasResult<short>> ReadOperationModeCodeAsync(
|
||||
CancellationToken cancellationToken = default,
|
||||
TimeSpan? timeout = null,
|
||||
@@ -670,6 +695,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
|
||||
/// <param name="timeout">Optional per-call timeout override.</param>
|
||||
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
|
||||
/// <returns>The executing program name and O-number, wrapped in a <see cref="FocasResult{T}"/>.</returns>
|
||||
public Task<FocasResult<WireProgramName>> ReadExecutingProgramNameAsync(
|
||||
CancellationToken cancellationToken = default,
|
||||
TimeSpan? timeout = null,
|
||||
@@ -680,6 +706,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
|
||||
/// <param name="timeout">Optional per-call timeout override.</param>
|
||||
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
|
||||
/// <returns>The executed block count, wrapped in a <see cref="FocasResult{T}"/>.</returns>
|
||||
public Task<FocasResult<int>> ReadBlockCountAsync(
|
||||
CancellationToken cancellationToken = default,
|
||||
TimeSpan? timeout = null,
|
||||
@@ -697,6 +724,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
|
||||
/// <param name="timeout">Optional per-call timeout override.</param>
|
||||
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
|
||||
/// <returns>The cumulative timer value, wrapped in a <see cref="FocasResult{T}"/>.</returns>
|
||||
public Task<FocasResult<WireTimer>> ReadTimerAsync(
|
||||
short type,
|
||||
CancellationToken cancellationToken = default,
|
||||
@@ -895,6 +923,9 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
/// cutting-time payload <c>ac f2 10 00 90 a3 00 00</c> → minute=1110188, msec=41872. See
|
||||
/// <c>docs/plans/2026-06-25-focas-pdu-v3-30i-b-support.md</c>.
|
||||
/// </summary>
|
||||
/// <param name="type">Timer type selector, carried through into the returned <see cref="WireTimer"/>.</param>
|
||||
/// <param name="payload">The raw 8-byte <c>cnc_rdtimer</c> response payload.</param>
|
||||
/// <returns>The decoded minute/msec timer value.</returns>
|
||||
internal static WireTimer ParseTimer(short type, byte[] payload) => new(
|
||||
type,
|
||||
payload.Length >= 4 ? BinaryPrimitives.ReadInt32LittleEndian(payload.AsSpan(0, 4)) : 0,
|
||||
@@ -914,6 +945,10 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
/// machine's servo-meter screen — confirm magnitude at commissioning. The alignment + name
|
||||
/// fix here is what removes the gross misaligned garbage.</para>
|
||||
/// </summary>
|
||||
/// <param name="svPayload">The raw <c>cnc_rdsvmeter</c> response payload.</param>
|
||||
/// <param name="axisNamePayload">The raw <c>cnc_rdaxisname</c> response payload used to correlate axis names by index.</param>
|
||||
/// <param name="maxCount">Maximum number of servo meter records to return.</param>
|
||||
/// <returns>The decoded servo-meter records.</returns>
|
||||
internal static IReadOnlyList<WireServoMeter> ParseServoMeters(byte[] svPayload, byte[] axisNamePayload, int maxCount)
|
||||
{
|
||||
var result = new List<WireServoMeter>();
|
||||
@@ -935,6 +970,8 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
}
|
||||
|
||||
/// <summary>Byte width of one PMC slot for a FOCAS data-type code: Byte=1, Word=2, Long/Real=4, Double=8.</summary>
|
||||
/// <param name="dataType">The PMC data width code.</param>
|
||||
/// <returns>The byte width of one slot for the given data-type code.</returns>
|
||||
internal static int PmcByteWidth(short dataType) => dataType switch
|
||||
{
|
||||
1 => 2,
|
||||
@@ -950,6 +987,12 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
/// <c>WireFocasClient.ReadPmcAsync</c>) or a trailing partial slot is dropped — which on the
|
||||
/// 31i-B surfaced as a spurious <c>BadOutOfRange</c> for a single Word read. 2026-06-25.
|
||||
/// </summary>
|
||||
/// <param name="area">The PMC address-letter code numeric value.</param>
|
||||
/// <param name="dataType">The PMC data width code numeric value.</param>
|
||||
/// <param name="start">The starting address.</param>
|
||||
/// <param name="end">The ending address.</param>
|
||||
/// <param name="payload">The raw <c>pmc_rdpmcrng</c> response payload.</param>
|
||||
/// <returns>The decoded PMC range values.</returns>
|
||||
internal static WirePmcRange ParsePmcRange(short area, short dataType, ushort start, ushort end, byte[] payload)
|
||||
{
|
||||
var width = PmcByteWidth(dataType);
|
||||
|
||||
@@ -32,6 +32,8 @@ internal static class FocasWireProtocol
|
||||
private static readonly ushort[] SupportedReadVersions = [1, 3];
|
||||
|
||||
/// <summary>True when <paramref name="version"/> is a PDU version this client can frame-parse.</summary>
|
||||
/// <param name="version">The PDU version from an inbound header.</param>
|
||||
/// <returns><c>true</c> if the version is one of the supported read PDU versions; otherwise, <c>false</c>.</returns>
|
||||
internal static bool IsSupportedReadVersion(ushort version) =>
|
||||
Array.IndexOf(SupportedReadVersions, version) >= 0;
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ public sealed class WireFocasClient : IFocasClient
|
||||
/// <summary>
|
||||
/// Construct with an optional logger. Threaded through to
|
||||
/// <see cref="FocasWireClient"/> so the per-response Debug entries actually reach
|
||||
/// the host's logging pipeline (Driver.FOCAS-007).
|
||||
/// the host's logging pipeline.
|
||||
/// </summary>
|
||||
/// <param name="logger">Optional logger for debug output from wire client responses.</param>
|
||||
public WireFocasClient(ILogger<FocasWireClient>? logger)
|
||||
@@ -36,13 +36,10 @@ public sealed class WireFocasClient : IFocasClient
|
||||
_wire = new FocasWireClient(logger);
|
||||
}
|
||||
|
||||
/// <summary>Gets a value indicating whether the wire client is connected to the FOCAS host.</summary>
|
||||
/// <inheritdoc />
|
||||
public bool IsConnected => _wire.IsConnected;
|
||||
|
||||
/// <summary>Connects to a FOCAS host at the specified address.</summary>
|
||||
/// <param name="address">The host address containing the machine name and port.</param>
|
||||
/// <param name="timeout">The connection timeout; values less than or equal to zero are clamped to 1 second.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <inheritdoc />
|
||||
public async Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_wire.IsConnected) return;
|
||||
@@ -54,11 +51,7 @@ public sealed class WireFocasClient : IFocasClient
|
||||
await _wire.ConnectAsync(address.Host, address.Port, effective, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Reads a value from the specified FOCAS address.</summary>
|
||||
/// <param name="address">The FOCAS address to read from.</param>
|
||||
/// <param name="type">The FOCAS data type of the value.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A tuple containing the read value and FOCAS status code.</returns>
|
||||
/// <inheritdoc />
|
||||
public async Task<(object? value, uint status)> ReadAsync(
|
||||
FocasAddress address, FocasDataType type, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -74,19 +67,12 @@ public sealed class WireFocasClient : IFocasClient
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Writes a value to a FOCAS address (always returns BadNotWritable as OtOpcUa is read-only).</summary>
|
||||
/// <param name="address">The FOCAS address to write to.</param>
|
||||
/// <param name="type">The FOCAS data type of the value.</param>
|
||||
/// <param name="value">The value to write.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A task that returns the BadNotWritable status code.</returns>
|
||||
/// <inheritdoc />
|
||||
public Task<uint> WriteAsync(
|
||||
FocasAddress address, FocasDataType type, object? value, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(FocasStatusMapper.BadNotWritable);
|
||||
|
||||
/// <summary>Probes the FOCAS host to verify connectivity.</summary>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>True if the probe succeeds; otherwise false.</returns>
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> ProbeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_wire.IsConnected) return false;
|
||||
@@ -101,9 +87,7 @@ public sealed class WireFocasClient : IFocasClient
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Reads all active alarms from the FOCAS host.</summary>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A list of active alarms; empty if read fails or not connected.</returns>
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<FocasActiveAlarm>> ReadAlarmsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_wire.IsConnected) return [];
|
||||
@@ -125,9 +109,7 @@ public sealed class WireFocasClient : IFocasClient
|
||||
Message: a.Message ?? string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>Gets system information from the FOCAS host.</summary>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>The FOCAS system information.</returns>
|
||||
/// <inheritdoc />
|
||||
public async Task<FocasSysInfo> GetSysInfoAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
RequireConnected();
|
||||
@@ -147,9 +129,7 @@ public sealed class WireFocasClient : IFocasClient
|
||||
AxesCount: axesCount);
|
||||
}
|
||||
|
||||
/// <summary>Gets the names of all axes on the FOCAS host.</summary>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A list of axis names; empty if read fails or not connected.</returns>
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<FocasAxisName>> GetAxisNamesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_wire.IsConnected) return [];
|
||||
@@ -169,9 +149,7 @@ public sealed class WireFocasClient : IFocasClient
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Gets the names of all spindles on the FOCAS host.</summary>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A list of spindle names; empty if read fails or not connected.</returns>
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<FocasSpindleName>> GetSpindleNamesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_wire.IsConnected) return [];
|
||||
@@ -190,10 +168,7 @@ public sealed class WireFocasClient : IFocasClient
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Reads the dynamic state of a specified axis.</summary>
|
||||
/// <param name="axisIndex">The index of the axis to read.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>The dynamic snapshot of the axis.</returns>
|
||||
/// <inheritdoc />
|
||||
public async Task<FocasDynamicSnapshot> ReadDynamicAsync(int axisIndex, CancellationToken cancellationToken)
|
||||
{
|
||||
// FOCAS axes are 1-based; cnc_rddynamic2 with axis 0 returns EW_4 (live-confirmed on the
|
||||
@@ -220,9 +195,7 @@ public sealed class WireFocasClient : IFocasClient
|
||||
DistanceToGo: pos.Distance);
|
||||
}
|
||||
|
||||
/// <summary>Gets information about the currently executing program.</summary>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>The current program information.</returns>
|
||||
/// <inheritdoc />
|
||||
public async Task<FocasProgramInfo> GetProgramInfoAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
RequireConnected();
|
||||
@@ -250,10 +223,7 @@ public sealed class WireFocasClient : IFocasClient
|
||||
Mode: modeResult.IsOk ? modeResult.Value : 0);
|
||||
}
|
||||
|
||||
/// <summary>Gets a timer value from the FOCAS host.</summary>
|
||||
/// <param name="kind">The kind of timer to read (run time, cutting time, etc.).</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>The timer value.</returns>
|
||||
/// <inheritdoc />
|
||||
public async Task<FocasTimer> GetTimerAsync(FocasTimerKind kind, CancellationToken cancellationToken)
|
||||
{
|
||||
RequireConnected();
|
||||
@@ -263,9 +233,7 @@ public sealed class WireFocasClient : IFocasClient
|
||||
return new FocasTimer(kind, t.Minutes, t.Milliseconds);
|
||||
}
|
||||
|
||||
/// <summary>Gets servo load information for all axes.</summary>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A list of servo load values for each axis; empty if read fails or not connected.</returns>
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<FocasServoLoad>> GetServoLoadsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_wire.IsConnected) return [];
|
||||
@@ -277,28 +245,18 @@ public sealed class WireFocasClient : IFocasClient
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>Gets spindle load information for all spindles.</summary>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A list of spindle load percentages; empty if read fails or not connected.</returns>
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<int>> GetSpindleLoadsAsync(CancellationToken cancellationToken) =>
|
||||
ReadSpindleMetricAsync((sel, ct) => _wire.ReadSpindleLoadAsync(sel, ct), cancellationToken);
|
||||
|
||||
/// <summary>Gets maximum RPM information for all spindles.</summary>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A list of maximum RPM values for each spindle; empty if read fails or not connected.</returns>
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<int>> GetSpindleMaxRpmsAsync(CancellationToken cancellationToken) =>
|
||||
ReadSpindleMetricAsync((sel, ct) => _wire.ReadSpindleMaxRpmAsync(sel, ct), cancellationToken);
|
||||
|
||||
/// <summary>Gets the per-axis position decimal-place figures via <c>cnc_getfigure</c>.</summary>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>
|
||||
/// The per-axis decimal-place figures read from the CNC via
|
||||
/// <see cref="FocasWireClient.ReadPositionFiguresAsync"/>, or an empty list when the read
|
||||
/// fails (non-zero RC — e.g. the series lacks <c>cnc_getfigure</c>) or is not connected.
|
||||
/// Per the <see cref="IFocasClient.GetPositionFiguresAsync"/> contract an empty list signals
|
||||
/// the driver to fall back to the configured <c>PositionDecimalPlaces</c>. Never throws —
|
||||
/// figure reads degrade to the config knob rather than faulting.
|
||||
/// </returns>
|
||||
/// <inheritdoc />
|
||||
// Reads via FocasWireClient.ReadPositionFiguresAsync; returns an empty list when the read
|
||||
// fails (non-zero RC — e.g. the series lacks cnc_getfigure) or is not connected. Never
|
||||
// throws — figure reads degrade to the configured PositionDecimalPlaces rather than faulting.
|
||||
public async Task<IReadOnlyList<int>> GetPositionFiguresAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_wire.IsConnected) return [];
|
||||
@@ -450,9 +408,8 @@ public sealed class WireFocasClientFactory : IFocasClientFactory
|
||||
|
||||
/// <summary>
|
||||
/// Construct the factory with a logger that every created <see cref="WireFocasClient"/>
|
||||
/// forwards to its <see cref="FocasWireClient"/>. Resolves Driver.FOCAS-007 — the wire
|
||||
/// client already emits Debug entries per FOCAS response, but the previous no-arg
|
||||
/// factory path discarded them.
|
||||
/// forwards to its <see cref="FocasWireClient"/> — the wire client already emits Debug
|
||||
/// entries per FOCAS response, but the previous no-arg factory path discarded them.
|
||||
/// </summary>
|
||||
/// <param name="logger">Optional logger for debug output from wire client responses.</param>
|
||||
public WireFocasClientFactory(ILogger<FocasWireClient>? logger)
|
||||
@@ -460,13 +417,10 @@ public sealed class WireFocasClientFactory : IFocasClientFactory
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// No-op usability probe — the wire backend is always usable at config time.
|
||||
/// Implements <see cref="IFocasClientFactory.EnsureUsable"/>.
|
||||
/// </summary>
|
||||
/// <inheritdoc />
|
||||
// No-op — the wire backend is always usable at config time.
|
||||
public void EnsureUsable() { }
|
||||
|
||||
/// <summary>Creates a new WireFocasClient instance.</summary>
|
||||
/// <returns>A new IFocasClient implementation.</returns>
|
||||
/// <inheritdoc />
|
||||
public IFocasClient Create() => new WireFocasClient(_logger);
|
||||
}
|
||||
|
||||
@@ -22,10 +22,10 @@ internal sealed class GalaxyBrowseSession : IBrowseSession
|
||||
private volatile bool _disposed;
|
||||
private IReadOnlyList<LazyBrowseNode>? _roots;
|
||||
|
||||
/// <summary>Opaque token identifying this session in the AdminUI registry.</summary>
|
||||
/// <inheritdoc />
|
||||
public Guid Token { get; } = Guid.NewGuid();
|
||||
|
||||
/// <summary>Wall-clock time of the most recent successful Root/Expand/Attributes call.</summary>
|
||||
/// <inheritdoc />
|
||||
public DateTime LastUsedUtc { get; private set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
@@ -40,11 +40,7 @@ internal sealed class GalaxyBrowseSession : IBrowseSession
|
||||
_client = client ?? throw new ArgumentNullException(nameof(client));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the top-level <see cref="LazyBrowseNode"/>s from the gateway and
|
||||
/// returns them as <see cref="BrowseNode"/>s. Result is cached; a second call
|
||||
/// returns the cached roots without a re-fetch.
|
||||
/// </summary>
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<BrowseNode>> RootAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
@@ -63,12 +59,7 @@ internal sealed class GalaxyBrowseSession : IBrowseSession
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the direct children of the cached node identified by
|
||||
/// <paramref name="nodeId"/> (the object's <c>TagName</c>) via
|
||||
/// <see cref="LazyBrowseNode.ExpandAsync"/>. Throws <see cref="ArgumentException"/>
|
||||
/// if the tag hasn't been handed out by a prior Root/Expand call.
|
||||
/// </summary>
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<BrowseNode>> ExpandAsync(string nodeId, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
@@ -84,11 +75,7 @@ internal sealed class GalaxyBrowseSession : IBrowseSession
|
||||
return Project(node.Children);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the attributes of the Galaxy object identified by <paramref name="nodeId"/>
|
||||
/// via <c>DiscoverHierarchyAsync(MaxDepth=0, RootTagName=nodeId, IncludeAttributes=true)</c>.
|
||||
/// Returns an empty list if the gateway has no matching object.
|
||||
/// </summary>
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<AttributeInfo>> AttributesAsync(string nodeId, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
@@ -151,6 +138,8 @@ internal sealed class GalaxyBrowseSession : IBrowseSession
|
||||
/// 0=FreeAccess, 1=Operate, 2=SecuredWrite, 3=VerifiedWrite,
|
||||
/// 4=Tune, 5=Configure, 6=ViewOnly; anything else surfaces as <c>Unknown(N)</c>.
|
||||
/// </summary>
|
||||
/// <param name="raw">The raw Galaxy security-classification integer.</param>
|
||||
/// <returns>The display string for the classification, or <c>Unknown(N)</c> if unrecognized.</returns>
|
||||
// internal for unit-test access (InternalsVisibleTo on the src project).
|
||||
internal static string MapSecurityClass(int raw) => raw switch
|
||||
{
|
||||
@@ -170,6 +159,7 @@ internal sealed class GalaxyBrowseSession : IBrowseSession
|
||||
/// Both <see cref="SemaphoreSlim.Dispose"/> and the client dispose are wrapped
|
||||
/// in try/catch so a concurrent second dispose call never propagates.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed) return;
|
||||
|
||||
@@ -42,24 +42,17 @@ public sealed class GalaxyDriverBrowser : IDriverBrowser
|
||||
_logger = logger ?? NullLogger<GalaxyDriverBrowser>.Instance;
|
||||
}
|
||||
|
||||
/// <summary>Driver type key — matches the AdminUI's persisted "GalaxyMxGateway" value.</summary>
|
||||
/// <inheritdoc />
|
||||
// Hardcoded literal: this project references Driver.Galaxy.Contracts, not Driver.Galaxy,
|
||||
// so GalaxyDriverFactoryExtensions.DriverTypeName isn't available here.
|
||||
public string DriverType => "GalaxyMxGateway";
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes a <see cref="GalaxyDriverOptions"/> blob, opens a transient
|
||||
/// <see cref="GalaxyRepositoryClient"/> against the configured gateway endpoint,
|
||||
/// and returns a browse session over it. The session owns the client and disposes
|
||||
/// it on <see cref="IBrowseSession.DisposeAsync"/>.
|
||||
/// </summary>
|
||||
/// <param name="configJson">Driver options serialized as JSON; same shape the runtime
|
||||
/// driver would consume.</param>
|
||||
/// <param name="cancellationToken">Cancellation for the connect phase only.</param>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// Thrown when the JSON deserialises to null, when <c>Gateway.Endpoint</c> is empty,
|
||||
/// or when <c>MxAccess.ClientName</c> is empty.
|
||||
/// </exception>
|
||||
// Deserializes a GalaxyDriverOptions blob, opens a transient GalaxyRepositoryClient
|
||||
// against the configured gateway endpoint, and returns a browse session over it. The
|
||||
// session owns the client and disposes it on IBrowseSession.DisposeAsync. Throws
|
||||
// InvalidOperationException when the JSON deserialises to null, when Gateway.Endpoint
|
||||
// is empty, or when MxAccess.ClientName is empty.
|
||||
/// <inheritdoc />
|
||||
public async Task<IBrowseSession> OpenAsync(string configJson, CancellationToken cancellationToken)
|
||||
{
|
||||
var opts = JsonSerializer.Deserialize<GalaxyDriverOptions>(configJson, JsonOpts)
|
||||
|
||||
@@ -67,8 +67,8 @@ public sealed record GalaxyGatewayOptions(
|
||||
/// </summary>
|
||||
/// <param name="ClientName">
|
||||
/// Wonderware client identity. MUST be unique per OtOpcUa instance — when two instances
|
||||
/// share a name, the older session loses subscription state. Redundancy pairs (decision
|
||||
/// #149) enforce uniqueness via install scripts.
|
||||
/// share a name, the older session loses subscription state. Redundancy pairs
|
||||
/// enforce uniqueness via install scripts.
|
||||
/// </param>
|
||||
/// <param name="PublishingIntervalMs">
|
||||
/// Hint forwarded as <c>buffered_update_interval_ms</c> on subscribe; lets the worker
|
||||
|
||||
@@ -18,7 +18,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||
/// <item>Anything else — used as the literal API key for back-compat with
|
||||
/// configs that pre-date this resolver. When a logger is supplied the
|
||||
/// resolver emits a startup warning so an operator who accidentally
|
||||
/// committed a cleartext key sees it (Driver.Galaxy-010).</item>
|
||||
/// committed a cleartext key sees it.</item>
|
||||
/// </list>
|
||||
/// A future PR can swap any of these arms for a DPAPI-backed lookup without
|
||||
/// changing the call site.
|
||||
@@ -39,6 +39,7 @@ public static class GalaxySecretRef
|
||||
/// </summary>
|
||||
/// <param name="secretRef">The secret reference string to resolve.</param>
|
||||
/// <param name="logger">Optional logger for warning on cleartext keys.</param>
|
||||
/// <returns>The resolved API-key string.</returns>
|
||||
public static string ResolveApiKey(string secretRef, ILogger? logger = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(secretRef);
|
||||
|
||||
@@ -39,6 +39,7 @@ internal static class AlarmRefBuilder
|
||||
/// <param name="fullReference">The full reference of the alarm-bearing attribute.</param>
|
||||
/// <param name="initialSeverity">The initial alarm severity level.</param>
|
||||
/// <param name="initialDescription">The initial alarm description.</param>
|
||||
/// <returns>The populated <see cref="AlarmConditionInfo"/> with all five sub-attribute references.</returns>
|
||||
public static AlarmConditionInfo Build(
|
||||
string fullReference,
|
||||
AlarmSeverity initialSeverity = AlarmSeverity.Medium,
|
||||
|
||||
@@ -14,12 +14,13 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
|
||||
/// and encode (Boolean, Int32, Int64, Float32, Float64, String, DateTime). Without it an
|
||||
/// Int64 attribute fell through to the <see cref="DriverDataType.String"/> default,
|
||||
/// creating a String address-space node while runtime reads decoded a boxed <c>long</c> —
|
||||
/// a metadata / coercion mismatch (Driver.Galaxy-002).
|
||||
/// a metadata / coercion mismatch.
|
||||
/// </remarks>
|
||||
internal static class DataTypeMap
|
||||
{
|
||||
/// <summary>Maps an MXAccess data type ID to a driver data type.</summary>
|
||||
/// <param name="mxDataType">The MXAccess data type ID.</param>
|
||||
/// <returns>The corresponding <see cref="DriverDataType"/>, or <see cref="DriverDataType.String"/> for unknown codes.</returns>
|
||||
public static DriverDataType Map(int mxDataType) => mxDataType switch
|
||||
{
|
||||
0 => DriverDataType.Boolean,
|
||||
|
||||
@@ -98,6 +98,7 @@ public sealed class DeployWatcher : IDisposable
|
||||
}
|
||||
|
||||
/// <summary>Cancels the loop and waits for it to exit cleanly.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async Task StopAsync()
|
||||
{
|
||||
var cts = _cts;
|
||||
|
||||
@@ -44,6 +44,7 @@ public sealed class GalaxyDiscoverer
|
||||
/// </summary>
|
||||
/// <param name="builder">The address space builder to populate with discovery results.</param>
|
||||
/// <param name="cancellationToken">The cancellation token for the operation.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
|
||||
+1
-4
@@ -22,10 +22,7 @@ public sealed class GatewayGalaxyDeployWatchSource : IGalaxyDeployWatchSource
|
||||
_client = client ?? throw new ArgumentNullException(nameof(client));
|
||||
}
|
||||
|
||||
/// <summary>Watches for deploy events asynchronously.</summary>
|
||||
/// <param name="lastSeenDeployTime">The last deploy time that was observed.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>An async enumerable of deploy events.</returns>
|
||||
/// <inheritdoc />
|
||||
public IAsyncEnumerable<DeployEvent> WatchAsync(
|
||||
DateTimeOffset? lastSeenDeployTime, CancellationToken cancellationToken)
|
||||
=> _client.WatchDeployEventsAsync(lastSeenDeployTime, cancellationToken);
|
||||
|
||||
@@ -20,10 +20,7 @@ public sealed class GatewayGalaxyHierarchySource : IGalaxyHierarchySource
|
||||
_client = client ?? throw new ArgumentNullException(nameof(client));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Discovers the Galaxy object hierarchy asynchronously via the gateway.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<GalaxyObject>> GetHierarchyAsync(CancellationToken cancellationToken)
|
||||
=> _client.DiscoverHierarchyAsync(cancellationToken);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ public interface IGalaxyDeployWatchSource
|
||||
/// </summary>
|
||||
/// <param name="lastSeenDeployTime">The last seen deploy time, or null to receive a bootstrap event.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>An async stream of deploy events, starting with a bootstrap event unless suppressed.</returns>
|
||||
IAsyncEnumerable<DeployEvent> WatchAsync(
|
||||
DateTimeOffset? lastSeenDeployTime, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -16,5 +16,6 @@ public interface IGalaxyHierarchySource
|
||||
/// callers don't reimplement paging.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>The full materialised Galaxy object hierarchy.</returns>
|
||||
Task<IReadOnlyList<GalaxyObject>> GetHierarchyAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ public sealed class GalaxyDriver
|
||||
private bool _alarmFeedWired;
|
||||
// List preserves insertion order so OnAlarmFeedTransition always picks the
|
||||
// earliest-registered handle — a deterministic choice that doesn't vary as
|
||||
// handles are added/removed (Driver.Galaxy-006 fix: HashSet.First() is unstable).
|
||||
// handles are added/removed (HashSet.First() is unstable).
|
||||
private readonly List<GalaxyAlarmSubscriptionHandle> _alarmSubscriptions = new();
|
||||
|
||||
// Production runtime owned by InitializeAsync (BuildProductionRuntimeAsync). The
|
||||
@@ -200,9 +200,10 @@ public sealed class GalaxyDriver
|
||||
/// <see cref="ReconnectSupervisor.ReportTransportFailure"/> drives this on a
|
||||
/// background task in production; tests prefer to invoke it directly so the
|
||||
/// <see cref="GalaxyReconnectOptions.ReplayOnSessionLost"/> branch can be
|
||||
/// asserted deterministically (Driver.Galaxy-013).
|
||||
/// asserted deterministically.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token for the replay operation.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
internal Task InvokeReplayForTestAsync(CancellationToken cancellationToken) =>
|
||||
ReplayAsync(cancellationToken);
|
||||
|
||||
@@ -310,7 +311,7 @@ public sealed class GalaxyDriver
|
||||
/// handles are dead once the session reopened. The faulted <see cref="EventPump"/>
|
||||
/// is recreated first so the replayed subscriptions have a live StreamEvents
|
||||
/// consumer; without that restart the replayed tags are subscribed on the gw but
|
||||
/// never reach <c>OnDataChange</c> (Driver.Galaxy-008). PR 6.x can swap this for
|
||||
/// never reach <c>OnDataChange</c>. PR 6.x can swap this for
|
||||
/// the gw's batched <c>ReplaySubscriptionsCommand</c> once it ships.
|
||||
/// </summary>
|
||||
private async Task ReplayAsync(CancellationToken cancellationToken)
|
||||
@@ -319,11 +320,11 @@ public sealed class GalaxyDriver
|
||||
var entries = _subscriptions.SnapshotEntries();
|
||||
if (entries.Count == 0) return;
|
||||
|
||||
// Driver.Galaxy-013: honor ReplayOnSessionLost. When operators opt out (false)
|
||||
// we skip the per-tag SubscribeBulk fan-out — they're delegating to the
|
||||
// gateway's session-level ReplaySubscriptions or accept post-reconnect tag
|
||||
// loss. We still restart the EventPump so a future Subscribe call lands on
|
||||
// a live consumer.
|
||||
// Honor ReplayOnSessionLost. When operators opt out (false) we skip the
|
||||
// per-tag SubscribeBulk fan-out — they're delegating to the gateway's
|
||||
// session-level ReplaySubscriptions or accept post-reconnect tag loss. We
|
||||
// still restart the EventPump so a future Subscribe call lands on a live
|
||||
// consumer.
|
||||
if (!_options.Reconnect.ReplayOnSessionLost)
|
||||
{
|
||||
RestartEventPumpForReplay();
|
||||
@@ -440,10 +441,10 @@ public sealed class GalaxyDriver
|
||||
private MxGatewayClientOptions BuildClientOptions(GalaxyGatewayOptions gw) => new()
|
||||
{
|
||||
Endpoint = new Uri(gw.Endpoint, UriKind.Absolute),
|
||||
// Driver.Galaxy-010: pass the logger so the literal-arm cleartext fallback
|
||||
// surfaces a startup warning rather than silently shipping the key. The
|
||||
// resolver lives in Driver.Galaxy.Contracts (GalaxySecretRef) so the runtime
|
||||
// driver and the AdminUI browser share one implementation.
|
||||
// Pass the logger so the literal-arm cleartext fallback surfaces a startup
|
||||
// warning rather than silently shipping the key. The resolver lives in
|
||||
// Driver.Galaxy.Contracts (GalaxySecretRef) so the runtime driver and the
|
||||
// AdminUI browser share one implementation.
|
||||
ApiKey = GalaxySecretRef.ResolveApiKey(gw.ApiKeySecretRef, _logger),
|
||||
UseTls = gw.UseTls,
|
||||
CaCertificatePath = gw.CaCertificatePath,
|
||||
@@ -459,8 +460,8 @@ public sealed class GalaxyDriver
|
||||
|
||||
// Reuse the lazily-built repository client (DiscoverAsync constructs it on demand).
|
||||
// If discovery hasn't run yet, build the client here so the watcher has a target.
|
||||
// Driver.Galaxy-009 fix: guard with ??= so if BuildDefaultHierarchySource later runs
|
||||
// it reuses this client rather than overwriting the field and leaking the first instance.
|
||||
// Guard with ??= so if BuildDefaultHierarchySource later runs it reuses this client
|
||||
// rather than overwriting the field and leaking the first instance.
|
||||
_ownedRepositoryClient ??= ZB.MOM.WW.MxGateway.Client.GalaxyRepositoryClient.Create(
|
||||
BuildClientOptions(_options.Gateway));
|
||||
|
||||
@@ -470,31 +471,26 @@ public sealed class GalaxyDriver
|
||||
|
||||
// StartAsync schedules the background loop and returns Task.CompletedTask immediately.
|
||||
// It throws InvalidOperationException synchronously if called twice (programming error).
|
||||
// Driver.Galaxy-009 fix: don't discard the return value — observe any synchronous throw.
|
||||
// Don't discard the return value — observe any synchronous throw.
|
||||
var startTask = _deployWatcher.StartAsync(CancellationToken.None);
|
||||
// The task is already completed (StartAsync is synchronous); surface any synchronous fault.
|
||||
if (startTask.IsFaulted) startTask.GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// In-place config reapply. The driver does not currently support
|
||||
/// hot-swapping <see cref="GalaxyDriverOptions"/> at runtime — changing the
|
||||
/// gateway endpoint, MxAccess client name, or reconnect policy requires
|
||||
/// tearing down the gw session, supervisor, event pump, and address space.
|
||||
/// The host stack handles that via DriverInstance restart, so this method
|
||||
/// only accepts an equivalent config (no meaningful change) and refreshes
|
||||
/// health; a non-equivalent reapply throws <see cref="NotSupportedException"/>
|
||||
/// so the caller knows the change wasn't applied (Driver.Galaxy-013:
|
||||
/// previously the method silently ignored <c>driverConfigJson</c>).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (!string.IsNullOrWhiteSpace(driverConfigJson))
|
||||
{
|
||||
// In-place config reapply. The driver does not currently support hot-swapping
|
||||
// GalaxyDriverOptions at runtime — changing the gateway endpoint, MxAccess
|
||||
// client name, or reconnect policy requires tearing down the gw session,
|
||||
// supervisor, event pump, and address space. The host stack handles that via
|
||||
// DriverInstance restart, so this method only accepts an equivalent config (no
|
||||
// meaningful change) and refreshes health; a non-equivalent reapply throws
|
||||
// NotSupportedException so the caller knows the change wasn't applied.
|
||||
//
|
||||
// Materialise the incoming config and compare against the live options. We
|
||||
// refuse any change that would require a session teardown rather than
|
||||
// pretending to apply it.
|
||||
@@ -562,17 +558,13 @@ public sealed class GalaxyDriver
|
||||
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses() => _hostStatuses.Snapshot();
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Estimated footprint: 64 bytes × tracked item handles (one gw subscription entry
|
||||
/// per bound tag) + 256 bytes × tracked driver subscriptions (registry overhead per
|
||||
/// OPC UA monitored item). Returns 0 when no subscriptions are active. These
|
||||
/// constants are conservative — a 50k-tag set occupies ~3 MB and registers clearly
|
||||
/// with the server's cache-flush heuristic. Driver.Galaxy-011: the stale
|
||||
/// "PR 4.4 sets this" comment is removed; PR 4.4 shipped the SubscriptionRegistry
|
||||
/// but never wired it here.
|
||||
/// </remarks>
|
||||
public long GetMemoryFootprint()
|
||||
{
|
||||
// Estimated footprint: 64 bytes × tracked item handles (one gw subscription entry
|
||||
// per bound tag) + 256 bytes × tracked driver subscriptions (registry overhead per
|
||||
// OPC UA monitored item). Returns 0 when no subscriptions are active. These
|
||||
// constants are conservative — a 50k-tag set occupies ~3 MB and registers clearly
|
||||
// with the server's cache-flush heuristic.
|
||||
const long BytesPerItemHandle = 64L; // TagBinding + reverse-map entry
|
||||
const long BytesPerSubscription = 256L; // SubscriptionEntry overhead
|
||||
return (_subscriptions.TrackedItemHandleCount * BytesPerItemHandle)
|
||||
@@ -584,13 +576,12 @@ public sealed class GalaxyDriver
|
||||
|
||||
// ===== ITagDiscovery (PR 4.1) =====
|
||||
|
||||
/// <summary>
|
||||
/// Run-once: <see cref="DiscoverAsync"/> fetches the full Galaxy hierarchy inline and
|
||||
/// streams the complete node set within a single awaited call — there is no FOCAS-style
|
||||
/// background cache that fills in after connect. Galaxy is a heavy network driver, so the
|
||||
/// bounded post-connect retry loop is deliberately avoided; re-discovery on Galaxy
|
||||
/// redeploy is handled separately via <see cref="IRediscoverable"/> + the deploy-event watcher.
|
||||
/// </summary>
|
||||
/// <inheritdoc />
|
||||
// Run-once: DiscoverAsync fetches the full Galaxy hierarchy inline and streams the complete
|
||||
// node set within a single awaited call — there is no FOCAS-style background cache that fills
|
||||
// in after connect. Galaxy is a heavy network driver, so the bounded post-connect retry loop
|
||||
// is deliberately avoided; re-discovery on Galaxy redeploy is handled separately via
|
||||
// IRediscoverable + the deploy-event watcher.
|
||||
public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.Once;
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -710,10 +701,10 @@ public sealed class GalaxyDriver
|
||||
}
|
||||
|
||||
// Register bindings so the pump knows to dispatch events for these handles.
|
||||
// Driver.Galaxy-012: index the SubscribeBulk results once and correlate to
|
||||
// references in O(1) instead of FirstOrDefault per element (O(n²) over the
|
||||
// batch). On the 50k-tag soak path this turns a 2.5G-comparison loop into
|
||||
// a single Dictionary build + linear scan.
|
||||
// Index the SubscribeBulk results once and correlate to references in O(1)
|
||||
// instead of FirstOrDefault per element (O(n²) over the batch). On the
|
||||
// 50k-tag soak path this turns a 2.5G-comparison loop into a single
|
||||
// Dictionary build + linear scan.
|
||||
var resultIndex = BuildResultIndex(results);
|
||||
var bindings = new List<TagBinding>(fullReferences.Count);
|
||||
for (var i = 0; i < fullReferences.Count; i++)
|
||||
@@ -853,8 +844,8 @@ public sealed class GalaxyDriver
|
||||
// recorded with a non-positive ItemHandle so the caller can detect partial failure
|
||||
// by inspecting the returned handle's diagnostic context — full per-tag error
|
||||
// surface lands in PR 5.3's parity tests.
|
||||
// Driver.Galaxy-012: index results once, correlate in O(1) per reference rather
|
||||
// than FirstOrDefault inside the loop (O(n²) on the 50k-tag path).
|
||||
// Index results once, correlate in O(1) per reference rather than
|
||||
// FirstOrDefault inside the loop (O(n²) on the 50k-tag path).
|
||||
var resultIndex = BuildResultIndex(results);
|
||||
var bindings = new List<TagBinding>(fullReferences.Count);
|
||||
for (var i = 0; i < fullReferences.Count; i++)
|
||||
@@ -1084,9 +1075,9 @@ public sealed class GalaxyDriver
|
||||
// by SourceNodeId (not by handle), so every active subscriber sees the same
|
||||
// transition regardless of which handle is attached here. Using the first
|
||||
// insertion-order entry is deterministic and stable as long as at least one
|
||||
// subscription remains — HashSet.First() was unstable across mutations
|
||||
// (Driver.Galaxy-006 fix). _alarmSubscriptions is a List, so [0] is always
|
||||
// the earliest-registered handle.
|
||||
// subscription remains — HashSet.First() was unstable across mutations.
|
||||
// _alarmSubscriptions is a List, so [0] is always the earliest-registered
|
||||
// handle.
|
||||
subCount = _alarmSubscriptions.Count;
|
||||
handle = subCount > 0 ? _alarmSubscriptions[0] : null;
|
||||
}
|
||||
@@ -1162,9 +1153,9 @@ public sealed class GalaxyDriver
|
||||
/// </summary>
|
||||
private IGalaxyHierarchySource BuildDefaultHierarchySource()
|
||||
{
|
||||
// Driver.Galaxy-009 fix: reuse a client that StartDeployWatcher may have already
|
||||
// created (??=) rather than always overwriting the field and leaking the first
|
||||
// instance. Both paths produce equivalent clients from the same options.
|
||||
// Reuse a client that StartDeployWatcher may have already created (??=) rather
|
||||
// than always overwriting the field and leaking the first instance. Both paths
|
||||
// produce equivalent clients from the same options.
|
||||
_ownedRepositoryClient ??= GalaxyRepositoryClient.Create(BuildClientOptions(_options.Gateway));
|
||||
return new TracedGalaxyHierarchySource(
|
||||
new GatewayGalaxyHierarchySource(_ownedRepositoryClient), _options.MxAccess.ClientName);
|
||||
@@ -1173,10 +1164,11 @@ public sealed class GalaxyDriver
|
||||
/// <summary>
|
||||
/// Asynchronous disposal. Prefer <c>await using</c> over <c>using</c> — the
|
||||
/// async path does not block the caller while awaiting EventPump / session /
|
||||
/// client shutdown (Driver.Galaxy-007: the sync path blocked on
|
||||
/// <c>GetAwaiter().GetResult()</c> for every async sub-component, risking a
|
||||
/// deadlock under thread-pool starvation).
|
||||
/// client shutdown (the sync path blocks on <c>GetAwaiter().GetResult()</c>
|
||||
/// for every async sub-component, risking a deadlock under thread-pool
|
||||
/// starvation).
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed) return;
|
||||
@@ -1250,26 +1242,18 @@ public sealed class GalaxyDriver
|
||||
System.Collections.Concurrent.ConcurrentDictionary<string, SecurityClassification> map)
|
||||
: IAddressSpaceBuilder
|
||||
{
|
||||
/// <summary>Creates a folder node and returns a builder for populating it.</summary>
|
||||
/// <param name="browseName">The OPC UA BrowseName of the folder.</param>
|
||||
/// <param name="displayName">The display name for the folder.</param>
|
||||
/// <inheritdoc />
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||
=> new SecurityCapturingBuilder(inner.Folder(browseName, displayName), map);
|
||||
|
||||
/// <summary>Creates a variable node and captures its security classification.</summary>
|
||||
/// <param name="browseName">The OPC UA BrowseName of the variable.</param>
|
||||
/// <param name="displayName">The display name for the variable.</param>
|
||||
/// <param name="attributeInfo">The driver attribute metadata including security classification.</param>
|
||||
/// <inheritdoc />
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
|
||||
{
|
||||
map[attributeInfo.FullName] = attributeInfo.SecurityClass;
|
||||
return inner.Variable(browseName, displayName, attributeInfo);
|
||||
}
|
||||
|
||||
/// <summary>Adds a property node to the current parent.</summary>
|
||||
/// <param name="browseName">The OPC UA BrowseName of the property.</param>
|
||||
/// <param name="dataType">The OPC UA data type of the property.</param>
|
||||
/// <param name="value">The property value.</param>
|
||||
/// <inheritdoc />
|
||||
public void AddProperty(string browseName, DriverDataType dataType, object? value)
|
||||
=> inner.AddProperty(browseName, dataType, value);
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ public static class GalaxyDriverFactoryExtensions
|
||||
/// <summary>Convenience for tests + standalone callers.</summary>
|
||||
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
|
||||
/// <param name="driverConfigJson">The driver configuration in JSON format.</param>
|
||||
/// <returns>The constructed Galaxy driver instance.</returns>
|
||||
public static GalaxyDriver CreateInstance(string driverInstanceId, string driverConfigJson)
|
||||
=> CreateInstance(driverInstanceId, driverConfigJson, loggerFactory: null);
|
||||
|
||||
@@ -41,6 +42,7 @@ public static class GalaxyDriverFactoryExtensions
|
||||
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
|
||||
/// <param name="driverConfigJson">The driver configuration in JSON format.</param>
|
||||
/// <param name="loggerFactory">The optional logger factory for creating drivers.</param>
|
||||
/// <returns>The constructed Galaxy driver instance.</returns>
|
||||
public static GalaxyDriver CreateInstance(
|
||||
string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory)
|
||||
{
|
||||
|
||||
@@ -145,6 +145,10 @@ public sealed class GalaxyDriverProbe : IDriverProbe
|
||||
/// <see cref="StatusCode.PermissionDenied"/>) counts as reachable because it proves a live
|
||||
/// gateway gRPC server answered.
|
||||
/// </summary>
|
||||
/// <param name="code">The gRPC status code returned by the ping RPC.</param>
|
||||
/// <param name="host">The probed host, used to compose the failure message.</param>
|
||||
/// <param name="port">The probed port, used to compose the failure message.</param>
|
||||
/// <returns>Whether the gateway should be considered reachable, plus a human-readable message.</returns>
|
||||
internal static (bool ok, string message) ClassifyRpc(StatusCode code, string host, int port) => code switch
|
||||
{
|
||||
StatusCode.OK => (true, "gateway gRPC OK"),
|
||||
|
||||
@@ -13,7 +13,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Health;
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The eventual production source for this signal is the gateway's <c>StreamSessionHealth</c>
|
||||
/// RPC (mxaccessgw issue gw-6). Until that ships, the driver-side reconnect supervisor
|
||||
/// RPC. Until that ships, the driver-side reconnect supervisor
|
||||
/// (PR 4.5) calls <see cref="SetTransport"/> on transport state transitions:
|
||||
/// <see cref="HostState.Running"/> when the gw session re-Registers, <see cref="HostState.Stopped"/>
|
||||
/// when the supervisor moves to <c>TransportLost</c>. The forwarder is intentionally
|
||||
@@ -57,7 +57,7 @@ public sealed class HostConnectivityForwarder : IDisposable
|
||||
/// <summary>Disposes the forwarder and marks it as disposed.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
// No-op today; reserved for the eventual gw-6 StreamSessionHealth consumer that
|
||||
// No-op today; reserved for the eventual StreamSessionHealth consumer that
|
||||
// will own a long-running task this method tears down.
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ public sealed class HostStatusAggregator
|
||||
/// Snapshot the current host set. Suitable as the body of
|
||||
/// <c>IHostConnectivityProbe.GetHostStatuses()</c>.
|
||||
/// </summary>
|
||||
/// <returns>The current set of tracked host connectivity statuses.</returns>
|
||||
public IReadOnlyList<HostConnectivityStatus> Snapshot()
|
||||
{
|
||||
lock (_lock)
|
||||
|
||||
@@ -77,6 +77,7 @@ public sealed class PerPlatformProbeWatcher : IDisposable
|
||||
/// </summary>
|
||||
/// <param name="platformTagNames">The platform tag names to synchronize.</param>
|
||||
/// <param name="cancellationToken">A cancellation token to cancel the operation.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async Task SyncPlatformsAsync(
|
||||
IEnumerable<string> platformTagNames, CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -100,8 +100,8 @@ internal sealed class EventPump : IAsyncDisposable
|
||||
// false immediately, which we account for via the EventsDropped counter.
|
||||
// We deliberately do NOT use BoundedChannelFullMode.DropWrite — that
|
||||
// would silently discard the new event inside Channel<T> without
|
||||
// surfacing the drop on a counter (Driver.Galaxy-005: keep the comment
|
||||
// and the FullMode value consistent).
|
||||
// surfacing the drop on a counter (keep this comment and the FullMode
|
||||
// value consistent).
|
||||
FullMode = BoundedChannelFullMode.Wait,
|
||||
SingleReader = true,
|
||||
SingleWriter = true,
|
||||
|
||||
@@ -68,6 +68,7 @@ public sealed class GalaxyMxSession : IAsyncDisposable
|
||||
/// </summary>
|
||||
/// <param name="clientOptions">The MX gateway client options.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async Task ConnectAsync(MxGatewayClientOptions clientOptions, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
@@ -107,6 +108,7 @@ public sealed class GalaxyMxSession : IAsyncDisposable
|
||||
/// </summary>
|
||||
/// <param name="clientOptions">The MX gateway client options.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async Task RecreateAsync(MxGatewayClientOptions clientOptions, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
@@ -136,6 +138,7 @@ public sealed class GalaxyMxSession : IAsyncDisposable
|
||||
public MxGatewaySession? Session => _session;
|
||||
|
||||
/// <summary>Disposes the session and underlying gateway client resources.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed) return;
|
||||
|
||||
@@ -8,6 +8,6 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||
/// </summary>
|
||||
internal sealed record GalaxySubscriptionHandle(long SubscriptionId) : ISubscriptionHandle
|
||||
{
|
||||
/// <summary>Gets the diagnostic identifier for the subscription.</summary>
|
||||
/// <inheritdoc />
|
||||
public string DiagnosticId => $"galaxy-sub-{SubscriptionId}";
|
||||
}
|
||||
|
||||
+1
-5
@@ -32,11 +32,7 @@ internal sealed class GatewayGalaxyAlarmAcknowledger : IGalaxyAlarmAcknowledger
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>Acknowledges an alarm via the gateway.</summary>
|
||||
/// <param name="alarmFullReference">The full reference path of the alarm to acknowledge.</param>
|
||||
/// <param name="comment">An operator-supplied comment attached to the acknowledgement.</param>
|
||||
/// <param name="operatorUser">The name of the operator performing the acknowledgement.</param>
|
||||
/// <param name="cancellationToken">A token to cancel the asynchronous operation.</param>
|
||||
/// <inheritdoc />
|
||||
public async Task AcknowledgeAsync(
|
||||
string alarmFullReference,
|
||||
string comment,
|
||||
|
||||
@@ -39,6 +39,7 @@ internal sealed class GatewayGalaxyAlarmFeed : IGalaxyAlarmFeed
|
||||
/// </summary>
|
||||
/// <param name="request">The stream request parameters.</param>
|
||||
/// <param name="cancellationToken">A cancellation token.</param>
|
||||
/// <returns>The async stream of decoded alarm-feed messages.</returns>
|
||||
internal delegate IAsyncEnumerable<AlarmFeedMessage> AlarmStreamFactory(
|
||||
StreamAlarmsRequest request, CancellationToken cancellationToken);
|
||||
|
||||
@@ -90,7 +91,7 @@ internal sealed class GatewayGalaxyAlarmFeed : IGalaxyAlarmFeed
|
||||
_clientTag = new KeyValuePair<string, object?>("galaxy.client", clientName ?? "<unknown>");
|
||||
}
|
||||
|
||||
/// <summary>Starts the alarm feed by opening the stream and processing messages in a background task.</summary>
|
||||
/// <inheritdoc />
|
||||
public void Start()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
@@ -261,6 +262,7 @@ internal sealed class GatewayGalaxyAlarmFeed : IGalaxyAlarmFeed
|
||||
};
|
||||
|
||||
/// <summary>Releases the alarm feed resources and stops the background stream task.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed) return;
|
||||
|
||||
@@ -115,11 +115,7 @@ public sealed class GatewayGalaxyDataWriter : IGalaxyDataWriter
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Writes values to Galaxy tags through the gateway.</summary>
|
||||
/// <param name="writes">The write requests.</param>
|
||||
/// <param name="securityResolver">Function to resolve security classification per tag.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The write results per request.</returns>
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
|
||||
IReadOnlyList<WriteRequest> writes,
|
||||
Func<string, SecurityClassification> securityResolver,
|
||||
|
||||
@@ -37,11 +37,7 @@ public sealed class GatewayGalaxySubscriber : IGalaxySubscriber
|
||||
_logger = logger ?? NullLogger.Instance;
|
||||
}
|
||||
|
||||
/// <summary>Subscribes to a bulk list of Galaxy references with optional buffered update interval.</summary>
|
||||
/// <param name="fullReferences">The full Galaxy tag references to subscribe to.</param>
|
||||
/// <param name="bufferedUpdateIntervalMs">The buffered update interval in milliseconds.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A task that returns a list of subscribe results.</returns>
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync(
|
||||
IReadOnlyList<string> fullReferences, int bufferedUpdateIntervalMs, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -142,10 +138,7 @@ public sealed class GatewayGalaxySubscriber : IGalaxySubscriber
|
||||
/// <returns><c>true</c> if the interval should be recorded as applied; otherwise <c>false</c>.</returns>
|
||||
internal static bool ClassifyIntervalReply(ProtocolStatusCode? code) => code == ProtocolStatusCode.Ok;
|
||||
|
||||
/// <summary>Unsubscribes from a bulk list of item handles.</summary>
|
||||
/// <param name="itemHandles">The item handles to unsubscribe from.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A task representing the unsubscribe operation.</returns>
|
||||
/// <inheritdoc />
|
||||
public async Task UnsubscribeBulkAsync(IReadOnlyList<int> itemHandles, CancellationToken cancellationToken)
|
||||
{
|
||||
if (itemHandles.Count == 0) return;
|
||||
@@ -159,9 +152,7 @@ public sealed class GatewayGalaxySubscriber : IGalaxySubscriber
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Streams Galaxy MX events asynchronously.</summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>An async enumerable of MX events.</returns>
|
||||
/// <inheritdoc />
|
||||
public IAsyncEnumerable<MxEvent> StreamEventsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var session = _session.Session
|
||||
|
||||
@@ -24,6 +24,7 @@ internal interface IGalaxyAlarmAcknowledger
|
||||
/// OPC UA session by the server-side ACL layer before reaching the driver.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">Cancels the gateway RPC.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
Task AcknowledgeAsync(
|
||||
string alarmFullReference,
|
||||
string comment,
|
||||
|
||||
@@ -26,6 +26,7 @@ public interface IGalaxyDataWriter
|
||||
/// (the safest default — non-secured Write).
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">Aborts the in-flight batch.</param>
|
||||
/// <returns>One <see cref="WriteResult"/> per request entry, in input order.</returns>
|
||||
Task<IReadOnlyList<WriteResult>> WriteAsync(
|
||||
IReadOnlyList<WriteRequest> writes,
|
||||
Func<string, SecurityClassification> securityResolver,
|
||||
|
||||
@@ -20,12 +20,14 @@ public interface IGalaxySubscriber
|
||||
/// <param name="fullReferences">The list of tag references to subscribe to.</param>
|
||||
/// <param name="bufferedUpdateIntervalMs">The buffered update interval in milliseconds.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>One <see cref="SubscribeResult"/> per requested reference, in input order.</returns>
|
||||
Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync(
|
||||
IReadOnlyList<string> fullReferences, int bufferedUpdateIntervalMs, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Unsubscribe a batch of item handles obtained from <see cref="SubscribeBulkAsync"/>.</summary>
|
||||
/// <param name="itemHandles">The item handles to unsubscribe.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
Task UnsubscribeBulkAsync(IReadOnlyList<int> itemHandles, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
@@ -34,5 +36,6 @@ public interface IGalaxySubscriber
|
||||
/// its <see cref="SubscriptionRegistry"/>.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token for the stream.</param>
|
||||
/// <returns>An asynchronous stream of <see cref="MxEvent"/>s as they arrive from the gateway.</returns>
|
||||
IAsyncEnumerable<MxEvent> StreamEventsAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ internal static class MxAccessSeverityMapper
|
||||
/// <see cref="AlarmSeverity"/> + OPC UA Part 9 numeric severity tuple.
|
||||
/// </summary>
|
||||
/// <param name="rawMxAccessSeverity">The raw MXAccess severity value (0-999 range, clamped if out of range).</param>
|
||||
/// <returns>The mapped four-bucket <see cref="AlarmSeverity"/> and its OPC UA Part 9 numeric severity.</returns>
|
||||
public static (AlarmSeverity Bucket, int OpcUaSeverity) Map(int rawMxAccessSeverity)
|
||||
{
|
||||
if (rawMxAccessSeverity < 250)
|
||||
|
||||
@@ -15,6 +15,7 @@ internal static class MxValueDecoder
|
||||
{
|
||||
/// <summary>Decodes a gateway MxValue into a boxed CLR object.</summary>
|
||||
/// <param name="value">The MxValue to decode, or null.</param>
|
||||
/// <returns>The decoded boxed CLR value, or null when <paramref name="value"/> is null or carries a null MxValue.</returns>
|
||||
public static object? Decode(MxValue? value)
|
||||
{
|
||||
if (value is null) return null;
|
||||
|
||||
@@ -25,7 +25,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||
/// <para>
|
||||
/// <b>Replay</b>: caller-supplied callback that re-establishes every active
|
||||
/// subscription. Production wraps gw's <c>ReplaySubscriptionsCommand</c>
|
||||
/// (mxaccessgw issue #0.3); when that's not available, the callback falls
|
||||
/// (tracked in the mxaccessgw backlog); when that's not available, the callback falls
|
||||
/// back to walking the SubscriptionRegistry and re-issuing SubscribeBulk for
|
||||
/// every tracked tag.
|
||||
/// </para>
|
||||
@@ -147,6 +147,7 @@ public sealed class ReconnectSupervisor : IDisposable
|
||||
/// and for orchestration that wants to gate calls on recovery completing.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Token to cancel the wait operation.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async Task WaitForHealthyAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested && IsDegraded)
|
||||
|
||||
@@ -19,10 +19,6 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||
internal sealed class SubscriptionRegistry
|
||||
{
|
||||
private readonly ConcurrentDictionary<long, SubscriptionEntry> _bySubscriptionId = new();
|
||||
// Driver.Galaxy-012: use ImmutableHashSet<long> for the reverse map so removal is
|
||||
// O(log n) instead of "rebuild the entire ConcurrentBag from a LINQ filter on every
|
||||
// unsubscribe"; reads are lock-free because the immutable snapshot is published
|
||||
// atomically via ConcurrentDictionary AddOrUpdate.
|
||||
private readonly ConcurrentDictionary<int, ImmutableHashSet<long>> _subscribersByItemHandle = new();
|
||||
// Forward index for the Galaxy writer: fullRef (case-insensitive) → live item handle.
|
||||
// Maintained in lock-step with _subscribersByItemHandle; entries are cleaned up when
|
||||
@@ -38,6 +34,7 @@ internal sealed class SubscriptionRegistry
|
||||
public int TrackedItemHandleCount => _subscribersByItemHandle.Count;
|
||||
|
||||
/// <summary>Allocate a fresh subscription id. Monotonic; unique per registry lifetime.</summary>
|
||||
/// <returns>The newly allocated subscription id.</returns>
|
||||
public long NextSubscriptionId() => Interlocked.Increment(ref _nextSubscriptionId);
|
||||
|
||||
/// <summary>
|
||||
@@ -75,8 +72,6 @@ internal sealed class SubscriptionRegistry
|
||||
foreach (var binding in entry.Bindings)
|
||||
{
|
||||
if (binding.ItemHandle <= 0) continue;
|
||||
// Driver.Galaxy-012: ImmutableHashSet.Remove is O(log n) and the result is
|
||||
// published atomically — no need to rebuild from a LINQ filter.
|
||||
if (!_subscribersByItemHandle.TryGetValue(binding.ItemHandle, out var set)) continue;
|
||||
var remaining = set.Remove(subscriptionId);
|
||||
if (remaining.IsEmpty)
|
||||
@@ -97,12 +92,6 @@ internal sealed class SubscriptionRegistry
|
||||
/// Look up the (subscription id, full reference) pairs that should receive an
|
||||
/// OnDataChange for the given gw item handle. Returns empty when nobody subscribes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Driver.Galaxy-012: O(1) per subscriber via the per-entry
|
||||
/// <c>FullRefByItemHandle</c> index, rather than a <c>FirstOrDefault</c> linear
|
||||
/// scan of the binding list. At 50k tags / 1Hz this turns each dispatch from a
|
||||
/// 50k-element scan into a single dictionary lookup.
|
||||
/// </remarks>
|
||||
/// <param name="itemHandle">The gateway item handle.</param>
|
||||
/// <returns>A list of subscription and reference pairs for the item handle.</returns>
|
||||
public IReadOnlyList<(long SubscriptionId, string FullReference)> ResolveSubscribers(int itemHandle)
|
||||
@@ -153,6 +142,7 @@ internal sealed class SubscriptionRegistry
|
||||
}
|
||||
|
||||
/// <summary>Snapshot every active binding for diagnostic output.</summary>
|
||||
/// <returns>A flattened list of every tag binding across all tracked subscriptions.</returns>
|
||||
public IReadOnlyList<TagBinding> SnapshotAllBindings() =>
|
||||
[.. _bySubscriptionId.Values.SelectMany(entry => entry.Bindings)];
|
||||
|
||||
@@ -161,6 +151,7 @@ internal sealed class SubscriptionRegistry
|
||||
/// Used by the reconnect replay path so it can re-issue SubscribeBulk per subscription
|
||||
/// and then <see cref="Rebind"/> each one with the post-reconnect item handles.
|
||||
/// </summary>
|
||||
/// <returns>Every tracked subscription id paired with its current tag bindings.</returns>
|
||||
public IReadOnlyList<(long SubscriptionId, IReadOnlyList<TagBinding> Bindings)> SnapshotEntries() =>
|
||||
[.. _bySubscriptionId.Values.Select(entry => (entry.SubscriptionId, entry.Bindings))];
|
||||
|
||||
@@ -178,7 +169,6 @@ internal sealed class SubscriptionRegistry
|
||||
|
||||
// Drop this subscription from every reverse-map set it currently appears in. The
|
||||
// pre-reconnect item handles are stale once the gw re-issues fresh ones.
|
||||
// Driver.Galaxy-012: ImmutableHashSet.Remove is O(log n) — no LINQ rebuild.
|
||||
foreach (var binding in oldEntry.Bindings)
|
||||
{
|
||||
if (binding.ItemHandle <= 0) continue;
|
||||
@@ -210,8 +200,8 @@ internal sealed class SubscriptionRegistry
|
||||
/// <summary>
|
||||
/// Per-subscription bookkeeping. <see cref="FullRefByItemHandle"/> is an index
|
||||
/// over <see cref="Bindings"/> keyed by item handle so <c>ResolveSubscribers</c>
|
||||
/// is O(1) per subscriber instead of a linear scan of every binding
|
||||
/// (Driver.Galaxy-012). Failed bindings (item handle ≤ 0) are excluded from the
|
||||
/// is O(1) per subscriber instead of a linear scan of every binding.
|
||||
/// Failed bindings (item handle ≤ 0) are excluded from the
|
||||
/// index because the EventPump only dispatches for positive handles.
|
||||
/// </summary>
|
||||
private sealed class SubscriptionEntry
|
||||
|
||||
@@ -11,13 +11,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||
internal sealed class TracedGalaxyDataWriter(IGalaxyDataWriter inner, string clientName) : IGalaxyDataWriter
|
||||
{
|
||||
/// <inheritdoc />
|
||||
/// <remarks>No span — this is a local cache-clear operation, not a gateway round-trip.</remarks>
|
||||
public void InvalidateHandleCaches() => inner.InvalidateHandleCaches();
|
||||
|
||||
/// <summary>Writes data to Galaxy while recording telemetry span.</summary>
|
||||
/// <param name="writes">The list of write requests to process.</param>
|
||||
/// <param name="securityResolver">Function to resolve security classification for tag references.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
|
||||
IReadOnlyList<WriteRequest> writes,
|
||||
Func<string, SecurityClassification> securityResolver,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user