docs: backfill XML documentation across 756 files
v2-ci / build (push) Failing after 1m43s
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 1m43s
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>, <typeparam>, and <inheritdoc/> tags to public members surfaced by commentchecker — resolves 5,847 of 5,869 issues (99.6%) across three /fixdocs passes.
This commit is contained in:
@@ -11,6 +11,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli;
|
||||
/// </summary>
|
||||
public abstract class AbCipCommandBase : DriverCommandBase
|
||||
{
|
||||
/// <summary>Gets the canonical AB CIP gateway address.</summary>
|
||||
[CommandOption("gateway", 'g', Description =
|
||||
"Canonical AB CIP gateway: ab://host[:port]/cip-path. Port defaults to 44818 " +
|
||||
"(EtherNet/IP). cip-path is family-specific: ControlLogix / CompactLogix need " +
|
||||
@@ -19,10 +20,12 @@ public abstract class AbCipCommandBase : DriverCommandBase
|
||||
IsRequired = true)]
|
||||
public string Gateway { get; init; } = default!;
|
||||
|
||||
/// <summary>Gets the PLC family type.</summary>
|
||||
[CommandOption("family", 'f', Description =
|
||||
"ControlLogix / CompactLogix / Micro800 / GuardLogix (default ControlLogix).")]
|
||||
public AbCipPlcFamily Family { get; init; } = AbCipPlcFamily.ControlLogix;
|
||||
|
||||
/// <summary>Gets the per-operation timeout in milliseconds.</summary>
|
||||
[CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 5000).")]
|
||||
public int TimeoutMs { get; init; } = 5000;
|
||||
|
||||
@@ -56,6 +59,7 @@ public abstract class AbCipCommandBase : DriverCommandBase
|
||||
/// supplies. Probe + alarm projection are disabled — CLI runs are one-shot; the
|
||||
/// probe loop would race the operator's own reads.
|
||||
/// </summary>
|
||||
/// <param name="tags">The list of tag definitions to include in the options.</param>
|
||||
protected AbCipDriverOptions BuildOptions(IReadOnlyList<AbCipTagDefinition> tags) => new()
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(
|
||||
@@ -82,6 +86,7 @@ public abstract class AbCipCommandBase : DriverCommandBase
|
||||
/// Throws a <see cref="CliFx.Exceptions.CommandException"/> if <paramref name="type"/>
|
||||
/// is <see cref="AbCipDataType.Structure"/>.
|
||||
/// </summary>
|
||||
/// <param name="type">The data type to validate.</param>
|
||||
protected static void RejectStructure(AbCipDataType type)
|
||||
{
|
||||
if (type == AbCipDataType.Structure)
|
||||
|
||||
@@ -12,16 +12,19 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Commands;
|
||||
[Command("probe", Description = "Verify the AB CIP gateway is reachable and a sample tag reads.")]
|
||||
public sealed class ProbeCommand : AbCipCommandBase
|
||||
{
|
||||
/// <summary>Gets the tag path to probe.</summary>
|
||||
[CommandOption("tag", 't', Description =
|
||||
"Tag path to probe. ControlLogix default is '@raw_cpu_type' (the canonical libplctag " +
|
||||
"system tag); Micro800 takes a user-supplied global (e.g. '_SYSVA_CLOCK_HOUR').",
|
||||
IsRequired = true)]
|
||||
public string TagPath { get; init; } = default!;
|
||||
|
||||
/// <summary>Gets the data type of the probe tag.</summary>
|
||||
[CommandOption("type", Description =
|
||||
"Logix atomic type of the probe tag (default DInt).")]
|
||||
public AbCipDataType DataType { get; init; } = AbCipDataType.DInt;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
|
||||
@@ -13,17 +13,22 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Commands;
|
||||
[Command("read", Description = "Read a single Logix tag by symbolic path.")]
|
||||
public sealed class ReadCommand : AbCipCommandBase
|
||||
{
|
||||
/// <summary>Gets the tag path to read.</summary>
|
||||
[CommandOption("tag", 't', Description =
|
||||
"Logix symbolic path. Controller scope: 'Motor01_Speed'. Program scope: " +
|
||||
"'Program:Main.Motor01_Speed'. Array element: 'Recipe[3]'. UDT member: " +
|
||||
"'Motor01.Speed'.", IsRequired = true)]
|
||||
public string TagPath { get; init; } = default!;
|
||||
|
||||
/// <summary>Gets the data type to read as.</summary>
|
||||
[CommandOption("type", Description =
|
||||
"Bool / SInt / Int / DInt / LInt / USInt / UInt / UDInt / ULInt / Real / LReal / " +
|
||||
"String / Dt / Structure (default DInt).")]
|
||||
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)
|
||||
{
|
||||
ConfigureLogging();
|
||||
@@ -59,6 +64,8 @@ public sealed class ReadCommand : AbCipCommandBase
|
||||
/// Tag-name key the driver uses internally. The path + type pair is already unique
|
||||
/// so we use them verbatim — keeps tag-level diagnostics readable without mangling.
|
||||
/// </summary>
|
||||
/// <param name="tagPath">The symbolic tag path.</param>
|
||||
/// <param name="type">The data type.</param>
|
||||
internal static string SynthesiseTagName(string tagPath, AbCipDataType type)
|
||||
=> $"{tagPath}:{type}";
|
||||
}
|
||||
|
||||
@@ -13,20 +13,24 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Commands;
|
||||
[Command("subscribe", Description = "Watch a Logix tag via polled subscription until Ctrl+C.")]
|
||||
public sealed class SubscribeCommand : AbCipCommandBase
|
||||
{
|
||||
/// <summary>Gets or sets the Logix tag path to subscribe to.</summary>
|
||||
[CommandOption("tag", 't', Description =
|
||||
"Logix symbolic path — same format as `read`.", IsRequired = true)]
|
||||
public string TagPath { get; init; } = default!;
|
||||
|
||||
/// <summary>Gets or sets the data type of the tag.</summary>
|
||||
[CommandOption("type", Description =
|
||||
"Bool / SInt / Int / DInt / LInt / USInt / UInt / UDInt / ULInt / Real / LReal / " +
|
||||
"String / Dt (default DInt).")]
|
||||
public AbCipDataType DataType { get; init; } = AbCipDataType.DInt;
|
||||
|
||||
/// <summary>Gets or sets the subscription interval in milliseconds.</summary>
|
||||
[CommandOption("interval-ms", 'i', Description =
|
||||
"Publishing interval in milliseconds (default 1000). PollGroupEngine floors " +
|
||||
"sub-250ms values.")]
|
||||
public int IntervalMs { get; init; } = 1000;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
@@ -98,6 +102,7 @@ public sealed class SubscribeCommand : AbCipCommandBase
|
||||
/// <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.
|
||||
/// </summary>
|
||||
/// <param name="intervalMs">The interval in milliseconds.</param>
|
||||
internal static void ValidateInterval(int intervalMs)
|
||||
{
|
||||
if (intervalMs <= 0)
|
||||
|
||||
@@ -15,20 +15,24 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Commands;
|
||||
[Command("write", Description = "Write a single Logix tag by symbolic path.")]
|
||||
public sealed class WriteCommand : AbCipCommandBase
|
||||
{
|
||||
/// <summary>Gets the Logix symbolic path of the tag to write.</summary>
|
||||
[CommandOption("tag", 't', Description =
|
||||
"Logix symbolic path — same format as `read`.", IsRequired = true)]
|
||||
public string TagPath { get; init; } = default!;
|
||||
|
||||
/// <summary>Gets the data type to write.</summary>
|
||||
[CommandOption("type", Description =
|
||||
"Bool / SInt / Int / DInt / LInt / USInt / UInt / UDInt / ULInt / Real / LReal / " +
|
||||
"String / Dt (default DInt).")]
|
||||
public AbCipDataType DataType { get; init; } = AbCipDataType.DInt;
|
||||
|
||||
/// <summary>Gets the value to write, as a string.</summary>
|
||||
[CommandOption("value", 'v', Description =
|
||||
"Value to write. Parsed per --type (booleans accept true/false/1/0).",
|
||||
IsRequired = true)]
|
||||
public string Value { get; init; } = default!;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
@@ -73,6 +77,9 @@ public sealed class WriteCommand : AbCipCommandBase
|
||||
/// <see cref="CliFx.Exceptions.CommandException"/> so CliFx renders a clean one-line
|
||||
/// error rather than a full .NET stack trace.
|
||||
/// </summary>
|
||||
/// <param name="raw">The raw string value to parse.</param>
|
||||
/// <param name="type">The AbCip data type to parse the value into.</param>
|
||||
/// <returns>The parsed value as an object.</returns>
|
||||
internal static object ParseValue(string raw, AbCipDataType type)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -11,6 +11,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli;
|
||||
/// </summary>
|
||||
public abstract class AbLegacyCommandBase : DriverCommandBase
|
||||
{
|
||||
/// <summary>Gets the gateway endpoint for AB Legacy connection.</summary>
|
||||
[CommandOption("gateway", 'g', Description =
|
||||
"Canonical AB Legacy gateway: ab://host[:port]/cip-path. Port defaults to 44818. " +
|
||||
"cip-path depends on the family: SLC 5/05 + PLC-5 typically '1,0'; MicroLogix " +
|
||||
@@ -18,10 +19,12 @@ public abstract class AbLegacyCommandBase : DriverCommandBase
|
||||
IsRequired = true)]
|
||||
public string Gateway { get; init; } = default!;
|
||||
|
||||
/// <summary>Gets the PLC family type.</summary>
|
||||
[CommandOption("plc-type", 'P', Description =
|
||||
"Slc500 / MicroLogix / Plc5 / LogixPccc (default Slc500).")]
|
||||
public AbLegacyPlcFamily PlcType { get; init; } = AbLegacyPlcFamily.Slc500;
|
||||
|
||||
/// <summary>Gets the per-operation timeout in milliseconds.</summary>
|
||||
[CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 5000).")]
|
||||
public int TimeoutMs { get; init; } = 5000;
|
||||
|
||||
@@ -36,6 +39,8 @@ public abstract class AbLegacyCommandBase : DriverCommandBase
|
||||
/// Build an <see cref="AbLegacyDriverOptions"/> with the device + tag list a subclass
|
||||
/// supplies. Probe disabled for CLI one-shot runs.
|
||||
/// </summary>
|
||||
/// <param name="tags">The tag definitions to include in the options.</param>
|
||||
/// <returns>Configured AB Legacy driver options.</returns>
|
||||
protected AbLegacyDriverOptions BuildOptions(IReadOnlyList<AbLegacyTagDefinition> tags) => new()
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions(
|
||||
@@ -47,5 +52,6 @@ public abstract class AbLegacyCommandBase : DriverCommandBase
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
};
|
||||
|
||||
/// <summary>Gets the driver instance identifier for CLI operations.</summary>
|
||||
protected string DriverInstanceId => $"ablegacy-cli-{Gateway}";
|
||||
}
|
||||
|
||||
@@ -12,15 +12,18 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Commands;
|
||||
[Command("probe", Description = "Verify the AB Legacy endpoint is reachable and a sample PCCC read succeeds.")]
|
||||
public sealed class ProbeCommand : AbLegacyCommandBase
|
||||
{
|
||||
/// <summary>Gets or sets the PCCC address to probe.</summary>
|
||||
[CommandOption("address", 'a', Description =
|
||||
"PCCC address to probe (default N7:0). Use S:0 for the status file when you want " +
|
||||
"the pre-populated register every SLC / MicroLogix / PLC-5 ships with.")]
|
||||
public string Address { get; init; } = "N7:0";
|
||||
|
||||
/// <summary>Gets or sets the PCCC data type of the probe address.</summary>
|
||||
[CommandOption("type", 't', Description =
|
||||
"PCCC data type of the probe address (default Int — matches N files).")]
|
||||
public AbLegacyDataType DataType { get; init; } = AbLegacyDataType.Int;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
|
||||
@@ -10,6 +10,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Commands;
|
||||
[Command("read", Description = "Read a single PCCC file address.")]
|
||||
public sealed class ReadCommand : AbLegacyCommandBase
|
||||
{
|
||||
/// <summary>Gets the PCCC file address to read.</summary>
|
||||
[CommandOption("address", 'a', Description =
|
||||
"PCCC file address. File letter implies storage; bit-within-word via slash " +
|
||||
"(B3:0/3 or N7:0/5). Sub-element access for timers/counters/controls uses " +
|
||||
@@ -17,11 +18,13 @@ public sealed class ReadCommand : AbLegacyCommandBase
|
||||
IsRequired = true)]
|
||||
public string Address { get; init; } = default!;
|
||||
|
||||
/// <summary>Gets the data type of the address.</summary>
|
||||
[CommandOption("type", 't', Description =
|
||||
"Bit / Int / Long / Float / AnalogInt / String / TimerElement / CounterElement / " +
|
||||
"ControlElement (default Int).")]
|
||||
public AbLegacyDataType DataType { get; init; } = AbLegacyDataType.Int;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
@@ -53,6 +56,8 @@ 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>
|
||||
internal static string SynthesiseTagName(string address, AbLegacyDataType type)
|
||||
=> $"{address}:{type}";
|
||||
}
|
||||
|
||||
@@ -13,18 +13,22 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Commands;
|
||||
public sealed class SubscribeCommand : AbLegacyCommandBase
|
||||
{
|
||||
[CommandOption("address", 'a', Description = "PCCC file address — same format as `read`.", IsRequired = true)]
|
||||
/// <summary>Gets or sets the PCCC file address to subscribe to.</summary>
|
||||
public string Address { get; init; } = default!;
|
||||
|
||||
[CommandOption("type", 't', Description =
|
||||
"Bit / Int / Long / Float / AnalogInt / String / TimerElement / CounterElement / " +
|
||||
"ControlElement (default Int).")]
|
||||
/// <summary>Gets or sets the data type of the address.</summary>
|
||||
public AbLegacyDataType DataType { get; init; } = AbLegacyDataType.Int;
|
||||
|
||||
[CommandOption("interval-ms", 'i', Description =
|
||||
"Publishing interval in milliseconds (default 1000). PollGroupEngine floors " +
|
||||
"sub-250ms values.")]
|
||||
/// <summary>Gets or sets the polling interval in milliseconds.</summary>
|
||||
public int IntervalMs { get; init; } = 1000;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
|
||||
@@ -15,20 +15,24 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Commands;
|
||||
[Command("write", Description = "Write a single PCCC file address.")]
|
||||
public sealed class WriteCommand : AbLegacyCommandBase
|
||||
{
|
||||
/// <summary>Gets or sets the PCCC file address to write to.</summary>
|
||||
[CommandOption("address", 'a', Description =
|
||||
"PCCC file address — same format as `read`.", IsRequired = true)]
|
||||
public string Address { get; init; } = default!;
|
||||
|
||||
/// <summary>Gets or sets the data type for the write operation.</summary>
|
||||
[CommandOption("type", 't', Description =
|
||||
"Bit / Int / Long / Float / AnalogInt / String / TimerElement / CounterElement / " +
|
||||
"ControlElement (default Int).")]
|
||||
public AbLegacyDataType DataType { get; init; } = AbLegacyDataType.Int;
|
||||
|
||||
/// <summary>Gets or sets the value to write.</summary>
|
||||
[CommandOption("value", 'v', Description =
|
||||
"Value to write. Parsed per --type (booleans accept true/false, 1/0, on/off, yes/no).",
|
||||
IsRequired = true)]
|
||||
public string Value { get; init; } = default!;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
@@ -62,6 +66,9 @@ public sealed class WriteCommand : AbLegacyCommandBase
|
||||
}
|
||||
|
||||
/// <summary>Parse <c>--value</c> per <see cref="AbLegacyDataType"/>, invariant culture.</summary>
|
||||
/// <param name="raw">The raw string value to parse.</param>
|
||||
/// <param name="type">The target data type for parsing.</param>
|
||||
/// <returns>The parsed value.</returns>
|
||||
/// <exception cref="CliFx.Exceptions.CommandException">
|
||||
/// Thrown when <paramref name="raw"/> cannot be parsed as the requested type (malformed
|
||||
/// input or out-of-range value) so CliFx renders a clean one-line error instead of a raw
|
||||
|
||||
@@ -41,6 +41,9 @@ public abstract class DriverCommandBase : ICommand
|
||||
/// </summary>
|
||||
public abstract TimeSpan Timeout { get; init; }
|
||||
|
||||
/// <summary>Executes the command with the given console output.</summary>
|
||||
/// <param name="console">The console for output.</param>
|
||||
/// <returns>A task that completes when the command is finished.</returns>
|
||||
public abstract ValueTask ExecuteAsync(IConsole console);
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -21,6 +21,8 @@ public static class SnapshotFormatter
|
||||
/// Server Time: 2026-04-21T12:34:56.790Z
|
||||
/// </code>
|
||||
/// </summary>
|
||||
/// <param name="tagName">The tag name to include in the output.</param>
|
||||
/// <param name="snapshot">The data value snapshot to format.</param>
|
||||
public static string Format(string tagName, DataValueSnapshot snapshot)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(snapshot);
|
||||
@@ -38,6 +40,8 @@ public static class SnapshotFormatter
|
||||
/// <summary>
|
||||
/// Write-result render, one line: <c>Write <tag>: 0x... (Good|...)</c>.
|
||||
/// </summary>
|
||||
/// <param name="tagName">The tag name to include in the output.</param>
|
||||
/// <param name="result">The write result to format.</param>
|
||||
public static string FormatWrite(string tagName, WriteResult result)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
@@ -48,6 +52,8 @@ public static class SnapshotFormatter
|
||||
/// Table-style render for batch reads. Emits an aligned 4-column layout:
|
||||
/// tag / value / status / source-time.
|
||||
/// </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>
|
||||
public static string FormatTable(
|
||||
IReadOnlyList<string> tagNames, IReadOnlyList<DataValueSnapshot> snapshots)
|
||||
{
|
||||
@@ -92,6 +98,9 @@ public static class SnapshotFormatter
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
/// <summary>Formats a value for console output, handling null, bool, string, and formattable types.</summary>
|
||||
/// <param name="value">The value to format.</param>
|
||||
/// <returns>A formatted string representation of the value.</returns>
|
||||
public static string FormatValue(object? value) => value switch
|
||||
{
|
||||
null => "<null>",
|
||||
@@ -101,6 +110,9 @@ public static class SnapshotFormatter
|
||||
_ => value.ToString() ?? "<null>",
|
||||
};
|
||||
|
||||
/// <summary>Formats an OPC UA status code as a hexadecimal string with named severity classification.</summary>
|
||||
/// <param name="statusCode">The OPC UA status code to format.</param>
|
||||
/// <returns>A formatted status string like "0x00000000 (Good)" or "0x80050000 (BadCommunicationError)".</returns>
|
||||
public static string FormatStatus(uint statusCode)
|
||||
{
|
||||
// OPC UA status codes carry sub-code and flag bits in the low 16 bits (info type,
|
||||
@@ -149,6 +161,9 @@ public static class SnapshotFormatter
|
||||
: $"0x{statusCode:X8} ({name})";
|
||||
}
|
||||
|
||||
/// <summary>Formats a UTC timestamp as an ISO 8601 string, or "-" if null.</summary>
|
||||
/// <param name="ts">The timestamp to format, or null.</param>
|
||||
/// <returns>An ISO 8601 formatted string or "-".</returns>
|
||||
public static string FormatTimestamp(DateTime? ts)
|
||||
{
|
||||
if (ts is null) return "-";
|
||||
|
||||
@@ -14,13 +14,16 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Commands;
|
||||
[Command("probe", Description = "Verify the CNC is reachable + a sample FOCAS read succeeds.")]
|
||||
public sealed class ProbeCommand : FocasCommandBase
|
||||
{
|
||||
/// <summary>Gets the FOCAS address to probe.</summary>
|
||||
[CommandOption("address", 'a', Description =
|
||||
"FOCAS address to probe (default R100 — PMC R-file register 100).")]
|
||||
public string Address { get; init; } = "R100";
|
||||
|
||||
/// <summary>Gets the data type to use for the probe read.</summary>
|
||||
[CommandOption("type", Description = "Data type (default Int16).")]
|
||||
public FocasDataType DataType { get; init; } = FocasDataType.Int16;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
|
||||
@@ -10,16 +10,20 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Commands;
|
||||
[Command("read", Description = "Read a single FOCAS address.")]
|
||||
public sealed class ReadCommand : FocasCommandBase
|
||||
{
|
||||
/// <summary>Gets the FOCAS address to read.</summary>
|
||||
[CommandOption("address", 'a', Description =
|
||||
"FOCAS address. Examples: R100 (PMC R-file word); X0.0 (PMC X-bit); " +
|
||||
"PARAM:1815/0 (parameter 1815, axis 0); MACRO:500 (macro variable 500).",
|
||||
IsRequired = true)]
|
||||
public string Address { get; init; } = default!;
|
||||
|
||||
/// <summary>Gets the data type to interpret the address as.</summary>
|
||||
[CommandOption("type", 't', Description =
|
||||
"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();
|
||||
@@ -45,6 +49,9 @@ public sealed class ReadCommand : FocasCommandBase
|
||||
await console.Output.WriteLineAsync(SnapshotFormatter.Format(Address, snapshot[0]));
|
||||
}
|
||||
|
||||
/// <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>
|
||||
internal static string SynthesiseTagName(string address, FocasDataType type)
|
||||
=> $"{address}:{type}";
|
||||
}
|
||||
|
||||
@@ -12,16 +12,20 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Commands;
|
||||
[Command("subscribe", Description = "Watch a FOCAS address via polled subscription until Ctrl+C.")]
|
||||
public sealed class SubscribeCommand : FocasCommandBase
|
||||
{
|
||||
/// <summary>Gets the FOCAS address to subscribe to.</summary>
|
||||
[CommandOption("address", 'a', Description = "FOCAS address — same format as `read`.", IsRequired = true)]
|
||||
public string Address { get; init; } = default!;
|
||||
|
||||
/// <summary>Gets the data type of the address.</summary>
|
||||
[CommandOption("type", 't', Description =
|
||||
"Bit / Byte / Int16 / Int32 / Float32 / Float64 / String (default Int16).")]
|
||||
public FocasDataType DataType { get; init; } = FocasDataType.Int16;
|
||||
|
||||
/// <summary>Gets the polling interval in milliseconds.</summary>
|
||||
[CommandOption("interval-ms", 'i', Description = "Publishing interval ms (default 1000).")]
|
||||
public int IntervalMs { get; init; } = 1000;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
|
||||
@@ -14,18 +14,22 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Commands;
|
||||
[Command("write", Description = "Write a single FOCAS address.")]
|
||||
public sealed class WriteCommand : FocasCommandBase
|
||||
{
|
||||
/// <summary>Gets the FOCAS address to write to.</summary>
|
||||
[CommandOption("address", 'a', Description = "FOCAS address — same format as `read`.", IsRequired = true)]
|
||||
public string Address { get; init; } = default!;
|
||||
|
||||
/// <summary>Gets the data type of the value to write.</summary>
|
||||
[CommandOption("type", 't', Description =
|
||||
"Bit / Byte / Int16 / Int32 / Float32 / Float64 / String (default Int16).")]
|
||||
public FocasDataType DataType { get; init; } = FocasDataType.Int16;
|
||||
|
||||
/// <summary>Gets the value to write.</summary>
|
||||
[CommandOption("value", 'v', Description =
|
||||
"Value to write. Parsed per --type (booleans accept true/false/1/0).",
|
||||
IsRequired = true)]
|
||||
public string Value { get; init; } = default!;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
@@ -62,6 +66,9 @@ public sealed class WriteCommand : FocasCommandBase
|
||||
/// as a clean <see cref="CliFx.Exceptions.CommandException"/> rather than a raw
|
||||
/// .NET stack trace — matching the friendly message the Bit path already produces.
|
||||
/// </remarks>
|
||||
/// <param name="raw">The raw string value to parse.</param>
|
||||
/// <param name="type">The data type to parse the value as.</param>
|
||||
/// <returns>The parsed value as an object.</returns>
|
||||
internal static object ParseValue(string raw, FocasDataType type)
|
||||
{
|
||||
if (type == FocasDataType.Bit) return ParseBool(raw);
|
||||
|
||||
@@ -10,19 +10,23 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli;
|
||||
/// </summary>
|
||||
public abstract class FocasCommandBase : DriverCommandBase
|
||||
{
|
||||
/// <summary>Gets the CNC IP address or hostname.</summary>
|
||||
[CommandOption("cnc-host", 'h', Description =
|
||||
"CNC IP address or hostname. FOCAS-over-EIP listens on port 8193 by default.",
|
||||
IsRequired = true)]
|
||||
public string CncHost { get; init; } = default!;
|
||||
|
||||
/// <summary>Gets the FOCAS TCP port.</summary>
|
||||
[CommandOption("cnc-port", 'p', Description = "FOCAS TCP port (default 8193).")]
|
||||
public int CncPort { get; init; } = 8193;
|
||||
|
||||
/// <summary>Gets the CNC series.</summary>
|
||||
[CommandOption("series", 's', Description =
|
||||
"CNC series: Unknown / Zero_i_D / Zero_i_F / Zero_i_MF / Zero_i_TF / Sixteen_i / " +
|
||||
"Thirty_i / ThirtyOne_i / ThirtyTwo_i / PowerMotion_i (default Unknown).")]
|
||||
public FocasCncSeries Series { get; init; } = FocasCncSeries.Unknown;
|
||||
|
||||
/// <summary>Gets the per-operation timeout in milliseconds.</summary>
|
||||
[CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 2000).")]
|
||||
public int TimeoutMs { get; init; } = 2000;
|
||||
|
||||
@@ -42,6 +46,7 @@ public abstract class FocasCommandBase : DriverCommandBase
|
||||
/// wire client opens a TCP:8193 session to the CNC and surfaces unreachable endpoints
|
||||
/// as <c>BadCommunicationError</c>.
|
||||
/// </summary>
|
||||
/// <param name="tags">The tag definitions to include in the driver options.</param>
|
||||
protected FocasDriverOptions BuildOptions(IReadOnlyList<FocasTagDefinition> tags) => new()
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(
|
||||
@@ -53,6 +58,7 @@ public abstract class FocasCommandBase : DriverCommandBase
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
};
|
||||
|
||||
/// <summary>Gets the driver instance ID.</summary>
|
||||
protected string DriverInstanceId => $"focas-cli-{CncHost}:{CncPort}";
|
||||
|
||||
/// <summary>
|
||||
@@ -64,6 +70,7 @@ public abstract class FocasCommandBase : DriverCommandBase
|
||||
/// option is subscribe-only — pass <c>null</c> for probe/read/write so this
|
||||
/// helper can be a single shared validator.
|
||||
/// </summary>
|
||||
/// <param name="intervalMs">The interval in milliseconds, or null if not applicable.</param>
|
||||
protected void ValidateOptions(int? intervalMs = null)
|
||||
{
|
||||
if (CncPort < 1 || CncPort > 65535)
|
||||
|
||||
@@ -14,11 +14,13 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Commands;
|
||||
[Command("probe", Description = "Verify the Modbus-TCP endpoint is reachable and speaks Modbus.")]
|
||||
public sealed class ProbeCommand : ModbusCommandBase
|
||||
{
|
||||
/// <summary>Gets the holding-register address to use for the probe read.</summary>
|
||||
[CommandOption("probe-address", Description =
|
||||
"Holding-register address used as the cheap-read probe (default 0). Some PLCs lock " +
|
||||
"register 0 — set this to a known-good address on your device.")]
|
||||
public ushort ProbeAddress { get; init; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
@@ -80,6 +82,9 @@ public sealed class ProbeCommand : ModbusCommandBase
|
||||
/// <item><c>OK</c> — driver Healthy and snapshot Good.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
/// <param name="state">The driver's health state.</param>
|
||||
/// <param name="statusCode">The OPC UA status code from the probe read.</param>
|
||||
/// <returns>A string describing the verdict.</returns>
|
||||
public static string ComputeVerdict(DriverState state, uint statusCode)
|
||||
{
|
||||
// OPC UA StatusCode top 2 bits encode the quality class:
|
||||
|
||||
@@ -14,35 +14,43 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Commands;
|
||||
[Command("read", Description = "Read a single Modbus register or coil.")]
|
||||
public sealed class ReadCommand : ModbusCommandBase
|
||||
{
|
||||
/// <summary>Gets the Modbus region to read from.</summary>
|
||||
[CommandOption("region", 'r', Description =
|
||||
"Coils / DiscreteInputs / InputRegisters / HoldingRegisters", IsRequired = true)]
|
||||
public ModbusRegion Region { get; init; }
|
||||
|
||||
/// <summary>Gets the zero-based address within the region.</summary>
|
||||
[CommandOption("address", 'a', Description =
|
||||
"Zero-based address within the region.", IsRequired = true)]
|
||||
public ushort Address { get; init; }
|
||||
|
||||
/// <summary>Gets the data type to read.</summary>
|
||||
[CommandOption("type", 't', Description =
|
||||
"Bool / Int16 / UInt16 / Int32 / UInt32 / Int64 / UInt64 / Float32 / Float64 / " +
|
||||
"BitInRegister / String / Bcd16 / Bcd32", IsRequired = true)]
|
||||
public ModbusDataType DataType { get; init; }
|
||||
|
||||
/// <summary>Gets the byte order for multi-register types.</summary>
|
||||
[CommandOption("byte-order", Description =
|
||||
"BigEndian (default, spec ABCD) or WordSwap (CDAB). Ignored for single-register types.")]
|
||||
public ModbusByteOrder ByteOrder { get; init; } = ModbusByteOrder.BigEndian;
|
||||
|
||||
/// <summary>Gets the bit index for BitInRegister type.</summary>
|
||||
[CommandOption("bit-index", Description =
|
||||
"For type=BitInRegister: bit 0-15 LSB-first.")]
|
||||
public byte BitIndex { get; init; }
|
||||
|
||||
/// <summary>Gets the string length for String type.</summary>
|
||||
[CommandOption("string-length", Description =
|
||||
"For type=String: character count (2 per register, rounded up).")]
|
||||
public ushort StringLength { get; init; }
|
||||
|
||||
/// <summary>Gets the byte order for string values.</summary>
|
||||
[CommandOption("string-byte-order", Description =
|
||||
"For type=String: HighByteFirst (standard) or LowByteFirst (DirectLOGIC et al).")]
|
||||
public ModbusStringByteOrder StringByteOrder { get; init; } = ModbusStringByteOrder.HighByteFirst;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
@@ -86,6 +94,10 @@ public sealed class ReadCommand : ModbusCommandBase
|
||||
/// (<c>HR[100]</c>, <c>Coil[5]</c>, <c>IR[42]</c>) — the driver treats the name
|
||||
/// purely as a lookup key, so any stable string works.
|
||||
/// </summary>
|
||||
/// <param name="region">The Modbus region (Coils, DiscreteInputs, InputRegisters, or HoldingRegisters).</param>
|
||||
/// <param name="address">The zero-based address within the region.</param>
|
||||
/// <param name="type">The Modbus data type being read.</param>
|
||||
/// <returns>A human-readable tag name.</returns>
|
||||
internal static string SynthesiseTagName(
|
||||
ModbusRegion region, ushort address, ModbusDataType type)
|
||||
{
|
||||
|
||||
@@ -14,23 +14,28 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Commands;
|
||||
[Command("subscribe", Description = "Watch a Modbus register via polled subscription until Ctrl+C.")]
|
||||
public sealed class SubscribeCommand : ModbusCommandBase
|
||||
{
|
||||
/// <summary>Gets or sets the Modbus register region (Coils, DiscreteInputs, InputRegisters, or HoldingRegisters).</summary>
|
||||
[CommandOption("region", 'r', Description =
|
||||
"Coils / DiscreteInputs / InputRegisters / HoldingRegisters", IsRequired = true)]
|
||||
public ModbusRegion Region { get; init; }
|
||||
|
||||
/// <summary>Gets or sets the zero-based address within the region.</summary>
|
||||
[CommandOption("address", 'a', Description = "Zero-based address within the region.", IsRequired = true)]
|
||||
public ushort Address { get; init; }
|
||||
|
||||
/// <summary>Gets or sets the data type (Bool, Int16, UInt16, Int32, UInt32, Int64, UInt64, Float32, Float64, BitInRegister, String, Bcd16, Bcd32).</summary>
|
||||
[CommandOption("type", 't', Description =
|
||||
"Bool / Int16 / UInt16 / Int32 / UInt32 / Int64 / UInt64 / Float32 / Float64 / " +
|
||||
"BitInRegister / String / Bcd16 / Bcd32", IsRequired = true)]
|
||||
public ModbusDataType DataType { get; init; }
|
||||
|
||||
/// <summary>Gets or sets the publishing interval in milliseconds.</summary>
|
||||
[CommandOption("interval-ms", 'i', Description =
|
||||
"Publishing interval in milliseconds (default 1000). The PollGroupEngine enforces " +
|
||||
"a floor of ~250ms; values below it get rounded up.")]
|
||||
public int IntervalMs { get; init; } = 1000;
|
||||
|
||||
/// <summary>Gets or sets the byte order (BigEndian or WordSwap).</summary>
|
||||
[CommandOption("byte-order", Description =
|
||||
"BigEndian (default) or WordSwap.")]
|
||||
public ModbusByteOrder ByteOrder { get; init; } = ModbusByteOrder.BigEndian;
|
||||
@@ -38,18 +43,22 @@ public sealed class SubscribeCommand : ModbusCommandBase
|
||||
// Driver.Modbus.Cli-001: 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>
|
||||
[CommandOption("bit-index", Description =
|
||||
"For type=BitInRegister: which bit of the holding register (0-15, LSB-first).")]
|
||||
public byte BitIndex { get; init; }
|
||||
|
||||
/// <summary>Gets or sets the string length for type=String (character count, 2 per register).</summary>
|
||||
[CommandOption("string-length", Description =
|
||||
"For type=String: character count (2 per register, rounded up).")]
|
||||
public ushort StringLength { get; init; }
|
||||
|
||||
/// <summary>Gets or sets the string byte order for type=String (HighByteFirst or LowByteFirst).</summary>
|
||||
[CommandOption("string-byte-order", Description =
|
||||
"For type=String: HighByteFirst (standard) or LowByteFirst (DirectLOGIC).")]
|
||||
public ModbusStringByteOrder StringByteOrder { get; init; } = ModbusStringByteOrder.HighByteFirst;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
|
||||
@@ -16,41 +16,51 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Commands;
|
||||
[Command("write", Description = "Write a single Modbus coil or holding register.")]
|
||||
public sealed class WriteCommand : ModbusCommandBase
|
||||
{
|
||||
/// <summary>Gets the Modbus region to write to.</summary>
|
||||
[CommandOption("region", 'r', Description =
|
||||
"Coils or HoldingRegisters (the only writable regions per the protocol spec).",
|
||||
IsRequired = true)]
|
||||
public ModbusRegion Region { get; init; }
|
||||
|
||||
/// <summary>Gets the zero-based address within the region.</summary>
|
||||
[CommandOption("address", 'a', Description =
|
||||
"Zero-based address within the region.", IsRequired = true)]
|
||||
public ushort Address { get; init; }
|
||||
|
||||
/// <summary>Gets the data type of the value to write.</summary>
|
||||
[CommandOption("type", 't', Description =
|
||||
"Bool / Int16 / UInt16 / Int32 / UInt32 / Int64 / UInt64 / Float32 / Float64 / " +
|
||||
"BitInRegister / String / Bcd16 / Bcd32", IsRequired = true)]
|
||||
public ModbusDataType DataType { get; init; }
|
||||
|
||||
/// <summary>Gets the value string to write and parse.</summary>
|
||||
[CommandOption("value", 'v', Description =
|
||||
"Value to write. Parsed per --type (booleans accept true/false/0/1).",
|
||||
IsRequired = true)]
|
||||
public string Value { get; init; } = default!;
|
||||
|
||||
/// <summary>Gets the byte order for multi-register values.</summary>
|
||||
[CommandOption("byte-order", Description =
|
||||
"BigEndian (default, ABCD) or WordSwap (CDAB). Ignored for single-register types.")]
|
||||
public ModbusByteOrder ByteOrder { get; init; } = ModbusByteOrder.BigEndian;
|
||||
|
||||
/// <summary>Gets the bit index for BitInRegister type.</summary>
|
||||
[CommandOption("bit-index", Description =
|
||||
"For type=BitInRegister: which bit of the holding register (0-15, LSB-first).")]
|
||||
public byte BitIndex { get; init; }
|
||||
|
||||
/// <summary>Gets the string length for String type.</summary>
|
||||
[CommandOption("string-length", Description =
|
||||
"For type=String: character count (2 per register, rounded up).")]
|
||||
public ushort StringLength { get; init; }
|
||||
|
||||
/// <summary>Gets the byte order for string characters.</summary>
|
||||
[CommandOption("string-byte-order", Description =
|
||||
"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)
|
||||
{
|
||||
ConfigureLogging();
|
||||
@@ -106,10 +116,13 @@ public sealed class WriteCommand : ModbusCommandBase
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse the operator's <c>--value</c> string into the CLR type the driver expects
|
||||
/// for the declared <see cref="ModbusDataType"/>. Uses invariant culture everywhere
|
||||
/// Parses the operator's <c>--value</c> string into the CLR type the driver expects
|
||||
/// for the declared data type. Uses invariant culture everywhere
|
||||
/// so <c>3.14</c> and <c>3,14</c> don't swap meaning between runs.
|
||||
/// </summary>
|
||||
/// <param name="raw">The raw value string from the command line.</param>
|
||||
/// <param name="type">The data type to parse into.</param>
|
||||
/// <returns>The parsed value in the appropriate CLR type.</returns>
|
||||
internal static object ParseValue(string raw, ModbusDataType type) => type switch
|
||||
{
|
||||
ModbusDataType.Bool or ModbusDataType.BitInRegister => ParseBool(raw),
|
||||
|
||||
@@ -12,18 +12,23 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli;
|
||||
/// </summary>
|
||||
public abstract class ModbusCommandBase : DriverCommandBase
|
||||
{
|
||||
/// <summary>Gets the Modbus-TCP server hostname or IP address.</summary>
|
||||
[CommandOption("host", 'h', Description = "Modbus-TCP server hostname or IP", IsRequired = true)]
|
||||
public string Host { get; init; } = default!;
|
||||
|
||||
/// <summary>Gets the Modbus-TCP port number.</summary>
|
||||
[CommandOption("port", 'p', Description = "Modbus-TCP port (default 502)")]
|
||||
public int Port { get; init; } = 502;
|
||||
|
||||
/// <summary>Gets the Modbus unit ID (slave ID).</summary>
|
||||
[CommandOption("unit-id", 'U', Description = "Modbus unit / slave ID (1-247, default 1)")]
|
||||
public byte UnitId { get; init; } = 1;
|
||||
|
||||
/// <summary>Gets the per-PDU timeout in milliseconds.</summary>
|
||||
[CommandOption("timeout-ms", Description = "Per-PDU timeout in milliseconds (default 2000)")]
|
||||
public int TimeoutMs { get; init; } = 2000;
|
||||
|
||||
/// <summary>Gets a value indicating whether to disable automatic reconnection.</summary>
|
||||
[CommandOption("disable-reconnect", Description =
|
||||
"Disable the built-in mid-transaction reconnect-and-retry. Matches the driver's " +
|
||||
"AutoReconnect=false setting — use when diagnosing socket teardown behaviour.")]
|
||||
@@ -42,6 +47,7 @@ public abstract class ModbusCommandBase : DriverCommandBase
|
||||
/// disabled — CLI runs are one-shot, the probe loop would race the operator's
|
||||
/// command against its own keep-alive reads.
|
||||
/// </summary>
|
||||
/// <param name="tags">The tag definitions to include in the options.</param>
|
||||
protected ModbusDriverOptions BuildOptions(IReadOnlyList<ModbusTagDefinition> tags) => new()
|
||||
{
|
||||
Host = Host,
|
||||
|
||||
@@ -16,11 +16,14 @@ public sealed class ProbeCommand : S7CommandBase
|
||||
[CommandOption("address", 'a', Description =
|
||||
"Probe address (default MW0 — merker word 0). DB1.DBW0 if your PLC project " +
|
||||
"reserves a fingerprint DB.")]
|
||||
/// <summary>Gets or sets the S7 address to probe.</summary>
|
||||
public string Address { get; init; } = "MW0";
|
||||
|
||||
[CommandOption("type", Description = "Probe data type (default Int16).")]
|
||||
/// <summary>Gets or sets the data type of the probe address.</summary>
|
||||
public S7DataType DataType { get; init; } = S7DataType.Int16;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
|
||||
@@ -12,6 +12,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Commands;
|
||||
[Command("read", Description = "Read a single S7 address.")]
|
||||
public sealed class ReadCommand : S7CommandBase
|
||||
{
|
||||
/// <summary>Gets the S7 address to read.</summary>
|
||||
[CommandOption("address", 'a', Description =
|
||||
"S7 address. Examples: DB1.DBW0 (DB1, word 0); M0.0 (merker bit); IW4 (input word 4); " +
|
||||
"QD8 (output dword 8); DB2.DBD20 (DB2, dword 20); DB5.DBX4.3 (DB5, byte 4, bit 3); " +
|
||||
@@ -22,15 +23,18 @@ public sealed class ReadCommand : S7CommandBase
|
||||
// Driver.S7.Cli-002: help text trimmed to the types the driver actually implements.
|
||||
// Int64 / UInt64 / Float64 / String / DateTime are defined in S7DataType but the driver
|
||||
// raises NotSupportedException (→ BadNotSupported) on reads of those types.
|
||||
/// <summary>Gets the data type to interpret the address as.</summary>
|
||||
[CommandOption("type", 't', Description =
|
||||
"Bool / Byte / Int16 / UInt16 / Int32 / UInt32 / Float32 (default Int16). " +
|
||||
"Int64, UInt64, Float64, String, and DateTime are not yet implemented and will return BadNotSupported.")]
|
||||
public S7DataType DataType { get; init; } = S7DataType.Int16;
|
||||
|
||||
/// <summary>Gets the maximum string length for string-type reads.</summary>
|
||||
[CommandOption("string-length", Description =
|
||||
"For type=String: S7-string max length (default 254, S7 max).")]
|
||||
public int StringLength { get; init; } = 254;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
@@ -55,6 +59,8 @@ 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>
|
||||
internal static string SynthesiseTagName(string address, S7DataType type)
|
||||
=> $"{address}:{type}";
|
||||
}
|
||||
|
||||
@@ -12,18 +12,22 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Commands;
|
||||
[Command("subscribe", Description = "Watch an S7 address via polled subscription until Ctrl+C.")]
|
||||
public sealed class SubscribeCommand : S7CommandBase
|
||||
{
|
||||
/// <summary>Gets the S7 address to subscribe to.</summary>
|
||||
[CommandOption("address", 'a', Description = "S7 address — same format as `read`.", IsRequired = true)]
|
||||
public string Address { get; init; } = default!;
|
||||
|
||||
/// <summary>Gets the data type of the address.</summary>
|
||||
// Driver.S7.Cli-002: help text trimmed to the types the driver actually implements.
|
||||
[CommandOption("type", 't', Description =
|
||||
"Bool / Byte / Int16 / UInt16 / Int32 / UInt32 / Float32 (default Int16). " +
|
||||
"Int64, UInt64, Float64, String, and DateTime are not yet implemented and will return BadNotSupported.")]
|
||||
public S7DataType DataType { get; init; } = S7DataType.Int16;
|
||||
|
||||
/// <summary>Gets the polling interval in milliseconds.</summary>
|
||||
[CommandOption("interval-ms", 'i', Description = "Publishing interval ms (default 1000).")]
|
||||
public int IntervalMs { get; init; } = 1000;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
|
||||
@@ -14,10 +14,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Commands;
|
||||
[Command("write", Description = "Write a single S7 address.")]
|
||||
public sealed class WriteCommand : S7CommandBase
|
||||
{
|
||||
/// <summary>Gets or sets the S7 address to write to.</summary>
|
||||
[CommandOption("address", 'a', Description =
|
||||
"S7 address — same format as `read`.", IsRequired = true)]
|
||||
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 trimmed to the types the driver actually implements.
|
||||
// Int64 / UInt64 / Float64 / String / DateTime are defined in S7DataType but the driver
|
||||
// raises NotSupportedException (→ BadNotSupported) on any read/write of those types;
|
||||
@@ -27,15 +29,18 @@ public sealed class WriteCommand : S7CommandBase
|
||||
"Int64, UInt64, Float64, String, and DateTime are not yet implemented and will return BadNotSupported.")]
|
||||
public S7DataType DataType { get; init; } = S7DataType.Int16;
|
||||
|
||||
/// <summary>Gets or sets the value to write.</summary>
|
||||
[CommandOption("value", 'v', Description =
|
||||
"Value to write. Parsed per --type (booleans accept true/false/1/0).",
|
||||
IsRequired = true)]
|
||||
public string Value { get; init; } = default!;
|
||||
|
||||
/// <summary>Gets or sets the maximum length for string values.</summary>
|
||||
[CommandOption("string-length", Description =
|
||||
"For type=String: S7-string max length (default 254).")]
|
||||
public int StringLength { get; init; } = 254;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
@@ -68,6 +73,9 @@ public sealed class WriteCommand : S7CommandBase
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
/// <param name="raw">The raw string value to parse.</param>
|
||||
/// <param name="type">The target data type for parsing.</param>
|
||||
/// <returns>The parsed value as an object.</returns>
|
||||
internal static object ParseValue(string raw, S7DataType type)
|
||||
{
|
||||
if (type == S7DataType.Bool) return ParseBool(raw);
|
||||
|
||||
@@ -12,24 +12,30 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Cli;
|
||||
/// </summary>
|
||||
public abstract class S7CommandBase : DriverCommandBase
|
||||
{
|
||||
/// <summary>Gets the PLC IP address or hostname.</summary>
|
||||
[CommandOption("host", 'h', Description = "PLC IP address or hostname.", IsRequired = true)]
|
||||
public string Host { get; init; } = default!;
|
||||
|
||||
/// <summary>Gets the ISO-on-TCP port.</summary>
|
||||
[CommandOption("port", 'p', Description = "ISO-on-TCP port (default 102).")]
|
||||
public int Port { get; init; } = 102;
|
||||
|
||||
/// <summary>Gets the S7 CPU family type.</summary>
|
||||
[CommandOption("cpu", 'c', Description =
|
||||
"S7 CPU family: S7200 / S7200Smart / S7300 / S7400 / S71200 / S71500 " +
|
||||
"(default S71500). Determines the ISO-TSAP slot byte.")]
|
||||
public S7NetCpuType CpuType { get; init; } = S7NetCpuType.S71500;
|
||||
|
||||
/// <summary>Gets the rack number.</summary>
|
||||
[CommandOption("rack", Description = "Rack number (default 0 — single-rack).")]
|
||||
public short Rack { get; init; } = 0;
|
||||
|
||||
/// <summary>Gets the CPU slot number.</summary>
|
||||
[CommandOption("slot", Description =
|
||||
"CPU slot. S7-300 = 2, S7-400 = 2 or 3, S7-1200 / S7-1500 = 0 (default 0).")]
|
||||
public short Slot { get; init; } = 0;
|
||||
|
||||
/// <summary>Gets the per-operation timeout in milliseconds.</summary>
|
||||
[CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 5000).")]
|
||||
public int TimeoutMs { get; init; } = 5000;
|
||||
|
||||
@@ -45,6 +51,7 @@ public abstract class S7CommandBase : DriverCommandBase
|
||||
/// collected + whatever <paramref name="tags"/> the subclass declares. Probe
|
||||
/// disabled — CLI runs are one-shot.
|
||||
/// </summary>
|
||||
/// <param name="tags">The tag definitions to include in the options.</param>
|
||||
protected S7DriverOptions BuildOptions(IReadOnlyList<S7TagDefinition> tags) => new()
|
||||
{
|
||||
Host = Host,
|
||||
@@ -57,5 +64,6 @@ public abstract class S7CommandBase : DriverCommandBase
|
||||
Probe = new S7ProbeOptions { Enabled = false },
|
||||
};
|
||||
|
||||
/// <summary>Gets the driver instance ID for this CLI session.</summary>
|
||||
protected string DriverInstanceId => $"s7-cli-{Host}:{Port}";
|
||||
}
|
||||
|
||||
@@ -19,16 +19,19 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Commands;
|
||||
[Command("browse", Description = "Enumerate controller symbols via the driver's DiscoverAsync walk.")]
|
||||
public sealed class BrowseCommand : TwinCATCommandBase
|
||||
{
|
||||
/// <summary>Gets or sets the case-sensitive instance-path prefix to filter on.</summary>
|
||||
[CommandOption("prefix", Description =
|
||||
"Case-sensitive instance-path prefix to filter on (e.g. 'GVL_Fixture' or " +
|
||||
"'MAIN.'). Empty (default) prints everything.")]
|
||||
public string? Prefix { get; init; }
|
||||
|
||||
/// <summary>Gets or sets the maximum number of symbols to print.</summary>
|
||||
[CommandOption("max", Description =
|
||||
"Maximum number of symbols to print. 0 = unbounded (default 500 for large " +
|
||||
"controllers — flat-mode symbol counts easily top 10k).")]
|
||||
public int Max { get; init; } = 500;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
Validate();
|
||||
@@ -85,6 +88,8 @@ public sealed class BrowseCommand : TwinCATCommandBase
|
||||
/// <see cref="StringComparison.Ordinal"/> — TwinCAT identifiers are case-sensitive on
|
||||
/// the wire, so a relaxed match would be misleading.
|
||||
/// </summary>
|
||||
/// <param name="source">The source collection to filter.</param>
|
||||
/// <param name="prefix">The prefix to filter on, or null to keep everything.</param>
|
||||
internal static List<(string BrowseName, DriverAttributeInfo Info)> FilterByPrefix(
|
||||
IReadOnlyList<(string BrowseName, DriverAttributeInfo Info)> source, string? prefix)
|
||||
=> source
|
||||
@@ -95,6 +100,8 @@ public sealed class BrowseCommand : TwinCATCommandBase
|
||||
/// Cap-to-max projection. <paramref name="max"/> <= 0 means unbounded, otherwise the
|
||||
/// min of <paramref name="matchedCount"/> and <paramref name="max"/>.
|
||||
/// </summary>
|
||||
/// <param name="matchedCount">The number of matched items.</param>
|
||||
/// <param name="max">The maximum number to show, or 0 for unbounded.</param>
|
||||
internal static int PrintLimit(int matchedCount, int max)
|
||||
=> max <= 0 ? matchedCount : Math.Min(max, matchedCount);
|
||||
|
||||
@@ -104,31 +111,42 @@ public sealed class BrowseCommand : TwinCATCommandBase
|
||||
/// written from at least one ACL tier, so the CLI labels it RW. The real per-tier
|
||||
/// authorization is enforced server-side.
|
||||
/// </summary>
|
||||
/// <param name="info">The attribute info to label.</param>
|
||||
internal static string AccessTag(DriverAttributeInfo info)
|
||||
=> info.SecurityClass == SecurityClassification.ViewOnly ? "RO" : "RW";
|
||||
|
||||
/// <summary>An address space builder that collects variables for enumeration.</summary>
|
||||
internal sealed class CollectingAddressSpaceBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
/// <summary>Gets the collected variables.</summary>
|
||||
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = [];
|
||||
|
||||
/// <inheritdoc />
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
|
||||
{
|
||||
Variables.Add((browseName, info));
|
||||
return new Handle(info.FullName);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void AddProperty(string name, DriverDataType type, object? value) { }
|
||||
|
||||
/// <summary>A variable handle that stores the full reference.</summary>
|
||||
private sealed class Handle(string fullRef) : IVariableHandle
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string FullReference => fullRef;
|
||||
/// <inheritdoc />
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
|
||||
}
|
||||
|
||||
/// <summary>A null sink that ignores alarm condition transitions.</summary>
|
||||
private sealed class NullSink : IAlarmConditionSink
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public void OnTransition(AlarmEventArgs args) { }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Commands;
|
||||
[Command("probe", Description = "Verify the TwinCAT runtime is reachable and a sample symbol reads.")]
|
||||
public sealed class ProbeCommand : TwinCATTagCommandBase
|
||||
{
|
||||
/// <summary>Gets the symbol path to probe in the TwinCAT runtime.</summary>
|
||||
[CommandOption("symbol", 's', Description =
|
||||
"Symbol path to probe. System-global examples: " +
|
||||
"'TwinCAT_SystemInfoVarList._AppInfo.OnlineChangeCnt', 'MAIN.bRunning'. " +
|
||||
@@ -20,11 +21,13 @@ public sealed class ProbeCommand : TwinCATTagCommandBase
|
||||
IsRequired = true)]
|
||||
public string SymbolPath { get; init; } = default!;
|
||||
|
||||
/// <summary>Gets the data type to use for reading the symbol.</summary>
|
||||
[CommandOption("type", 't', Description =
|
||||
"Bool / SInt / USInt / Int / UInt / DInt / UDInt / LInt / ULInt / Real / LReal / " +
|
||||
"String / WString / Time / Date / DateTime / TimeOfDay (default DInt).")]
|
||||
public TwinCATDataType DataType { get; init; } = TwinCATDataType.DInt;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
Validate();
|
||||
|
||||
@@ -11,17 +11,20 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Commands;
|
||||
[Command("read", Description = "Read a single TwinCAT symbol.")]
|
||||
public sealed class ReadCommand : TwinCATTagCommandBase
|
||||
{
|
||||
/// <summary>Gets or sets the TwinCAT symbol path to read.</summary>
|
||||
[CommandOption("symbol", 's', Description =
|
||||
"Symbol path. Program scope: 'MAIN.bStart'. Global: 'GVL.Counter'. " +
|
||||
"Nested UDT member: 'Motor1.Status.Running'. Array element: 'Recipe[3]'.",
|
||||
IsRequired = true)]
|
||||
public string SymbolPath { get; init; } = default!;
|
||||
|
||||
/// <summary>Gets or sets the data type of the symbol being read.</summary>
|
||||
[CommandOption("type", 't', Description =
|
||||
"Bool / SInt / USInt / Int / UInt / DInt / UDInt / LInt / ULInt / Real / LReal / " +
|
||||
"String / WString / Time / Date / DateTime / TimeOfDay (default DInt).")]
|
||||
public TwinCATDataType DataType { get; init; } = TwinCATDataType.DInt;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
Validate();
|
||||
@@ -50,6 +53,10 @@ public sealed class ReadCommand : TwinCATTagCommandBase
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Synthesizes an internal tag name from a symbol path and data type.</summary>
|
||||
/// <param name="symbolPath">The TwinCAT symbol path.</param>
|
||||
/// <param name="type">The data type of the symbol.</param>
|
||||
/// <returns>A synthesized tag name combining the symbol path and type.</returns>
|
||||
internal static string SynthesiseTagName(string symbolPath, TwinCATDataType type)
|
||||
=> $"{symbolPath}:{type}";
|
||||
}
|
||||
|
||||
@@ -13,17 +13,21 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Commands;
|
||||
[Command("subscribe", Description = "Watch a TwinCAT symbol via ADS notification or poll, until Ctrl+C.")]
|
||||
public sealed class SubscribeCommand : TwinCATTagCommandBase
|
||||
{
|
||||
/// <summary>Gets the TwinCAT symbol path to subscribe to.</summary>
|
||||
[CommandOption("symbol", 's', Description = "Symbol path — same format as `read`.", IsRequired = true)]
|
||||
public string SymbolPath { get; init; } = default!;
|
||||
|
||||
/// <summary>Gets the TwinCAT data type of the symbol.</summary>
|
||||
[CommandOption("type", 't', Description =
|
||||
"Bool / SInt / USInt / Int / UInt / DInt / UDInt / LInt / ULInt / Real / LReal / " +
|
||||
"String / WString / Time / Date / DateTime / TimeOfDay (default DInt).")]
|
||||
public TwinCATDataType DataType { get; init; } = TwinCATDataType.DInt;
|
||||
|
||||
/// <summary>Gets the publishing interval in milliseconds.</summary>
|
||||
[CommandOption("interval-ms", 'i', Description = "Publishing interval ms (default 1000).")]
|
||||
public int IntervalMs { get; init; } = 1000;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Validate()
|
||||
{
|
||||
base.Validate();
|
||||
@@ -32,6 +36,7 @@ public sealed class SubscribeCommand : TwinCATTagCommandBase
|
||||
$"--interval-ms must be greater than 0 (got {IntervalMs}).");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
Validate();
|
||||
@@ -113,6 +118,8 @@ public sealed class SubscribeCommand : TwinCATTagCommandBase
|
||||
/// different format — anything else means we landed on the poll loop. Internal so the
|
||||
/// test assembly can cover the mapping without spinning a real driver.
|
||||
/// </summary>
|
||||
/// <param name="handle">The subscription handle to describe.</param>
|
||||
/// <returns>A description of the subscription mechanism ("ADS notification" or "polling").</returns>
|
||||
internal static string DescribeMechanism(ISubscriptionHandle handle) =>
|
||||
handle.DiagnosticId.StartsWith("twincat-native-sub-", StringComparison.Ordinal)
|
||||
? "ADS notification"
|
||||
|
||||
@@ -13,20 +13,24 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Commands;
|
||||
[Command("write", Description = "Write a single TwinCAT symbol.")]
|
||||
public sealed class WriteCommand : TwinCATTagCommandBase
|
||||
{
|
||||
/// <summary>Gets the symbol path to write.</summary>
|
||||
[CommandOption("symbol", 's', Description =
|
||||
"Symbol path — same format as `read`.", IsRequired = true)]
|
||||
public string SymbolPath { get; init; } = default!;
|
||||
|
||||
/// <summary>Gets the data type of the symbol value.</summary>
|
||||
[CommandOption("type", 't', Description =
|
||||
"Bool / SInt / USInt / Int / UInt / DInt / UDInt / LInt / ULInt / Real / LReal / " +
|
||||
"String / WString / Time / Date / DateTime / TimeOfDay (default DInt).")]
|
||||
public TwinCATDataType DataType { get; init; } = TwinCATDataType.DInt;
|
||||
|
||||
/// <summary>Gets the value to write.</summary>
|
||||
[CommandOption("value", 'v', Description =
|
||||
"Value to write. Parsed per --type (booleans accept true/false/1/0).",
|
||||
IsRequired = true)]
|
||||
public string Value { get; init; } = default!;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
Validate();
|
||||
@@ -63,6 +67,8 @@ public sealed class WriteCommand : TwinCATTagCommandBase
|
||||
}
|
||||
|
||||
/// <summary>Parse <c>--value</c> per <see cref="TwinCATDataType"/>, invariant culture.</summary>
|
||||
/// <param name="raw">The raw string value to parse.</param>
|
||||
/// <param name="type">The target TwinCAT data type.</param>
|
||||
internal static object ParseValue(string raw, TwinCATDataType type) => type switch
|
||||
{
|
||||
TwinCATDataType.Bool => ParseBool(raw),
|
||||
|
||||
@@ -13,27 +13,31 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli;
|
||||
/// </summary>
|
||||
public abstract class TwinCATCommandBase : DriverCommandBase
|
||||
{
|
||||
/// <summary>Gets the AMS Net ID of the target runtime.</summary>
|
||||
[CommandOption("ams-net-id", 'n', Description =
|
||||
"AMS Net ID of the target runtime (e.g. '192.168.1.40.1.1' or '127.0.0.1.1.1' for local).",
|
||||
IsRequired = true)]
|
||||
public string AmsNetId { get; init; } = default!;
|
||||
|
||||
/// <summary>Gets the AMS port number.</summary>
|
||||
[CommandOption("ams-port", 'p', Description =
|
||||
"AMS port. TwinCAT 3 PLC runtime defaults to 851; TwinCAT 2 uses 801.")]
|
||||
public int AmsPort { get; init; } = 851;
|
||||
|
||||
/// <summary>Gets the per-operation timeout in milliseconds.</summary>
|
||||
[CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 5000).")]
|
||||
public int TimeoutMs { get; init; } = 5000;
|
||||
|
||||
/// <summary>
|
||||
/// The per-operation timeout, projected from <see cref="TimeoutMs"/>. The CliFx
|
||||
/// 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 <see cref="Timeout"/> directly is silently
|
||||
/// `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 />
|
||||
public override TimeSpan Timeout
|
||||
{
|
||||
get => TimeSpan.FromMilliseconds(TimeoutMs);
|
||||
@@ -41,11 +45,12 @@ public abstract class TwinCATCommandBase : DriverCommandBase
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Canonical TwinCAT gateway string the driver's <c>TwinCATAmsAddress.TryParse</c>
|
||||
/// Gets the canonical TwinCAT gateway string the driver's <c>TwinCATAmsAddress.TryParse</c>
|
||||
/// consumes — shape <c>ads://{AmsNetId}:{AmsPort}</c>.
|
||||
/// </summary>
|
||||
protected string Gateway => $"ads://{AmsNetId}:{AmsPort}";
|
||||
|
||||
/// <summary>Gets the driver instance ID for this command.</summary>
|
||||
protected string DriverInstanceId => $"twincat-cli-{AmsNetId}:{AmsPort}";
|
||||
|
||||
/// <summary>
|
||||
@@ -68,7 +73,12 @@ public abstract class TwinCATCommandBase : DriverCommandBase
|
||||
// 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).
|
||||
/// <summary>Gets the gateway string for testing.</summary>
|
||||
internal string GatewayForTest => Gateway;
|
||||
|
||||
/// <summary>Gets the driver instance ID for testing.</summary>
|
||||
internal string DriverInstanceIdForTest => DriverInstanceId;
|
||||
|
||||
/// <summary>Validates the command for testing.</summary>
|
||||
internal void ValidateForTest() => Validate();
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli;
|
||||
/// </summary>
|
||||
public abstract class TwinCATTagCommandBase : TwinCATCommandBase
|
||||
{
|
||||
/// <summary>Gets or sets a value indicating whether to use polling instead of native ADS notifications.</summary>
|
||||
[CommandOption("poll-only", Description =
|
||||
"Disable native ADS notifications and fall through to the shared PollGroupEngine " +
|
||||
"(same as setting UseNativeNotifications=false in a real driver config).")]
|
||||
@@ -22,6 +23,7 @@ public abstract class TwinCATTagCommandBase : TwinCATCommandBase
|
||||
/// the tag list a subclass supplies. Probe disabled, controller-browse disabled,
|
||||
/// native notifications toggled by <see cref="PollOnly"/>.
|
||||
/// </summary>
|
||||
/// <param name="tags">Tag definitions for the driver.</param>
|
||||
protected TwinCATDriverOptions BuildOptions(IReadOnlyList<TwinCATTagDefinition> tags) => new()
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions(
|
||||
@@ -35,6 +37,8 @@ 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>
|
||||
internal TwinCATDriverOptions BuildOptionsForTest(IReadOnlyList<TwinCATTagDefinition> tags)
|
||||
=> BuildOptions(tags);
|
||||
}
|
||||
|
||||
@@ -39,6 +39,10 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable
|
||||
private readonly Lock _subsLock = new();
|
||||
private long _nextId;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="AbCipAlarmProjection"/> class.</summary>
|
||||
/// <param name="driver">The AB CIP driver instance.</param>
|
||||
/// <param name="pollInterval">The interval at which to poll for alarm state changes.</param>
|
||||
/// <param name="logger">Optional logger instance.</param>
|
||||
public AbCipAlarmProjection(AbCipDriver driver, TimeSpan pollInterval, ILogger? logger = null)
|
||||
{
|
||||
_driver = driver;
|
||||
@@ -46,6 +50,10 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable
|
||||
_logger = logger ?? NullLogger.Instance;
|
||||
}
|
||||
|
||||
/// <summary>Subscribes to alarm events for the specified source nodes.</summary>
|
||||
/// <param name="sourceNodeIds">The node identifiers to monitor for alarm state changes.</param>
|
||||
/// <param name="cancellationToken">A cancellation token to stop the operation.</param>
|
||||
/// <returns>A subscription handle for managing the subscription.</returns>
|
||||
public async Task<IAlarmSubscriptionHandle> SubscribeAsync(
|
||||
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -61,6 +69,10 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable
|
||||
return handle;
|
||||
}
|
||||
|
||||
/// <summary>Unsubscribes from alarm events using the provided subscription handle.</summary>
|
||||
/// <param name="handle">The subscription handle obtained from <see cref="SubscribeAsync"/>.</param>
|
||||
/// <param name="cancellationToken">A cancellation token to stop the operation.</param>
|
||||
/// <returns>A task representing the asynchronous unsubscribe operation.</returns>
|
||||
public async Task UnsubscribeAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken)
|
||||
{
|
||||
if (handle is not AbCipAlarmSubscriptionHandle h) return;
|
||||
@@ -74,6 +86,10 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable
|
||||
sub.Cts.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>Acknowledges one or more active alarms.</summary>
|
||||
/// <param name="acknowledgements">The list of acknowledgement requests specifying which alarms to acknowledge.</param>
|
||||
/// <param name="cancellationToken">A cancellation token to stop the operation.</param>
|
||||
/// <returns>A task representing the asynchronous acknowledgement operation.</returns>
|
||||
public async Task AcknowledgeAsync(
|
||||
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -91,6 +107,8 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable
|
||||
_ = await _driver.WriteAsync(requests, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Releases all resources associated with this alarm projection.</summary>
|
||||
/// <returns>A task representing the asynchronous disposal operation.</returns>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
List<Subscription> snap;
|
||||
@@ -108,6 +126,8 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable
|
||||
/// in the subscription, diffs each against last-seen state, fires raise/clear events.
|
||||
/// Extracted so tests can drive one tick without standing up the Task.Run loop.
|
||||
/// </summary>
|
||||
/// <param name="sub">The subscription to process.</param>
|
||||
/// <param name="results">The data values read from the subscription source nodes.</param>
|
||||
internal void Tick(Subscription sub, IReadOnlyList<DataValueSnapshot> results)
|
||||
{
|
||||
// results index layout: for each sourceNode, [InFaulted, Severity] in order.
|
||||
@@ -176,6 +196,9 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Maps a raw severity value to an <see cref="AlarmSeverity"/> enum value.</summary>
|
||||
/// <param name="raw">The raw severity value from the alarm data.</param>
|
||||
/// <returns>The corresponding alarm severity level.</returns>
|
||||
internal static AlarmSeverity MapSeverity(int raw) => raw switch
|
||||
{
|
||||
<= 250 => AlarmSeverity.Low,
|
||||
@@ -203,14 +226,28 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable
|
||||
|
||||
internal sealed class Subscription
|
||||
{
|
||||
/// <summary>Initializes a new instance of the <see cref="Subscription"/> class.</summary>
|
||||
/// <param name="handle">The subscription handle.</param>
|
||||
/// <param name="sourceNodeIds">The source node identifiers to monitor.</param>
|
||||
/// <param name="cts">The cancellation token source for stopping the subscription.</param>
|
||||
public Subscription(AbCipAlarmSubscriptionHandle handle, IReadOnlyList<string> sourceNodeIds, CancellationTokenSource cts)
|
||||
{
|
||||
Handle = handle; SourceNodeIds = sourceNodeIds; Cts = cts;
|
||||
}
|
||||
|
||||
/// <summary>Gets the subscription handle.</summary>
|
||||
public AbCipAlarmSubscriptionHandle Handle { get; }
|
||||
|
||||
/// <summary>Gets the source node identifiers being monitored.</summary>
|
||||
public IReadOnlyList<string> SourceNodeIds { get; }
|
||||
|
||||
/// <summary>Gets the cancellation token source for this subscription.</summary>
|
||||
public CancellationTokenSource Cts { get; }
|
||||
|
||||
/// <summary>Gets or sets the polling loop task.</summary>
|
||||
public Task Loop { get; set; } = Task.CompletedTask;
|
||||
|
||||
/// <summary>Gets the dictionary tracking the last known InFaulted state for each node.</summary>
|
||||
public Dictionary<string, bool> LastInFaulted { get; } = new(StringComparer.Ordinal);
|
||||
}
|
||||
}
|
||||
@@ -218,6 +255,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>
|
||||
public string DiagnosticId => $"abcip-alarm-sub-{Id}";
|
||||
}
|
||||
|
||||
@@ -234,6 +272,8 @@ public static class AbCipAlarmDetector
|
||||
/// (analog alarms with <c>HHLimit</c>/<c>HLimit</c>/<c>LLimit</c>/<c>LLLimit</c>)
|
||||
/// ships as a follow-up.
|
||||
/// </summary>
|
||||
/// <param name="tag">The tag definition to check for ALMD signature.</param>
|
||||
/// <returns>True if the tag has the ALMD alarm signature; false otherwise.</returns>
|
||||
public static bool IsAlmd(AbCipTagDefinition tag)
|
||||
{
|
||||
if (tag.DataType != AbCipDataType.Structure || tag.Members is null) return false;
|
||||
|
||||
@@ -51,6 +51,8 @@ public static class AbCipDataTypeExtensions
|
||||
/// <item>USInt / UInt widen into Int32; they can never overflow it.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
/// <param name="t">The Logix data type to convert.</param>
|
||||
/// <returns>The corresponding driver data type.</returns>
|
||||
public static DriverDataType ToDriverDataType(this AbCipDataType t) => t switch
|
||||
{
|
||||
AbCipDataType.Bool => DriverDataType.Boolean,
|
||||
|
||||
@@ -40,13 +40,26 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
private AbCipAlarmProjection _alarmProjection;
|
||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||
|
||||
/// <summary>Occurs when a subscribed tag's value changes.</summary>
|
||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||
|
||||
/// <summary>Occurs when a device's host connectivity status changes.</summary>
|
||||
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
||||
|
||||
/// <summary>Occurs when an alarm event is raised.</summary>
|
||||
public event EventHandler<AlarmEventArgs>? OnAlarmEvent;
|
||||
|
||||
/// <summary>Internal seam for the alarm projection to raise events through the driver.</summary>
|
||||
/// <param name="args">The alarm event arguments.</param>
|
||||
internal void InvokeAlarmEvent(AlarmEventArgs args) => OnAlarmEvent?.Invoke(this, args);
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="AbCipDriver"/> class.</summary>
|
||||
/// <param name="options">The driver configuration options.</param>
|
||||
/// <param name="driverInstanceId">A unique identifier for this driver instance.</param>
|
||||
/// <param name="tagFactory">Optional factory for creating tag runtimes; uses libplctag default if null.</param>
|
||||
/// <param name="enumeratorFactory">Optional factory for enumerating tags; uses libplctag default if null.</param>
|
||||
/// <param name="templateReaderFactory">Optional factory for reading UDT templates; uses libplctag default if null.</param>
|
||||
/// <param name="logger">Optional logger; uses null logger if not provided.</param>
|
||||
public AbCipDriver(AbCipDriverOptions options, string driverInstanceId,
|
||||
IAbCipTagFactory? tagFactory = null,
|
||||
IAbCipTagEnumeratorFactory? enumeratorFactory = null,
|
||||
@@ -74,6 +87,10 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// additional network traffic. <c>null</c> on template-not-found / decode failure so
|
||||
/// callers can fall back to declaration-driven UDT fan-out.
|
||||
/// </summary>
|
||||
/// <param name="deviceHostAddress">The host address of the device to read the template from.</param>
|
||||
/// <param name="templateInstanceId">The instance ID of the UDT template to fetch.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>The UDT shape if found and decoded successfully; null otherwise.</returns>
|
||||
internal async Task<AbCipUdtShape?> FetchUdtShapeAsync(
|
||||
string deviceHostAddress, uint templateInstanceId, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -113,7 +130,10 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// </summary>
|
||||
internal AbCipTemplateCache TemplateCache => _templateCache;
|
||||
|
||||
/// <summary>Gets the unique identifier for this driver instance.</summary>
|
||||
public string DriverInstanceId => _driverInstanceId;
|
||||
|
||||
/// <summary>Gets the driver type identifier.</summary>
|
||||
public string DriverType => "AbCip";
|
||||
|
||||
/// <summary>
|
||||
@@ -127,6 +147,9 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// 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>
|
||||
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Initializing, null, null);
|
||||
@@ -221,6 +244,10 @@ 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>
|
||||
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
await ShutdownAsync(cancellationToken).ConfigureAwait(false);
|
||||
@@ -235,6 +262,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// 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>
|
||||
public async Task ShutdownAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _alarmProjection.DisposeAsync().ConfigureAwait(false);
|
||||
@@ -276,10 +305,19 @@ 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>
|
||||
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>
|
||||
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
|
||||
{
|
||||
_poll.Unsubscribe(handle);
|
||||
@@ -297,6 +335,9 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// <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>
|
||||
public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
|
||||
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -308,11 +349,19 @@ 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>
|
||||
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>
|
||||
public Task AcknowledgeAsync(
|
||||
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken) =>
|
||||
_options.EnableAlarmProjection
|
||||
@@ -321,6 +370,8 @@ 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>
|
||||
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses() =>
|
||||
[.. _devices.Values.Select(s => new HostConnectivityStatus(s.Options.HostAddress, s.HostState, s.HostStateChangedUtc))];
|
||||
|
||||
@@ -395,6 +446,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// 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>
|
||||
public string ResolveHost(string fullReference)
|
||||
{
|
||||
if (_tagsByName.TryGetValue(fullReference, out var def))
|
||||
@@ -411,6 +464,9 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// <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>
|
||||
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -584,6 +640,9 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// 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>
|
||||
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
|
||||
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -814,6 +873,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Gets the current health status of the driver.</summary>
|
||||
/// <returns>The driver health information.</returns>
|
||||
public DriverHealth GetHealth() => _health;
|
||||
|
||||
/// <summary>
|
||||
@@ -821,8 +882,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// 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>
|
||||
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>
|
||||
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_templateCache.Clear();
|
||||
@@ -838,6 +903,9 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// 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>
|
||||
public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
@@ -934,11 +1002,16 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
internal int DeviceCount => _devices.Count;
|
||||
|
||||
/// <summary>Looked-up device state for the given host address. Tests + later-PR capabilities hit this.</summary>
|
||||
/// <param name="hostAddress">The host address of the device to look up.</param>
|
||||
/// <returns>The device state if found; null otherwise.</returns>
|
||||
internal DeviceState? GetDeviceState(string hostAddress) =>
|
||||
_devices.TryGetValue(hostAddress, out var s) ? s : null;
|
||||
|
||||
/// <summary>Releases all resources used by the driver.</summary>
|
||||
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
|
||||
/// <summary>Asynchronously releases all resources used by the driver.</summary>
|
||||
/// <returns>A value task representing the asynchronous disposal.</returns>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
@@ -956,14 +1029,22 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
AbCipDeviceOptions options,
|
||||
AbCipPlcFamilyProfile profile)
|
||||
{
|
||||
/// <summary>Gets the parsed host address for this device.</summary>
|
||||
public AbCipHostAddress ParsedAddress { get; } = parsedAddress;
|
||||
/// <summary>Gets the configuration options for this device.</summary>
|
||||
public AbCipDeviceOptions Options { get; } = options;
|
||||
/// <summary>Gets the PLC family profile for this device.</summary>
|
||||
public AbCipPlcFamilyProfile Profile { get; } = profile;
|
||||
|
||||
/// <summary>Gets the lock object used for probe synchronization.</summary>
|
||||
public object ProbeLock { get; } = new();
|
||||
/// <summary>Gets or sets the current host state of this device.</summary>
|
||||
public HostState HostState { get; set; } = HostState.Unknown;
|
||||
/// <summary>Gets or sets the UTC timestamp when the host state was last changed.</summary>
|
||||
public DateTime HostStateChangedUtc { get; set; } = DateTime.UtcNow;
|
||||
/// <summary>Gets or sets the cancellation token source for the probe loop.</summary>
|
||||
public CancellationTokenSource? ProbeCts { get; set; }
|
||||
/// <summary>Gets or sets whether the probe has been initialized.</summary>
|
||||
public bool ProbeInitialized { get; set; }
|
||||
|
||||
/// <summary>
|
||||
@@ -996,6 +1077,9 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, SemaphoreSlim> _rmwLocks = new();
|
||||
|
||||
/// <summary>Gets or creates a semaphore for coordinating RMW (read-modify-write) operations on a parent tag.</summary>
|
||||
/// <param name="parentTagName">The name of the parent tag.</param>
|
||||
/// <returns>A semaphore for coordinating RMW operations.</returns>
|
||||
public SemaphoreSlim GetRmwLock(string parentTagName) =>
|
||||
_rmwLocks.GetOrAdd(parentTagName, _ => new SemaphoreSlim(1, 1));
|
||||
|
||||
@@ -1006,6 +1090,9 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// <see cref="AbCipDeviceOptions.ConnectionSize"/>) with the family profile defaults
|
||||
/// so the wire layer sees one place that resolves both.
|
||||
/// </summary>
|
||||
/// <param name="tagName">The name of the tag to create parameters for.</param>
|
||||
/// <param name="timeout">The timeout for tag operations.</param>
|
||||
/// <returns>The computed tag creation parameters.</returns>
|
||||
public AbCipTagCreateParams BuildCreateParams(string tagName, TimeSpan timeout) => new(
|
||||
Gateway: ParsedAddress.Gateway,
|
||||
Port: ParsedAddress.Port,
|
||||
@@ -1016,6 +1103,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
AllowPacking: Options.AllowPacking ?? Profile.SupportsRequestPacking,
|
||||
ConnectionSize: Options.ConnectionSize ?? Profile.DefaultConnectionSize);
|
||||
|
||||
/// <summary>Disposes all runtime tag handles and clears the caches.</summary>
|
||||
public void DisposeHandles()
|
||||
{
|
||||
foreach (var r in Runtimes.Values) r.Dispose();
|
||||
|
||||
@@ -14,12 +14,22 @@ public static class AbCipDriverFactoryExtensions
|
||||
{
|
||||
public const string DriverTypeName = "AbCip";
|
||||
|
||||
/// <summary>
|
||||
/// Registers the AB CIP driver factory with the driver registry.
|
||||
/// </summary>
|
||||
/// <param name="registry">The driver factory registry to register with.</param>
|
||||
public static void Register(DriverFactoryRegistry registry)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
registry.Register(DriverTypeName, CreateInstance);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an instance of the AB CIP driver from configuration.
|
||||
/// </summary>
|
||||
/// <param name="driverInstanceId">The unique identifier for this driver instance.</param>
|
||||
/// <param name="driverConfigJson">The driver configuration as a JSON string.</param>
|
||||
/// <returns>A configured <see cref="AbCipDriver"/> instance.</returns>
|
||||
internal static AbCipDriver CreateInstance(string driverInstanceId, string driverConfigJson)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||
@@ -34,6 +44,9 @@ public static class AbCipDriverFactoryExtensions
|
||||
/// so a reinitialize with a changed config JSON (new device, new tag, changed timeout)
|
||||
/// actually takes effect rather than being silently discarded.
|
||||
/// </summary>
|
||||
/// <param name="driverInstanceId">The unique identifier for this driver instance.</param>
|
||||
/// <param name="driverConfigJson">The driver configuration as a JSON string.</param>
|
||||
/// <returns>Parsed driver options.</returns>
|
||||
internal static AbCipDriverOptions ParseOptions(string driverInstanceId, string driverConfigJson)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||
@@ -120,50 +133,161 @@ public static class AbCipDriverFactoryExtensions
|
||||
|
||||
internal sealed class AbCipDriverConfigDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the timeout in milliseconds for operations.
|
||||
/// </summary>
|
||||
public int? TimeoutMs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether controller browsing is enabled.
|
||||
/// </summary>
|
||||
public bool? EnableControllerBrowse { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether alarm projection is enabled.
|
||||
/// </summary>
|
||||
public bool? EnableAlarmProjection { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether declaration-only UDT grouping is enabled.
|
||||
/// </summary>
|
||||
public bool? EnableDeclarationOnlyUdtGrouping { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the alarm poll interval in milliseconds.
|
||||
/// </summary>
|
||||
public int? AlarmPollIntervalMs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the list of devices to connect to.
|
||||
/// </summary>
|
||||
public List<AbCipDeviceDto>? Devices { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the list of tags to monitor.
|
||||
/// </summary>
|
||||
public List<AbCipTagDto>? Tags { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the probe configuration.
|
||||
/// </summary>
|
||||
public AbCipProbeDto? Probe { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AbCipDeviceDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the host address of the device.
|
||||
/// </summary>
|
||||
public string? HostAddress { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the PLC family.
|
||||
/// </summary>
|
||||
public string? PlcFamily { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the device name.
|
||||
/// </summary>
|
||||
public string? DeviceName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether packing is allowed.
|
||||
/// </summary>
|
||||
public bool? AllowPacking { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the connection size.
|
||||
/// </summary>
|
||||
public int? ConnectionSize { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AbCipTagDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the tag name.
|
||||
/// </summary>
|
||||
public string? Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the device host address.
|
||||
/// </summary>
|
||||
public string? DeviceHostAddress { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the tag path.
|
||||
/// </summary>
|
||||
public string? TagPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the data type.
|
||||
/// </summary>
|
||||
public string? DataType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether the tag is writable.
|
||||
/// </summary>
|
||||
public bool? Writable { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether write is idempotent.
|
||||
/// </summary>
|
||||
public bool? WriteIdempotent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the list of structure members.
|
||||
/// </summary>
|
||||
public List<AbCipMemberDto>? Members { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether this is a safety tag.
|
||||
/// </summary>
|
||||
public bool? SafetyTag { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AbCipMemberDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the member name.
|
||||
/// </summary>
|
||||
public string? Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the data type.
|
||||
/// </summary>
|
||||
public string? DataType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether the member is writable.
|
||||
/// </summary>
|
||||
public bool? Writable { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether write is idempotent.
|
||||
/// </summary>
|
||||
public bool? WriteIdempotent { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AbCipProbeDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets whether probing is enabled.
|
||||
/// </summary>
|
||||
public bool? Enabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the probe interval in milliseconds.
|
||||
/// </summary>
|
||||
public int? IntervalMs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the probe timeout in milliseconds.
|
||||
/// </summary>
|
||||
public int? TimeoutMs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the probe tag path.
|
||||
/// </summary>
|
||||
public string? ProbeTagPath { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,8 +163,11 @@ public enum AbCipPlcFamily
|
||||
/// </summary>
|
||||
public sealed class AbCipProbeOptions
|
||||
{
|
||||
/// <summary>Gets a value indicating whether the probe is enabled.</summary>
|
||||
public bool Enabled { get; init; } = true;
|
||||
/// <summary>Gets the interval at which the probe reads the probe tag.</summary>
|
||||
public TimeSpan Interval { get; init; } = TimeSpan.FromSeconds(5);
|
||||
/// <summary>Gets the timeout for each probe read operation.</summary>
|
||||
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -25,15 +25,17 @@ public sealed record AbCipHostAddress(string Gateway, int Port, string CipPath)
|
||||
/// <summary>Default EtherNet/IP TCP port — spec-reserved.</summary>
|
||||
public const int DefaultEipPort = 44818;
|
||||
|
||||
/// <summary>Recompose the canonical <c>ab://...</c> form.</summary>
|
||||
/// <inheritdoc />
|
||||
public override string ToString() => Port == DefaultEipPort
|
||||
? $"ab://{Gateway}/{CipPath}"
|
||||
: $"ab://{Gateway}:{Port}/{CipPath}";
|
||||
|
||||
/// <summary>
|
||||
/// Parse <paramref name="value"/>. Returns <c>null</c> on any malformed input — callers
|
||||
/// Parses an ab:// host address string. Returns <c>null</c> on any malformed input — callers
|
||||
/// should treat a null return as a config-validation failure rather than catching.
|
||||
/// </summary>
|
||||
/// <param name="value">The ab:// URL string to parse.</param>
|
||||
/// <returns>A parsed host address, or null if the value is invalid.</returns>
|
||||
public static AbCipHostAddress? TryParse(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
|
||||
@@ -47,6 +47,7 @@ public static class AbCipStatusMapper
|
||||
public const uint BadTypeMismatch = 0x80730000u;
|
||||
|
||||
/// <summary>Map a CIP general-status byte to an OPC UA StatusCode.</summary>
|
||||
/// <param name="status">The CIP general-status byte value.</param>
|
||||
public static uint MapCipGeneralStatus(byte status) => status switch
|
||||
{
|
||||
0x00 => Good,
|
||||
@@ -70,6 +71,7 @@ public static class AbCipStatusMapper
|
||||
/// <see cref="Status.Ok"/> is success; <see cref="Status.Pending"/> is an in-flight
|
||||
/// operation; every other (negative) member is an error.
|
||||
/// </summary>
|
||||
/// <param name="status">The libplctag status code as an integer.</param>
|
||||
public static uint MapLibplctagStatus(int status) => MapLibplctagStatus((Status)status);
|
||||
|
||||
/// <summary>
|
||||
@@ -77,6 +79,7 @@ public static class AbCipStatusMapper
|
||||
/// the strongly-typed core of the mapper; the <c>int</c> overload exists only for the
|
||||
/// <see cref="IAbCipTagRuntime.GetStatus"/> seam, which returns the boxed-as-int value.
|
||||
/// </summary>
|
||||
/// <param name="status">The libplctag Status enum value.</param>
|
||||
public static uint MapLibplctagStatus(Status status) => status switch
|
||||
{
|
||||
Status.Ok => Good,
|
||||
|
||||
@@ -18,6 +18,7 @@ public static class AbCipSystemTagFilter
|
||||
/// should hide from the default address space. Case-sensitive — Logix symbols are
|
||||
/// always preserved case and the system-tag prefixes are uppercase by convention.
|
||||
/// </summary>
|
||||
/// <param name="tagName">The tag name to check.</param>
|
||||
public static bool IsSystemTag(string tagName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tagName)) return true;
|
||||
|
||||
@@ -47,6 +47,8 @@ public sealed record AbCipTagPath(
|
||||
/// doesn't support — the driver surfaces that as a config-validation error rather than
|
||||
/// attempting a best-effort translation.
|
||||
/// </summary>
|
||||
/// <param name="value">The tag path string to parse, or null.</param>
|
||||
/// <returns>The parsed AbCipTagPath, or null if the syntax is invalid.</returns>
|
||||
public static AbCipTagPath? TryParse(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
|
||||
@@ -21,10 +21,15 @@ public sealed class AbCipTemplateCache
|
||||
/// <summary>
|
||||
/// Retrieve a cached UDT shape, or <c>null</c> if not yet read.
|
||||
/// </summary>
|
||||
/// <param name="deviceHostAddress">The device host address and port.</param>
|
||||
/// <param name="templateInstanceId">The template instance ID.</param>
|
||||
public AbCipUdtShape? TryGet(string deviceHostAddress, uint templateInstanceId) =>
|
||||
_shapes.TryGetValue((deviceHostAddress, templateInstanceId), out var shape) ? shape : null;
|
||||
|
||||
/// <summary>Store a freshly-decoded UDT shape.</summary>
|
||||
/// <param name="deviceHostAddress">The device host address and port.</param>
|
||||
/// <param name="templateInstanceId">The template instance ID.</param>
|
||||
/// <param name="shape">The UDT shape to cache.</param>
|
||||
public void Put(string deviceHostAddress, uint templateInstanceId, AbCipUdtShape shape) =>
|
||||
_shapes[(deviceHostAddress, templateInstanceId)] = shape;
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ public static class AbCipUdtMemberLayout
|
||||
/// Try to compute member offsets for the supplied declared members. Returns <c>null</c>
|
||||
/// if any member type is unsupported for declaration-only layout.
|
||||
/// </summary>
|
||||
/// <param name="members">The list of UDT member declarations.</param>
|
||||
public static IReadOnlyDictionary<string, int>? TryBuild(
|
||||
IReadOnlyList<AbCipStructureMember> members)
|
||||
{
|
||||
|
||||
@@ -25,6 +25,9 @@ public static class AbCipUdtReadPlanner
|
||||
/// formed — every reference goes to the per-tag fallback path so member decoding never
|
||||
/// relies on declaration-order offsets that may not match the controller layout.
|
||||
/// </summary>
|
||||
/// <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>
|
||||
public static AbCipUdtReadPlan Build(
|
||||
IReadOnlyList<string> requests,
|
||||
IReadOnlyDictionary<string, AbCipTagDefinition> tagsByName,
|
||||
|
||||
@@ -44,6 +44,8 @@ public static class CipSymbolObjectDecoder
|
||||
/// the tail cause decoding to stop gracefully — the caller gets whatever it could parse
|
||||
/// cleanly before the corruption.
|
||||
/// </summary>
|
||||
/// <param name="buffer">The raw byte buffer from the Symbol Object response.</param>
|
||||
/// <returns>An enumerable sequence of discovered CIP tags.</returns>
|
||||
public static IEnumerable<AbCipDiscoveredTag> Decode(byte[] buffer)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(buffer);
|
||||
@@ -91,6 +93,8 @@ public static class CipSymbolObjectDecoder
|
||||
/// Split a <c>Program:MainProgram.StepIndex</c>-style name into its scope + local
|
||||
/// parts. Names without the <c>Program:</c> prefix pass through unchanged.
|
||||
/// </summary>
|
||||
/// <param name="fullName">The full tag name possibly prefixed with a program scope.</param>
|
||||
/// <returns>A tuple containing the program scope (or null) and the simple name.</returns>
|
||||
internal static (string? programScope, string simpleName) SplitProgramScope(string fullName)
|
||||
{
|
||||
const string prefix = "Program:";
|
||||
@@ -107,6 +111,8 @@ public static class CipSymbolObjectDecoder
|
||||
/// caller treats those as <see cref="AbCipDataType.Structure"/> so the symbol is still
|
||||
/// surfaced + downstream config can add a concrete type override.
|
||||
/// </summary>
|
||||
/// <param name="typeCode">The CIP type code to map.</param>
|
||||
/// <returns>The corresponding AbCipDataType, or null if unrecognized.</returns>
|
||||
internal static AbCipDataType? MapTypeCode(byte typeCode) => typeCode switch
|
||||
{
|
||||
0xC1 => AbCipDataType.Bool,
|
||||
|
||||
@@ -46,6 +46,8 @@ public static class CipTemplateObjectDecoder
|
||||
/// Decode the raw Template Object blob. Returns <c>null</c> when the header indicates
|
||||
/// zero members or the buffer is too short to hold the fixed header.
|
||||
/// </summary>
|
||||
/// <param name="buffer">The raw Template Object buffer to decode.</param>
|
||||
/// <returns>An AbCipUdtShape describing the structure, or null if decoding fails.</returns>
|
||||
public static AbCipUdtShape? Decode(byte[] buffer)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(buffer);
|
||||
@@ -105,6 +107,8 @@ public static class CipTemplateObjectDecoder
|
||||
/// the null byte after each semicolon is optional padding per Rockwell's string
|
||||
/// encoding convention. Stops at a trailing null / end of buffer.
|
||||
/// </summary>
|
||||
/// <param name="span">The byte span containing semicolon-terminated strings.</param>
|
||||
/// <returns>List of parsed strings, one per name in the span.</returns>
|
||||
internal static List<string> ParseSemicolonTerminatedStrings(ReadOnlySpan<byte> span)
|
||||
{
|
||||
var result = new List<string>();
|
||||
|
||||
@@ -13,6 +13,8 @@ public interface IAbCipTagEnumerator : IDisposable
|
||||
/// Enumerate the controller's tags for one device. Callers iterate asynchronously so
|
||||
/// large symbol tables don't require buffering the entire list.
|
||||
/// </summary>
|
||||
/// <param name="deviceParams">Parameters for creating device tags.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
IAsyncEnumerable<AbCipDiscoveredTag> EnumerateAsync(
|
||||
AbCipTagCreateParams deviceParams,
|
||||
CancellationToken cancellationToken);
|
||||
@@ -21,6 +23,9 @@ public interface IAbCipTagEnumerator : IDisposable
|
||||
/// <summary>Factory for per-driver enumerators.</summary>
|
||||
public interface IAbCipTagEnumeratorFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new tag enumerator instance.
|
||||
/// </summary>
|
||||
IAbCipTagEnumerator Create();
|
||||
}
|
||||
|
||||
@@ -49,6 +54,11 @@ 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>
|
||||
public async IAsyncEnumerable<AbCipDiscoveredTag> EnumerateAsync(
|
||||
AbCipTagCreateParams deviceParams,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
@@ -57,11 +67,17 @@ internal sealed class EmptyAbCipTagEnumerator : IAbCipTagEnumerator
|
||||
yield break;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases resources (no-op for this implementation).
|
||||
/// </summary>
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
/// <summary>Factory for <see cref="EmptyAbCipTagEnumerator"/>.</summary>
|
||||
internal sealed class EmptyAbCipTagEnumeratorFactory : IAbCipTagEnumeratorFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new empty tag enumerator.
|
||||
/// </summary>
|
||||
public IAbCipTagEnumerator Create() => new EmptyAbCipTagEnumerator();
|
||||
}
|
||||
|
||||
@@ -10,12 +10,15 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
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>
|
||||
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>
|
||||
Task ReadAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Flush the local buffer to the PLC.</summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task WriteAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
@@ -29,6 +32,8 @@ public interface IAbCipTagRuntime : IDisposable
|
||||
/// <paramref name="bitIndex"/> is non-null only for BOOL-within-DINT tags captured in
|
||||
/// the <c>.N</c> syntax at parse time.
|
||||
/// </summary>
|
||||
/// <param name="type">CIP data type to decode.</param>
|
||||
/// <param name="bitIndex">Bit index for BOOL-within-DINT extraction, or null.</param>
|
||||
object? DecodeValue(AbCipDataType type, int? bitIndex);
|
||||
|
||||
/// <summary>
|
||||
@@ -40,12 +45,18 @@ public interface IAbCipTagRuntime : IDisposable
|
||||
/// offsets greater than zero against an unsupporting runtime should return <c>null</c>
|
||||
/// so the planner can skip grouping.
|
||||
/// </summary>
|
||||
/// <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>
|
||||
object? DecodeValueAt(AbCipDataType type, int offset, int? bitIndex);
|
||||
|
||||
/// <summary>
|
||||
/// Encode <paramref name="value"/> into the local buffer per the tag's type. Callers
|
||||
/// pair this with <see cref="WriteAsync"/>.
|
||||
/// </summary>
|
||||
/// <param name="type">CIP data type to encode.</param>
|
||||
/// <param name="bitIndex">Bit index for BOOL-within-DINT insertion, or null.</param>
|
||||
/// <param name="value">Value to encode.</param>
|
||||
void EncodeValue(AbCipDataType type, int? bitIndex, object? value);
|
||||
}
|
||||
|
||||
@@ -55,6 +66,8 @@ public interface IAbCipTagRuntime : IDisposable
|
||||
/// </summary>
|
||||
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>
|
||||
IAbCipTagRuntime Create(AbCipTagCreateParams createParams);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,9 @@ public interface IAbCipTemplateReader : IDisposable
|
||||
/// full blob the Read Template service produced — the managed <see cref="CipTemplateObjectDecoder"/>
|
||||
/// parses it into an <see cref="AbCipUdtShape"/>.
|
||||
/// </summary>
|
||||
/// <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>
|
||||
Task<byte[]> ReadAsync(
|
||||
AbCipTagCreateParams deviceParams,
|
||||
uint templateInstanceId,
|
||||
@@ -22,5 +25,6 @@ public interface IAbCipTemplateReader : IDisposable
|
||||
/// <summary>Factory for <see cref="IAbCipTemplateReader"/>.</summary>
|
||||
public interface IAbCipTemplateReaderFactory
|
||||
{
|
||||
/// <summary>Creates a new template reader instance.</summary>
|
||||
IAbCipTemplateReader Create();
|
||||
}
|
||||
|
||||
@@ -21,6 +21,10 @@ 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>
|
||||
public async IAsyncEnumerable<AbCipDiscoveredTag> EnumerateAsync(
|
||||
AbCipTagCreateParams deviceParams,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
@@ -45,6 +49,7 @@ internal sealed class LibplctagTagEnumerator : IAbCipTagEnumerator
|
||||
yield return tag;
|
||||
}
|
||||
|
||||
/// <summary>Disposes the enumerator and releases the underlying libplctag tag.</summary>
|
||||
public void Dispose() => _tag?.Dispose();
|
||||
|
||||
private static PlcType MapPlcType(string attribute) => attribute switch
|
||||
@@ -59,5 +64,7 @@ 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>
|
||||
public IAbCipTagEnumerator Create() => new LibplctagTagEnumerator();
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
|
||||
{
|
||||
private readonly Tag _tag;
|
||||
|
||||
/// <summary>Initializes a new instance of the LibplctagTagRuntime class.</summary>
|
||||
/// <param name="p">The tag creation parameters.</param>
|
||||
public LibplctagTagRuntime(AbCipTagCreateParams p)
|
||||
{
|
||||
_tag = new Tag
|
||||
@@ -34,14 +36,36 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
|
||||
// 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>
|
||||
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>
|
||||
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>
|
||||
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>
|
||||
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>
|
||||
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>
|
||||
public object? DecodeValueAt(AbCipDataType type, int offset, int? bitIndex) => type switch
|
||||
{
|
||||
AbCipDataType.Bool => bitIndex is int bit
|
||||
@@ -63,6 +87,10 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <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>
|
||||
public void EncodeValue(AbCipDataType type, int? bitIndex, object? value)
|
||||
{
|
||||
switch (type)
|
||||
@@ -126,6 +154,7 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Disposes the tag and releases its resources.</summary>
|
||||
public void Dispose() => _tag.Dispose();
|
||||
|
||||
private static PlcType MapPlcType(string attribute) => attribute switch
|
||||
@@ -145,8 +174,12 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
|
||||
/// Default <see cref="IAbCipTagFactory"/> — creates a fresh <see cref="LibplctagTagRuntime"/>
|
||||
/// per call. Stateless; safe to share across devices.
|
||||
/// </summary>
|
||||
/// <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>
|
||||
public IAbCipTagRuntime Create(AbCipTagCreateParams createParams) =>
|
||||
new LibplctagTagRuntime(createParams);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,11 @@ 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>
|
||||
public async Task<byte[]> ReadAsync(
|
||||
AbCipTagCreateParams deviceParams,
|
||||
uint templateInstanceId,
|
||||
@@ -45,6 +50,7 @@ internal sealed class LibplctagTemplateReader : IAbCipTemplateReader
|
||||
return _tag.GetBuffer();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose() => _tag?.Dispose();
|
||||
|
||||
private static PlcType MapPlcType(string attribute) => attribute switch
|
||||
@@ -58,5 +64,7 @@ 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>
|
||||
public IAbCipTemplateReader Create() => new LibplctagTemplateReader();
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ public sealed record AbCipPlcFamilyProfile(
|
||||
int MaxFragmentBytes)
|
||||
{
|
||||
/// <summary>Look up the profile for a configured family.</summary>
|
||||
/// <param name="family">The PLC family to look up the profile for.</param>
|
||||
public static AbCipPlcFamilyProfile ForFamily(AbCipPlcFamily family) => family switch
|
||||
{
|
||||
AbCipPlcFamily.ControlLogix => ControlLogix,
|
||||
|
||||
@@ -32,6 +32,7 @@ public sealed record AbLegacyAddress(
|
||||
int? BitIndex,
|
||||
string? SubElement)
|
||||
{
|
||||
/// <summary>Converts the address to the libplctag library address format.</summary>
|
||||
public string ToLibplctagName()
|
||||
{
|
||||
var file = FileNumber is null ? FileLetter : $"{FileLetter}{FileNumber}";
|
||||
@@ -41,6 +42,8 @@ public sealed record AbLegacyAddress(
|
||||
return wordPart;
|
||||
}
|
||||
|
||||
/// <summary>Attempts to parse a string into an AB legacy address.</summary>
|
||||
/// <param name="value">The address string to parse.</param>
|
||||
public static AbLegacyAddress? TryParse(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
|
||||
@@ -31,6 +31,9 @@ public enum AbLegacyDataType
|
||||
/// <summary>Map a PCCC data type to the driver-surface <see cref="DriverDataType"/>.</summary>
|
||||
public static class AbLegacyDataTypeExtensions
|
||||
{
|
||||
/// <summary>Converts an AbLegacyDataType to the corresponding DriverDataType.</summary>
|
||||
/// <param name="t">The PCCC data type to convert.</param>
|
||||
/// <returns>The mapped driver data type.</returns>
|
||||
public static DriverDataType ToDriverDataType(this AbLegacyDataType t) => t switch
|
||||
{
|
||||
AbLegacyDataType.Bit => DriverDataType.Boolean,
|
||||
|
||||
@@ -28,9 +28,23 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
// observes the most recently written value.
|
||||
private volatile DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||
|
||||
/// <summary>
|
||||
/// Occurs when data values change.
|
||||
/// </summary>
|
||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||
|
||||
/// <summary>
|
||||
/// Occurs when host status changes.
|
||||
/// </summary>
|
||||
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AbLegacyDriver"/> class.
|
||||
/// </summary>
|
||||
/// <param name="options">The driver options.</param>
|
||||
/// <param name="driverInstanceId">The driver instance identifier.</param>
|
||||
/// <param name="tagFactory">The tag factory, or <c>null</c> to use the default.</param>
|
||||
/// <param name="logger">The logger, or <c>null</c> to use the null logger.</param>
|
||||
public AbLegacyDriver(AbLegacyDriverOptions options, string driverInstanceId,
|
||||
IAbLegacyTagFactory? tagFactory = null,
|
||||
ILogger<AbLegacyDriver>? logger = null)
|
||||
@@ -46,9 +60,22 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the driver instance identifier.
|
||||
/// </summary>
|
||||
public string DriverInstanceId => _driverInstanceId;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the driver type.
|
||||
/// </summary>
|
||||
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>
|
||||
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Initializing, null, null);
|
||||
@@ -121,12 +148,23 @@ 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>
|
||||
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>
|
||||
public async Task ShutdownAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _poll.DisposeAsync().ConfigureAwait(false);
|
||||
@@ -142,16 +180,46 @@ 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>
|
||||
public DriverHealth GetHealth() => _health;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the memory footprint of the driver.
|
||||
/// </summary>
|
||||
/// <returns>The memory footprint in bytes.</returns>
|
||||
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>
|
||||
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the device count.
|
||||
/// </summary>
|
||||
internal int DeviceCount => _devices.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the device state for the specified host address.
|
||||
/// </summary>
|
||||
/// <param name="hostAddress">The host address.</param>
|
||||
/// <returns>The device state, or <c>null</c> if not found.</returns>
|
||||
internal DeviceState? GetDeviceState(string hostAddress) =>
|
||||
_devices.TryGetValue(hostAddress, out var s) ? s : null;
|
||||
|
||||
// ---- 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>
|
||||
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -237,6 +305,12 @@ 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>
|
||||
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
|
||||
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -326,6 +400,12 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
|
||||
// ---- ITagDiscovery ----
|
||||
|
||||
/// <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>
|
||||
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
@@ -364,10 +444,23 @@ 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>
|
||||
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>
|
||||
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
|
||||
{
|
||||
_poll.Unsubscribe(handle);
|
||||
@@ -376,6 +469,10 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
|
||||
// ---- IHostConnectivityProbe ----
|
||||
|
||||
/// <summary>
|
||||
/// Gets the host connectivity statuses.
|
||||
/// </summary>
|
||||
/// <returns>A list of host connectivity statuses.</returns>
|
||||
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses() =>
|
||||
[.. _devices.Values.Select(s => new HostConnectivityStatus(s.Options.HostAddress, s.HostState, s.HostStateChangedUtc))];
|
||||
|
||||
@@ -462,6 +559,8 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
/// <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>
|
||||
public string ResolveHost(string fullReference)
|
||||
{
|
||||
if (_tagsByName.TryGetValue(fullReference, out var def))
|
||||
@@ -638,6 +737,10 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes the driver asynchronously.
|
||||
/// </summary>
|
||||
/// <returns>A task representing the asynchronous disposal.</returns>
|
||||
public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
internal sealed class DeviceState(
|
||||
@@ -645,8 +748,19 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
AbLegacyDeviceOptions options,
|
||||
AbLegacyPlcFamilyProfile profile)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the parsed host address.
|
||||
/// </summary>
|
||||
public AbLegacyHostAddress ParsedAddress { get; } = parsedAddress;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the device options.
|
||||
/// </summary>
|
||||
public AbLegacyDeviceOptions Options { get; } = options;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the PLC family profile.
|
||||
/// </summary>
|
||||
public AbLegacyPlcFamilyProfile Profile { get; } = profile;
|
||||
|
||||
/// <summary>
|
||||
@@ -686,11 +800,21 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
/// </summary>
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, SemaphoreSlim> _creationLocks = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or creates the creation lock for the specified key.
|
||||
/// </summary>
|
||||
/// <param name="key">The lock key.</param>
|
||||
/// <returns>The semaphore slim for the key.</returns>
|
||||
public SemaphoreSlim GetCreationLock(string key) =>
|
||||
_creationLocks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1));
|
||||
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, SemaphoreSlim> _rmwLocks = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or creates the read-modify-write lock for the specified parent name.
|
||||
/// </summary>
|
||||
/// <param name="parentName">The parent name.</param>
|
||||
/// <returns>The semaphore slim for the parent.</returns>
|
||||
public SemaphoreSlim GetRmwLock(string parentName) =>
|
||||
_rmwLocks.GetOrAdd(parentName, _ => new SemaphoreSlim(1, 1));
|
||||
|
||||
@@ -705,13 +829,34 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
/// or value is never observed mid-update by another thread. Keyed by tag name, which
|
||||
/// is also the <see cref="Runtimes"/> dictionary key.
|
||||
/// </summary>
|
||||
/// <param name="tagName">The tag name.</param>
|
||||
/// <returns>The semaphore slim for the tag.</returns>
|
||||
public SemaphoreSlim GetRuntimeLock(string tagName) =>
|
||||
_runtimeLocks.GetOrAdd(tagName, _ => new SemaphoreSlim(1, 1));
|
||||
|
||||
/// <summary>
|
||||
/// Gets the probe synchronization lock.
|
||||
/// </summary>
|
||||
public object ProbeLock { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the host state.
|
||||
/// </summary>
|
||||
public HostState HostState { get; set; } = HostState.Unknown;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the UTC time when the host state last changed.
|
||||
/// </summary>
|
||||
public DateTime HostStateChangedUtc { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the cancellation token source for the probe loop.
|
||||
/// </summary>
|
||||
public CancellationTokenSource? ProbeCts { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the probe has been initialized.
|
||||
/// </summary>
|
||||
public bool ProbeInitialized { get; set; }
|
||||
|
||||
/// <summary>
|
||||
@@ -725,6 +870,9 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
/// </summary>
|
||||
public bool FirstNonZeroStatusLogged { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Disposes all cached tag runtimes.
|
||||
/// </summary>
|
||||
public void DisposeRuntimes()
|
||||
{
|
||||
foreach (var r in Runtimes.Values) r.Dispose();
|
||||
|
||||
@@ -23,15 +23,30 @@ public static class AbLegacyDriverFactoryExtensions
|
||||
/// the driver runs with the null logger (existing tests and standalone callers stay
|
||||
/// unchanged). Mirrors the Modbus driver registration pattern.
|
||||
/// </summary>
|
||||
/// <param name="registry">The driver factory registry to register with.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory for driver instances.</param>
|
||||
public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an instance of the AB Legacy driver from configuration.
|
||||
/// </summary>
|
||||
/// <param name="driverInstanceId">The unique identifier for this driver instance.</param>
|
||||
/// <param name="driverConfigJson">The driver configuration as a JSON string.</param>
|
||||
/// <returns>A configured <see cref="AbLegacyDriver"/> instance.</returns>
|
||||
internal static AbLegacyDriver CreateInstance(string driverInstanceId, string driverConfigJson)
|
||||
=> CreateInstance(driverInstanceId, driverConfigJson, loggerFactory: null);
|
||||
|
||||
/// <summary>
|
||||
/// Creates an instance of the AB Legacy driver with optional logger.
|
||||
/// </summary>
|
||||
/// <param name="driverInstanceId">The unique identifier for this driver instance.</param>
|
||||
/// <param name="driverConfigJson">The driver configuration as a JSON string.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory for the driver instance.</param>
|
||||
/// <returns>A configured <see cref="AbLegacyDriver"/> instance.</returns>
|
||||
internal static AbLegacyDriver CreateInstance(string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||
@@ -105,34 +120,98 @@ public static class AbLegacyDriverFactoryExtensions
|
||||
|
||||
internal sealed class AbLegacyDriverConfigDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the timeout in milliseconds for operations.
|
||||
/// </summary>
|
||||
public int? TimeoutMs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the list of devices to connect to.
|
||||
/// </summary>
|
||||
public List<AbLegacyDeviceDto>? Devices { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the list of tags to monitor.
|
||||
/// </summary>
|
||||
public List<AbLegacyTagDto>? Tags { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the probe configuration.
|
||||
/// </summary>
|
||||
public AbLegacyProbeDto? Probe { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AbLegacyDeviceDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the host address of the device.
|
||||
/// </summary>
|
||||
public string? HostAddress { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the PLC family.
|
||||
/// </summary>
|
||||
public string? PlcFamily { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the device name.
|
||||
/// </summary>
|
||||
public string? DeviceName { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AbLegacyTagDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the tag name.
|
||||
/// </summary>
|
||||
public string? Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the device host address.
|
||||
/// </summary>
|
||||
public string? DeviceHostAddress { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the tag address.
|
||||
/// </summary>
|
||||
public string? Address { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the data type.
|
||||
/// </summary>
|
||||
public string? DataType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether the tag is writable.
|
||||
/// </summary>
|
||||
public bool? Writable { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether write is idempotent.
|
||||
/// </summary>
|
||||
public bool? WriteIdempotent { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AbLegacyProbeDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets whether probing is enabled.
|
||||
/// </summary>
|
||||
public bool? Enabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the probe interval in milliseconds.
|
||||
/// </summary>
|
||||
public int? IntervalMs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the probe timeout in milliseconds.
|
||||
/// </summary>
|
||||
public int? TimeoutMs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the probe address.
|
||||
/// </summary>
|
||||
public string? ProbeAddress { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,9 +10,16 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
/// </summary>
|
||||
public sealed class AbLegacyDriverOptions
|
||||
{
|
||||
/// <summary>Gets or sets the list of PCCC devices to connect to.</summary>
|
||||
public IReadOnlyList<AbLegacyDeviceOptions> Devices { get; init; } = [];
|
||||
|
||||
/// <summary>Gets or sets the list of PCCC tag definitions.</summary>
|
||||
public IReadOnlyList<AbLegacyTagDefinition> Tags { get; init; } = [];
|
||||
|
||||
/// <summary>Gets or sets the probe (connectivity check) options.</summary>
|
||||
public AbLegacyProbeOptions Probe { get; init; } = new();
|
||||
|
||||
/// <summary>Gets or sets the default timeout for read/write operations.</summary>
|
||||
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
|
||||
}
|
||||
|
||||
@@ -35,10 +42,15 @@ public sealed record AbLegacyTagDefinition(
|
||||
|
||||
public sealed class AbLegacyProbeOptions
|
||||
{
|
||||
/// <summary>Gets or sets a value indicating whether connectivity probing is enabled.</summary>
|
||||
public bool Enabled { get; init; } = true;
|
||||
|
||||
/// <summary>Gets or sets the interval between probe attempts.</summary>
|
||||
public TimeSpan Interval { get; init; } = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>Gets or sets the timeout for each probe operation.</summary>
|
||||
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
|
||||
|
||||
/// <summary>Probe address — defaults to <c>S:0</c> (status file, first word) when null.</summary>
|
||||
/// <summary>Gets or sets the probe address (defaults to <c>S:0</c> — status file, first word).</summary>
|
||||
public string? ProbeAddress { get; init; } = "S:0";
|
||||
}
|
||||
|
||||
@@ -15,10 +15,14 @@ public sealed record AbLegacyHostAddress(string Gateway, int Port, string CipPat
|
||||
{
|
||||
public const int DefaultEipPort = 44818;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string ToString() => Port == DefaultEipPort
|
||||
? $"ab://{Gateway}/{CipPath}"
|
||||
: $"ab://{Gateway}:{Port}/{CipPath}";
|
||||
|
||||
/// <summary>Attempts to parse an AB host address string in the format ab://gateway[:port]/cip-path.</summary>
|
||||
/// <param name="value">The host address string to parse.</param>
|
||||
/// <returns>The parsed host address, or null if the format is invalid.</returns>
|
||||
public static AbLegacyHostAddress? TryParse(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
|
||||
@@ -28,6 +28,8 @@ public static class AbLegacyStatusMapper
|
||||
/// stays correct regardless of how the wrapper renumbers native PLCTAG_ERR_* constants
|
||||
/// in future releases.
|
||||
/// </summary>
|
||||
/// <param name="status">The integer status code from libplctag.</param>
|
||||
/// <returns>The corresponding OPC UA status code.</returns>
|
||||
public static uint MapLibplctagStatus(int status) => MapLibplctagStatus((Status)status);
|
||||
|
||||
/// <summary>
|
||||
@@ -35,6 +37,8 @@ public static class AbLegacyStatusMapper
|
||||
/// the canonical core; the <c>int</c> overload exists only for the
|
||||
/// <see cref="IAbLegacyTagRuntime.GetStatus"/> seam which boxes the enum as an int.
|
||||
/// </summary>
|
||||
/// <param name="status">The libplctag Status enum value.</param>
|
||||
/// <returns>The corresponding OPC UA status code.</returns>
|
||||
public static uint MapLibplctagStatus(Status status) => status switch
|
||||
{
|
||||
Status.Ok => Good,
|
||||
@@ -59,6 +63,8 @@ public static class AbLegacyStatusMapper
|
||||
/// the raw STS byte, so this method is not wired into the current read/write path.
|
||||
/// It is retained as the reference mapping for future PCCC-STS inspection.
|
||||
/// </summary>
|
||||
/// <param name="sts">The PCCC STS byte.</param>
|
||||
/// <returns>The corresponding OPC UA status code.</returns>
|
||||
public static uint MapPcccStatus(byte sts) => sts switch
|
||||
{
|
||||
0x00 => Good,
|
||||
|
||||
@@ -7,16 +7,37 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
/// </summary>
|
||||
public interface IAbLegacyTagRuntime : IDisposable
|
||||
{
|
||||
/// <summary>Initializes the tag runtime.</summary>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
Task InitializeAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Reads the current value of the tag.</summary>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
Task ReadAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Writes the encoded value to the tag.</summary>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
Task WriteAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Gets the current status of the tag operation.</summary>
|
||||
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>
|
||||
object? DecodeValue(AbLegacyDataType type, int? bitIndex);
|
||||
|
||||
/// <summary>Encodes a value for writing to the tag.</summary>
|
||||
/// <param name="type">The data type to encode.</param>
|
||||
/// <param name="bitIndex">Optional bit index for bit-level access.</param>
|
||||
/// <param name="value">The value to encode.</param>
|
||||
void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value);
|
||||
}
|
||||
|
||||
public interface IAbLegacyTagFactory
|
||||
{
|
||||
/// <summary>Creates a tag runtime instance with the specified parameters.</summary>
|
||||
/// <param name="createParams">The tag creation parameters.</param>
|
||||
IAbLegacyTagRuntime Create(AbLegacyTagCreateParams createParams);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
|
||||
{
|
||||
private readonly Tag _tag;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="LibplctagLegacyTagRuntime"/> class.</summary>
|
||||
/// <param name="p">The parameters for tag creation.</param>
|
||||
public LibplctagLegacyTagRuntime(AbLegacyTagCreateParams p)
|
||||
{
|
||||
_tag = new Tag
|
||||
@@ -25,12 +27,19 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task InitializeAsync(CancellationToken cancellationToken) => _tag.InitializeAsync(cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task ReadAsync(CancellationToken cancellationToken) => _tag.ReadAsync(cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task WriteAsync(CancellationToken cancellationToken) => _tag.WriteAsync(cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public int GetStatus() => (int)_tag.GetStatus();
|
||||
|
||||
/// <inheritdoc />
|
||||
public object? DecodeValue(AbLegacyDataType type, int? bitIndex) => type switch
|
||||
{
|
||||
// When a bit suffix is present (e.g. B3:0/5) libplctag resolves the individual bit and
|
||||
@@ -49,6 +58,7 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
public void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value)
|
||||
{
|
||||
switch (type)
|
||||
@@ -86,6 +96,7 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose() => _tag.Dispose();
|
||||
|
||||
private static PlcType MapPlcType(string attribute) => attribute switch
|
||||
@@ -100,6 +111,9 @@ 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>
|
||||
public IAbLegacyTagRuntime Create(AbLegacyTagCreateParams createParams) =>
|
||||
new LibplctagLegacyTagRuntime(createParams);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ public sealed record AbLegacyPlcFamilyProfile(
|
||||
bool SupportsStringFile,
|
||||
bool SupportsLongFile)
|
||||
{
|
||||
/// <summary>Gets the profile for the specified PLC family.</summary>
|
||||
/// <param name="family">The PLC family.</param>
|
||||
public static AbLegacyPlcFamilyProfile ForFamily(AbLegacyPlcFamily family) => family switch
|
||||
{
|
||||
AbLegacyPlcFamily.Slc500 => Slc500,
|
||||
|
||||
@@ -19,6 +19,7 @@ public sealed record FocasAddress(
|
||||
int Number,
|
||||
int? BitIndex)
|
||||
{
|
||||
/// <summary>Gets the canonical string representation of this address.</summary>
|
||||
public string Canonical => Kind switch
|
||||
{
|
||||
FocasAreaKind.Pmc => BitIndex is null
|
||||
@@ -31,6 +32,9 @@ public sealed record FocasAddress(
|
||||
_ => $"?{Number}",
|
||||
};
|
||||
|
||||
/// <summary>Attempts to parse a FOCAS address from the given string.</summary>
|
||||
/// <param name="value">The address string to parse.</param>
|
||||
/// <returns>A FocasAddress if parsing succeeds; otherwise null.</returns>
|
||||
public static FocasAddress? TryParse(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
|
||||
@@ -28,6 +28,10 @@ internal sealed class FocasAlarmProjection : IAsyncDisposable
|
||||
private readonly Lock _subsLock = new();
|
||||
private long _nextId;
|
||||
|
||||
/// <summary>Initializes a new FOCAS alarm projection.</summary>
|
||||
/// <param name="driver">FOCAS driver instance.</param>
|
||||
/// <param name="pollInterval">Polling interval.</param>
|
||||
/// <param name="logger">Optional logger.</param>
|
||||
public FocasAlarmProjection(FocasDriver driver, TimeSpan pollInterval, ILogger? logger = null)
|
||||
{
|
||||
_driver = driver;
|
||||
@@ -35,6 +39,10 @@ internal sealed class FocasAlarmProjection : IAsyncDisposable
|
||||
_logger = logger ?? NullLogger.Instance;
|
||||
}
|
||||
|
||||
/// <summary>Subscribes to alarms from the specified device sources.</summary>
|
||||
/// <param name="sourceNodeIds">Source node IDs to listen to.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task returning the alarm subscription handle.</returns>
|
||||
public Task<IAlarmSubscriptionHandle> SubscribeAsync(
|
||||
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -54,6 +62,10 @@ internal sealed class FocasAlarmProjection : IAsyncDisposable
|
||||
return Task.FromResult<IAlarmSubscriptionHandle>(handle);
|
||||
}
|
||||
|
||||
/// <summary>Unsubscribes from an alarm subscription.</summary>
|
||||
/// <param name="handle">Alarm subscription handle.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task representing the asynchronous operation.</returns>
|
||||
public async Task UnsubscribeAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken)
|
||||
{
|
||||
if (handle is not FocasAlarmSubscriptionHandle h) return;
|
||||
@@ -74,10 +86,15 @@ internal sealed class FocasAlarmProjection : IAsyncDisposable
|
||||
/// condition resolves. Swallow the request so capability negotiation succeeds, rather
|
||||
/// than surfacing a confusing "not supported" error to the operator.
|
||||
/// </summary>
|
||||
/// <param name="acknowledgements">Alarms to acknowledge.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A completed task.</returns>
|
||||
public Task AcknowledgeAsync(
|
||||
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken) =>
|
||||
Task.CompletedTask;
|
||||
|
||||
/// <summary>Disposes the alarm projection.</summary>
|
||||
/// <returns>A task representing the asynchronous operation.</returns>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
List<Subscription> snap;
|
||||
@@ -97,6 +114,9 @@ internal sealed class FocasAlarmProjection : IAsyncDisposable
|
||||
/// emits raise + clear events. Extracted so tests can drive a tick without spinning up
|
||||
/// the full Task.Run loop.
|
||||
/// </summary>
|
||||
/// <param name="sub">Active subscription.</param>
|
||||
/// <param name="deviceHostAddress">Device host address.</param>
|
||||
/// <param name="current">Current alarms from the device.</param>
|
||||
internal void Tick(Subscription sub, string deviceHostAddress, IReadOnlyList<FocasActiveAlarm> current)
|
||||
{
|
||||
var prev = sub.LastByDevice.GetValueOrDefault(deviceHostAddress) ?? [];
|
||||
@@ -158,6 +178,8 @@ internal sealed class FocasAlarmProjection : IAsyncDisposable
|
||||
private static string AlarmKey(FocasActiveAlarm a) => $"{a.Type}:{a.AlarmNumber}";
|
||||
|
||||
/// <summary>Map FOCAS type to a human-readable category; falls back to the numeric type.</summary>
|
||||
/// <param name="type">FOCAS alarm type.</param>
|
||||
/// <returns>The mapped alarm type string.</returns>
|
||||
internal static string MapAlarmType(short type) => type switch
|
||||
{
|
||||
FocasAlarmType.Parameter => "Parameter",
|
||||
@@ -176,6 +198,8 @@ internal sealed class FocasAlarmProjection : IAsyncDisposable
|
||||
/// Servo / Emergency-equivalents are Critical; Parameter + Macro are Medium; rest land
|
||||
/// at High (everything else on a CNC is safety-relevant).
|
||||
/// </summary>
|
||||
/// <param name="type">FOCAS alarm type.</param>
|
||||
/// <returns>The mapped alarm severity.</returns>
|
||||
internal static AlarmSeverity MapSeverity(short type) => type switch
|
||||
{
|
||||
FocasAlarmType.Overtravel => AlarmSeverity.Critical,
|
||||
@@ -191,10 +215,15 @@ internal sealed class FocasAlarmProjection : IAsyncDisposable
|
||||
HashSet<string>? deviceFilter,
|
||||
CancellationTokenSource cts)
|
||||
{
|
||||
/// <summary>Gets the subscription handle.</summary>
|
||||
public FocasAlarmSubscriptionHandle Handle { get; } = handle;
|
||||
/// <summary>Gets the device filter.</summary>
|
||||
public HashSet<string>? DeviceFilter { get; } = deviceFilter;
|
||||
/// <summary>Gets the cancellation token source.</summary>
|
||||
public CancellationTokenSource Cts { get; } = cts;
|
||||
/// <summary>Gets or sets the polling loop task.</summary>
|
||||
public Task Loop { get; set; } = Task.CompletedTask;
|
||||
/// <summary>Gets the last seen alarms by device.</summary>
|
||||
public Dictionary<string, IReadOnlyList<FocasActiveAlarm>> LastByDevice { get; } =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
@@ -203,5 +232,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>
|
||||
public string DiagnosticId => $"focas-alarm-sub-{Id}";
|
||||
}
|
||||
|
||||
@@ -20,12 +20,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
public static class FocasCapabilityMatrix
|
||||
{
|
||||
/// <summary>
|
||||
/// Check whether <paramref name="address"/> is accepted by a CNC of
|
||||
/// <paramref name="series"/>. Returns <c>null</c> on pass + a failure reason
|
||||
/// on reject — the driver surfaces the reason string verbatim when failing
|
||||
/// <c>InitializeAsync</c> so operators see the specific out-of-range without
|
||||
/// guessing.
|
||||
/// Validates whether an address is accepted by a CNC of the given series.
|
||||
/// Returns null on pass, or a failure reason on reject.
|
||||
/// </summary>
|
||||
/// <param name="series">The CNC series to validate against.</param>
|
||||
/// <param name="address">The address to validate.</param>
|
||||
/// <returns>Null if valid, otherwise a failure reason string.</returns>
|
||||
public static string? Validate(FocasCncSeries series, FocasAddress address)
|
||||
{
|
||||
if (series == FocasCncSeries.Unknown) return null;
|
||||
@@ -39,8 +39,9 @@ public static class FocasCapabilityMatrix
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Macro variable number accepted by a CNC series. Cites
|
||||
/// <c>cnc_rdmacro</c>/<c>cnc_wrmacro</c> in the Developer Kit.</summary>
|
||||
/// <summary>Gets the macro variable number range accepted by a CNC series.</summary>
|
||||
/// <param name="series">The CNC series.</param>
|
||||
/// <returns>A tuple of (min, max) macro numbers.</returns>
|
||||
internal static (int min, int max) MacroRange(FocasCncSeries series) => series switch
|
||||
{
|
||||
// Common macros 1-33 + 100-199 + 500-999 universally; extended 10000+ only on
|
||||
@@ -57,8 +58,9 @@ public static class FocasCapabilityMatrix
|
||||
_ => (0, int.MaxValue),
|
||||
};
|
||||
|
||||
/// <summary>Parameter number accepted; from <c>cnc_rdparam</c>/<c>cnc_wrparam</c>.
|
||||
/// Ranges reflect the highest-numbered parameter documented per series.</summary>
|
||||
/// <summary>Gets the parameter number range accepted by a CNC series.</summary>
|
||||
/// <param name="series">The CNC series.</param>
|
||||
/// <returns>A tuple of (min, max) parameter numbers.</returns>
|
||||
internal static (int min, int max) ParameterRange(FocasCncSeries series) => series switch
|
||||
{
|
||||
FocasCncSeries.Sixteen_i => (0, 9999),
|
||||
@@ -73,8 +75,9 @@ public static class FocasCapabilityMatrix
|
||||
_ => (0, int.MaxValue),
|
||||
};
|
||||
|
||||
/// <summary>PMC letters accepted per series. Legacy controllers omit F/M/C
|
||||
/// signal groups that 30i-family ladder programs use.</summary>
|
||||
/// <summary>Gets the PMC letters accepted by a CNC series.</summary>
|
||||
/// <param name="series">The CNC series.</param>
|
||||
/// <returns>A set of accepted PMC letter strings.</returns>
|
||||
internal static IReadOnlySet<string> PmcLetters(FocasCncSeries series) => series switch
|
||||
{
|
||||
FocasCncSeries.Sixteen_i => new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "X", "Y", "R", "D" },
|
||||
@@ -89,9 +92,9 @@ public static class FocasCapabilityMatrix
|
||||
_ => new HashSet<string>(StringComparer.OrdinalIgnoreCase),
|
||||
};
|
||||
|
||||
/// <summary>PMC address-number ceiling per series. Multiplied by 8 to get bit
|
||||
/// count since PMC addresses are byte-addressed on read + bit-addressed on
|
||||
/// write — FocasAddress carries the bit separately.</summary>
|
||||
/// <summary>Gets the maximum PMC address number for a CNC series.</summary>
|
||||
/// <param name="series">The CNC series.</param>
|
||||
/// <returns>The maximum address number.</returns>
|
||||
internal static int PmcMaxNumber(FocasCncSeries series) => series switch
|
||||
{
|
||||
FocasCncSeries.Sixteen_i => 999,
|
||||
|
||||
@@ -27,6 +27,9 @@ public enum FocasDataType
|
||||
|
||||
public static class FocasDataTypeExtensions
|
||||
{
|
||||
/// <summary>Converts a FOCAS data type to the corresponding driver data type.</summary>
|
||||
/// <param name="t">The FOCAS data type to convert.</param>
|
||||
/// <returns>The equivalent driver data type.</returns>
|
||||
public static DriverDataType ToDriverDataType(this FocasDataType t) => t switch
|
||||
{
|
||||
FocasDataType.Bit => DriverDataType.Boolean,
|
||||
|
||||
@@ -38,10 +38,18 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
// record is immutable so there is no torn-read risk on the object itself.
|
||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||
|
||||
/// <summary>Occurs when data changes on a subscribed tag.</summary>
|
||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||
/// <summary>Occurs when a device host connection status changes.</summary>
|
||||
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
||||
/// <summary>Occurs when an alarm event is raised.</summary>
|
||||
public event EventHandler<AlarmEventArgs>? OnAlarmEvent;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="FocasDriver"/> class with the provided options and dependencies.</summary>
|
||||
/// <param name="options">The driver configuration options.</param>
|
||||
/// <param name="driverInstanceId">The unique identifier for this driver instance.</param>
|
||||
/// <param name="clientFactory">Optional factory for creating FOCAS client instances.</param>
|
||||
/// <param name="logger">Optional logger instance.</param>
|
||||
public FocasDriver(FocasDriverOptions options, string driverInstanceId,
|
||||
IFocasClientFactory? clientFactory = null,
|
||||
ILogger<FocasDriver>? logger = null)
|
||||
@@ -57,9 +65,15 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)));
|
||||
}
|
||||
|
||||
/// <summary>Gets the driver instance identifier.</summary>
|
||||
public string DriverInstanceId => _driverInstanceId;
|
||||
/// <summary>Gets the driver type name.</summary>
|
||||
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>
|
||||
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
Volatile.Write(ref _health, new DriverHealth(DriverState.Initializing, null, null));
|
||||
@@ -142,12 +156,19 @@ 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>
|
||||
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>
|
||||
public async Task ShutdownAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _poll.DisposeAsync().ConfigureAwait(false);
|
||||
@@ -181,11 +202,20 @@ 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>
|
||||
public DriverHealth GetHealth() => Volatile.Read(ref _health);
|
||||
/// <summary>Gets the current memory footprint of the driver.</summary>
|
||||
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>
|
||||
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
/// <summary>Gets the number of configured devices.</summary>
|
||||
internal int DeviceCount => _devices.Count;
|
||||
/// <summary>Gets the state of a device by host address.</summary>
|
||||
/// <param name="hostAddress">The host address of the device.</param>
|
||||
/// <returns>The device state if found; otherwise null.</returns>
|
||||
internal DeviceState? GetDeviceState(string hostAddress) =>
|
||||
_devices.TryGetValue(hostAddress, out var s) ? s : null;
|
||||
|
||||
@@ -194,6 +224,10 @@ 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>
|
||||
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -260,6 +294,10 @@ 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>
|
||||
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
|
||||
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -325,6 +363,10 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
|
||||
// ---- ITagDiscovery ----
|
||||
|
||||
/// <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>
|
||||
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
@@ -493,15 +535,27 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// the path inside the tree. Matches what poll-loop snapshots publish +
|
||||
/// what <see cref="ReadAsync"/> looks up.
|
||||
/// </summary>
|
||||
/// <param name="deviceHost">The host address of the device.</param>
|
||||
/// <param name="path">The path within the fixed tree.</param>
|
||||
/// <returns>The canonical full reference for the node.</returns>
|
||||
internal static string FixedTreeReference(string deviceHost, string path) =>
|
||||
$"{deviceHost}/{path}";
|
||||
|
||||
// ---- 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>
|
||||
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>
|
||||
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
|
||||
{
|
||||
_poll.Unsubscribe(handle);
|
||||
@@ -510,6 +564,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
|
||||
// ---- IHostConnectivityProbe ----
|
||||
|
||||
/// <summary>Gets the connectivity status of all configured devices.</summary>
|
||||
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses() =>
|
||||
[.. _devices.Values.Select(s => new HostConnectivityStatus(s.Options.HostAddress, s.HostState, s.HostStateChangedUtc))];
|
||||
|
||||
@@ -915,6 +970,10 @@ 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>
|
||||
public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
|
||||
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -924,13 +983,23 @@ 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>
|
||||
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>
|
||||
public Task AcknowledgeAsync(
|
||||
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken) =>
|
||||
_alarmProjection is { } p ? p.AcknowledgeAsync(acknowledgements, cancellationToken) : Task.CompletedTask;
|
||||
|
||||
/// <summary>Raises an alarm event with the provided arguments.</summary>
|
||||
/// <param name="args">The alarm event arguments.</param>
|
||||
internal void InvokeAlarmEvent(AlarmEventArgs args) => OnAlarmEvent?.Invoke(this, args);
|
||||
|
||||
/// <summary>
|
||||
@@ -938,6 +1007,9 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// projection — kept <c>internal</c> rather than <c>public</c> because callers that
|
||||
/// want alarm events should subscribe through <c>IAlarmSource</c> instead.
|
||||
/// </summary>
|
||||
/// <param name="deviceFilter">Optional set of device host addresses to filter results; null includes all devices.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>A list of tuples containing host address and active alarms for each device.</returns>
|
||||
internal async Task<IReadOnlyList<(string HostAddress, IReadOnlyList<FocasActiveAlarm> Alarms)>>
|
||||
ReadActiveAlarmsAcrossDevicesAsync(HashSet<string>? deviceFilter, CancellationToken ct)
|
||||
{
|
||||
@@ -963,6 +1035,9 @@ 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>
|
||||
public string ResolveHost(string fullReference)
|
||||
{
|
||||
if (_tagsByName.TryGetValue(fullReference, out var def))
|
||||
@@ -999,7 +1074,9 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
return device.Client;
|
||||
}
|
||||
|
||||
/// <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>
|
||||
public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
/// <summary>
|
||||
@@ -1031,25 +1108,41 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
|
||||
internal sealed class DeviceState(FocasHostAddress parsedAddress, FocasDeviceOptions options)
|
||||
{
|
||||
/// <summary>Gets the parsed host address.</summary>
|
||||
public FocasHostAddress ParsedAddress { get; } = parsedAddress;
|
||||
/// <summary>Gets the device configuration options.</summary>
|
||||
public FocasDeviceOptions Options { get; } = options;
|
||||
/// <summary>Gets or sets the FOCAS client instance.</summary>
|
||||
public IFocasClient? Client { get; set; }
|
||||
|
||||
/// <summary>Gets the lock object for probe synchronization.</summary>
|
||||
public object ProbeLock { get; } = new();
|
||||
/// <summary>Gets or sets the current host connectivity state.</summary>
|
||||
public HostState HostState { get; set; } = HostState.Unknown;
|
||||
/// <summary>Gets or sets the timestamp when host state last changed.</summary>
|
||||
public DateTime HostStateChangedUtc { get; set; } = DateTime.UtcNow;
|
||||
/// <summary>Gets or sets the cancellation token source for the probe loop.</summary>
|
||||
public CancellationTokenSource? ProbeCts { get; set; }
|
||||
/// <summary>Gets or sets the cancellation token source for the recycle loop.</summary>
|
||||
public CancellationTokenSource? RecycleCts { get; set; }
|
||||
/// <summary>Gets or sets the cancellation token source for the fixed-tree loop.</summary>
|
||||
public CancellationTokenSource? FixedTreeCts { get; set; }
|
||||
/// <summary>Gets or sets the fixed-tree cache for this device.</summary>
|
||||
public FocasFixedTreeCache? FixedTreeCache { get; set; }
|
||||
/// <summary>Gets the last fixed tree snapshots by field name.</summary>
|
||||
public Dictionary<string, int> LastFixedSnapshots { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
/// <summary>Gets or sets the last program information snapshot.</summary>
|
||||
public FocasProgramInfo? LastProgramInfo { get; set; }
|
||||
/// <summary>Cached first-axis dynamic snapshot — feeds Program/Number, /MainNumber, /Sequence.</summary>
|
||||
/// <summary>Gets or sets the cached first-axis dynamic snapshot — feeds Program/Number, /MainNumber, /Sequence.</summary>
|
||||
public FocasDynamicSnapshot? LastProgramAxisRef { get; set; }
|
||||
/// <summary>Gets the last timer values by timer kind.</summary>
|
||||
public Dictionary<FocasTimerKind, FocasTimer> LastTimers { get; } = [];
|
||||
/// <summary>Gets the last servo load percentages by servo name.</summary>
|
||||
public Dictionary<string, double> LastServoLoads { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
/// <summary>Gets the last spindle load percentages by spindle index.</summary>
|
||||
public Dictionary<int, int> LastSpindleLoads { get; } = [];
|
||||
|
||||
/// <summary>Disposes the FOCAS client instance.</summary>
|
||||
public void DisposeClient()
|
||||
{
|
||||
Client?.Dispose();
|
||||
|
||||
@@ -32,12 +32,19 @@ public static class FocasDriverFactoryExtensions
|
||||
/// Register the FOCAS driver factory in the supplied <see cref="DriverFactoryRegistry"/>.
|
||||
/// Throws if 'FOCAS' is already registered — single-instance per process.
|
||||
/// </summary>
|
||||
/// <param name="registry">The driver factory registry to register with.</param>
|
||||
public static void Register(DriverFactoryRegistry registry)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
registry.Register(DriverTypeName, CreateInstance);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new <see cref="FocasDriver"/> instance from the supplied configuration ID and JSON.
|
||||
/// </summary>
|
||||
/// <param name="driverInstanceId">The unique driver instance identifier.</param>
|
||||
/// <param name="driverConfigJson">The driver configuration JSON string.</param>
|
||||
/// <returns>A configured <see cref="FocasDriver"/> instance.</returns>
|
||||
internal static FocasDriver CreateInstance(string driverInstanceId, string driverConfigJson)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||
@@ -88,6 +95,12 @@ public static class FocasDriverFactoryExtensions
|
||||
return new FocasDriver(options, driverInstanceId, clientFactory);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the appropriate <see cref="IFocasClientFactory"/> based on the config DTO's backend selection.
|
||||
/// </summary>
|
||||
/// <param name="dto">The driver configuration DTO.</param>
|
||||
/// <param name="driverInstanceId">The driver instance identifier for error reporting.</param>
|
||||
/// <returns>A configured <see cref="IFocasClientFactory"/> instance.</returns>
|
||||
internal static IFocasClientFactory BuildClientFactory(
|
||||
FocasDriverConfigDto dto, string driverInstanceId)
|
||||
{
|
||||
@@ -183,38 +196,76 @@ public static class FocasDriverFactoryExtensions
|
||||
|
||||
internal sealed class FocasDriverConfigDto
|
||||
{
|
||||
/// <summary>Gets or sets the FOCAS client factory backend name (e.g. "wire" or "stub").</summary>
|
||||
public string? Backend { get; init; }
|
||||
|
||||
/// <summary>Gets or sets the CNC series for this driver.</summary>
|
||||
public string? Series { get; init; }
|
||||
|
||||
/// <summary>Gets or sets the operation timeout in milliseconds.</summary>
|
||||
public int? TimeoutMs { get; init; }
|
||||
|
||||
/// <summary>Gets or sets the list of CNC devices to manage.</summary>
|
||||
public List<FocasDeviceDto>? Devices { get; init; }
|
||||
|
||||
/// <summary>Gets or sets the list of FOCAS tags to poll.</summary>
|
||||
public List<FocasTagDto>? Tags { get; init; }
|
||||
|
||||
/// <summary>Gets or sets the probe configuration options.</summary>
|
||||
public FocasProbeDto? Probe { get; init; }
|
||||
|
||||
/// <summary>Gets or sets the fixed-tree configuration options.</summary>
|
||||
public FocasFixedTreeDto? FixedTree { get; init; }
|
||||
|
||||
/// <summary>Gets or sets the alarm projection configuration options.</summary>
|
||||
public FocasAlarmProjectionDto? AlarmProjection { get; init; }
|
||||
|
||||
/// <summary>Gets or sets the handle recycle configuration options.</summary>
|
||||
public FocasHandleRecycleDto? HandleRecycle { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class FocasDeviceDto
|
||||
{
|
||||
/// <summary>Gets or sets the hostname or IP address of the CNC.</summary>
|
||||
public string? HostAddress { get; init; }
|
||||
|
||||
/// <summary>Gets or sets the logical device name.</summary>
|
||||
public string? DeviceName { get; init; }
|
||||
|
||||
/// <summary>Gets or sets the CNC series for this device (overrides top-level series if provided).</summary>
|
||||
public string? Series { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class FocasTagDto
|
||||
{
|
||||
/// <summary>Gets or sets the tag name.</summary>
|
||||
public string? Name { get; init; }
|
||||
|
||||
/// <summary>Gets or sets the hostname or IP address of the CNC device for this tag.</summary>
|
||||
public string? DeviceHostAddress { get; init; }
|
||||
|
||||
/// <summary>Gets or sets the FOCAS address or path for this tag.</summary>
|
||||
public string? Address { get; init; }
|
||||
|
||||
/// <summary>Gets or sets the data type for this tag.</summary>
|
||||
public string? DataType { get; init; }
|
||||
|
||||
/// <summary>Gets or sets a value indicating whether this tag is writable.</summary>
|
||||
public bool? Writable { get; init; }
|
||||
|
||||
/// <summary>Gets or sets a value indicating whether writes to this tag are idempotent.</summary>
|
||||
public bool? WriteIdempotent { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class FocasProbeDto
|
||||
{
|
||||
/// <summary>Gets or sets a value indicating whether probing is enabled.</summary>
|
||||
public bool? Enabled { get; init; }
|
||||
|
||||
/// <summary>Gets or sets the probe interval in milliseconds.</summary>
|
||||
public int? IntervalMs { get; init; }
|
||||
|
||||
/// <summary>Gets or sets the probe timeout in milliseconds.</summary>
|
||||
public int? TimeoutMs { get; init; }
|
||||
}
|
||||
|
||||
@@ -225,23 +276,36 @@ public static class FocasDriverFactoryExtensions
|
||||
/// </summary>
|
||||
internal sealed class FocasFixedTreeDto
|
||||
{
|
||||
/// <summary>Gets or sets a value indicating whether fixed-tree discovery is enabled.</summary>
|
||||
public bool? Enabled { get; init; }
|
||||
|
||||
/// <summary>Gets or sets the poll interval for discovering fixed-tree items.</summary>
|
||||
public TimeSpan? PollInterval { get; init; }
|
||||
|
||||
/// <summary>Gets or sets the poll interval for discovering program-related items.</summary>
|
||||
public TimeSpan? ProgramPollInterval { get; init; }
|
||||
|
||||
/// <summary>Gets or sets the poll interval for discovering timer-related items.</summary>
|
||||
public TimeSpan? TimerPollInterval { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Optional <c>AlarmProjection</c> config section.</summary>
|
||||
internal sealed class FocasAlarmProjectionDto
|
||||
{
|
||||
/// <summary>Gets or sets a value indicating whether alarm projection is enabled.</summary>
|
||||
public bool? Enabled { get; init; }
|
||||
|
||||
/// <summary>Gets or sets the alarm poll interval.</summary>
|
||||
public TimeSpan? PollInterval { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Optional <c>HandleRecycle</c> config section.</summary>
|
||||
internal sealed class FocasHandleRecycleDto
|
||||
{
|
||||
/// <summary>Gets or sets a value indicating whether handle recycling is enabled.</summary>
|
||||
public bool? Enabled { get; init; }
|
||||
|
||||
/// <summary>Gets or sets the handle recycle interval.</summary>
|
||||
public TimeSpan? Interval { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,12 +7,19 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
/// </summary>
|
||||
public sealed class FocasDriverOptions
|
||||
{
|
||||
/// <summary>Gets the list of configured CNC devices.</summary>
|
||||
public IReadOnlyList<FocasDeviceOptions> Devices { get; init; } = [];
|
||||
/// <summary>Gets the list of FOCAS tag definitions.</summary>
|
||||
public IReadOnlyList<FocasTagDefinition> Tags { get; init; } = [];
|
||||
/// <summary>Gets the probe options.</summary>
|
||||
public FocasProbeOptions Probe { get; init; } = new();
|
||||
/// <summary>Gets the timeout duration for operations.</summary>
|
||||
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
|
||||
/// <summary>Gets the alarm projection options.</summary>
|
||||
public FocasAlarmProjectionOptions AlarmProjection { get; init; } = new();
|
||||
/// <summary>Gets the handle recycle options.</summary>
|
||||
public FocasHandleRecycleOptions HandleRecycle { get; init; } = new();
|
||||
/// <summary>Gets the fixed tree options.</summary>
|
||||
public FocasFixedTreeOptions FixedTree { get; init; } = new();
|
||||
}
|
||||
|
||||
@@ -25,7 +32,7 @@ public sealed class FocasDriverOptions
|
||||
/// </summary>
|
||||
public sealed class FocasFixedTreeOptions
|
||||
{
|
||||
/// <summary>Enable the fixed-node tree for every configured device.</summary>
|
||||
/// <summary>Gets or sets a value indicating whether the fixed-node tree is enabled for every configured device.</summary>
|
||||
public bool Enabled { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
@@ -67,7 +74,9 @@ public sealed class FocasFixedTreeOptions
|
||||
/// </remarks>
|
||||
public sealed class FocasHandleRecycleOptions
|
||||
{
|
||||
/// <summary>Gets or sets a value indicating whether handle recycling is enabled.</summary>
|
||||
public bool Enabled { get; init; } = false;
|
||||
/// <summary>Gets or sets the interval for handle recycle operations.</summary>
|
||||
public TimeSpan Interval { get; init; } = TimeSpan.FromHours(1);
|
||||
}
|
||||
|
||||
@@ -78,6 +87,7 @@ public sealed class FocasHandleRecycleOptions
|
||||
/// </summary>
|
||||
public sealed class FocasAlarmProjectionOptions
|
||||
{
|
||||
/// <summary>Gets or sets a value indicating whether alarm projection is enabled.</summary>
|
||||
public bool Enabled { get; init; } = false;
|
||||
|
||||
/// <summary>Poll cadence. One <c>cnc_rdalmmsg2</c> call per device per tick.</summary>
|
||||
@@ -109,7 +119,10 @@ public sealed record FocasTagDefinition(
|
||||
|
||||
public sealed class FocasProbeOptions
|
||||
{
|
||||
/// <summary>Gets or sets a value indicating whether probing is enabled.</summary>
|
||||
public bool Enabled { get; init; } = true;
|
||||
/// <summary>Gets or sets the probe interval.</summary>
|
||||
public TimeSpan Interval { get; init; } = TimeSpan.FromSeconds(5);
|
||||
/// <summary>Gets or sets the probe timeout.</summary>
|
||||
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
|
||||
}
|
||||
|
||||
@@ -9,10 +9,14 @@ public sealed record FocasHostAddress(string Host, int Port)
|
||||
/// <summary>Fanuc-reserved TCP port for FOCAS Ethernet.</summary>
|
||||
public const int DefaultPort = 8193;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string ToString() => Port == DefaultPort
|
||||
? $"focas://{Host}"
|
||||
: $"focas://{Host}:{Port}";
|
||||
|
||||
/// <summary>Attempts to parse a FOCAS address string.</summary>
|
||||
/// <param name="value">The address string to parse.</param>
|
||||
/// <returns>A parsed address, or null if the string is invalid.</returns>
|
||||
public static FocasHostAddress? TryParse(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
|
||||
@@ -25,6 +25,8 @@ public static class FocasStatusMapper
|
||||
/// EW_DATA=8, EW_NOOPT=6, EW_PROT=5, EW_OVRFLOW=2, EW_PARITY=9, EW_PASSWD=11,
|
||||
/// EW_BUSY=-1, EW_HANDLE=-8, EW_VERSION=-9, EW_UNEXP=-10, EW_SOCKET=-16).
|
||||
/// </summary>
|
||||
/// <param name="ret">The FWLIB return code.</param>
|
||||
/// <returns>The corresponding OPC UA status code.</returns>
|
||||
public static uint MapFocasReturn(int ret) => ret switch
|
||||
{
|
||||
0 => Good,
|
||||
|
||||
@@ -17,6 +17,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
public interface IFocasClient : IDisposable
|
||||
{
|
||||
/// <summary>Open the FWLIB handle + TCP session. Idempotent.</summary>
|
||||
/// <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>
|
||||
Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>True when the FWLIB handle is valid + the socket is up.</summary>
|
||||
@@ -27,6 +30,9 @@ public interface IFocasClient : IDisposable
|
||||
/// <paramref name="type"/>. Returns a boxed .NET value + the OPC UA status mapped
|
||||
/// through <see cref="FocasStatusMapper"/>.
|
||||
/// </summary>
|
||||
/// <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>
|
||||
Task<(object? value, uint status)> ReadAsync(
|
||||
FocasAddress address,
|
||||
FocasDataType type,
|
||||
@@ -36,6 +42,10 @@ public interface IFocasClient : IDisposable
|
||||
/// Write <paramref name="value"/> to <paramref name="address"/>. Returns the mapped
|
||||
/// OPC UA status (0 = Good).
|
||||
/// </summary>
|
||||
/// <param name="address">The CNC memory address to write to.</param>
|
||||
/// <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>
|
||||
Task<uint> WriteAsync(
|
||||
FocasAddress address,
|
||||
FocasDataType type,
|
||||
@@ -46,6 +56,7 @@ public interface IFocasClient : IDisposable
|
||||
/// Cheap health probe — e.g. <c>cnc_rdcncstat</c>. Returns <c>true</c> when the CNC
|
||||
/// responds with any valid status.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
Task<bool> ProbeAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
@@ -54,6 +65,7 @@ public interface IFocasClient : IDisposable
|
||||
/// active". IAlarmSource projection polls this at a configurable interval +
|
||||
/// emits transitions (raise / clear) on the driver's <c>OnAlarmEvent</c>.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
Task<IReadOnlyList<FocasActiveAlarm>> ReadAlarmsAsync(CancellationToken cancellationToken);
|
||||
|
||||
// ---- Fixed-tree T1 (identity + axis discovery + fast-poll dynamic bundle) ----
|
||||
@@ -63,6 +75,7 @@ public interface IFocasClient : IDisposable
|
||||
/// subtree of the fixed-node surface. Callable once at session open; the
|
||||
/// values don't change across the session.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
Task<FocasSysInfo> GetSysInfoAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
@@ -70,12 +83,14 @@ public interface IFocasClient : IDisposable
|
||||
/// uses these to build the <c>Axes/{name}/</c> subtree and to index
|
||||
/// <see cref="ReadDynamicAsync"/> calls.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
Task<IReadOnlyList<FocasAxisName>> GetAxisNamesAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Read the CNC's configured spindle names via <c>cnc_rdspdlname</c>. Drives
|
||||
/// the <c>Spindle/{name}/</c> subtree.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
Task<IReadOnlyList<FocasSpindleName>> GetSpindleNamesAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
@@ -84,6 +99,8 @@ public interface IFocasClient : IDisposable
|
||||
/// distance-to-go) plus actual feed rate + actual spindle speed + alarm
|
||||
/// flags + program / sequence numbers — one network round-trip per call.
|
||||
/// </summary>
|
||||
/// <param name="axisIndex">The axis index to read dynamics for.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
Task<FocasDynamicSnapshot> ReadDynamicAsync(int axisIndex, CancellationToken cancellationToken);
|
||||
|
||||
// ---- Fixed-tree T2 (program + operation mode) ----
|
||||
@@ -95,6 +112,7 @@ public interface IFocasClient : IDisposable
|
||||
/// <see cref="ReadDynamicAsync"/> since program / mode transitions happen
|
||||
/// on human-operator timescales.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
Task<FocasProgramInfo> GetProgramInfoAsync(CancellationToken cancellationToken);
|
||||
|
||||
// ---- Fixed-tree T3 (timers) ----
|
||||
@@ -104,6 +122,8 @@ public interface IFocasClient : IDisposable
|
||||
/// Cycle. Values are seconds — the managed side already converted the native
|
||||
/// minute+msec representation so downstream nodes display uniform units.
|
||||
/// </summary>
|
||||
/// <param name="kind">The timer kind to read.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
Task<FocasTimer> GetTimerAsync(FocasTimerKind kind, CancellationToken cancellationToken);
|
||||
|
||||
// ---- Fixed-tree T3.5 (servo meters) ----
|
||||
@@ -113,6 +133,7 @@ public interface IFocasClient : IDisposable
|
||||
/// Values are percentages (scaled by <c>10^Dec</c>). Empty list on a
|
||||
/// disconnected session or unsupported CNC.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
Task<IReadOnlyList<FocasServoLoad>> GetServoLoadsAsync(CancellationToken cancellationToken);
|
||||
|
||||
// ---- Fixed-tree T3.6 (spindle meters) ----
|
||||
@@ -123,12 +144,14 @@ public interface IFocasClient : IDisposable
|
||||
/// disconnected session or when the CNC doesn't support the call (older
|
||||
/// series like 16i may return EW_FUNC).
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
Task<IReadOnlyList<int>> GetSpindleLoadsAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Read per-spindle maximum RPM values. Static configuration, fetched once at
|
||||
/// bootstrap. Index alignment as per <see cref="GetSpindleLoadsAsync"/>.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
Task<IReadOnlyList<int>> GetSpindleMaxRpmsAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -180,6 +203,7 @@ public sealed record FocasAxisName(string Name, string Suffix)
|
||||
/// <summary>One configured spindle name (e.g. "S1").</summary>
|
||||
public sealed record FocasSpindleName(string Name, string Suffix1, string Suffix2, string Suffix3)
|
||||
{
|
||||
/// <summary>Gets the display name — name + concatenated suffixes, trimmed.</summary>
|
||||
public string Display
|
||||
{
|
||||
get
|
||||
@@ -210,6 +234,9 @@ public sealed record FocasProgramInfo(
|
||||
/// <summary>Human-readable text for the <see cref="FocasProgramInfo.Mode"/> integer.</summary>
|
||||
public static class FocasOpMode
|
||||
{
|
||||
/// <summary>Converts a numeric operation mode to its text representation.</summary>
|
||||
/// <param name="mode">The operation mode integer code.</param>
|
||||
/// <returns>The text representation of the mode, or a fallback format if unknown.</returns>
|
||||
public static string ToText(int mode) => mode switch
|
||||
{
|
||||
0 => "MDI",
|
||||
@@ -252,6 +279,8 @@ public sealed record FocasActiveAlarm(
|
||||
/// <summary>Factory for <see cref="IFocasClient"/>s. One client per configured device.</summary>
|
||||
public interface IFocasClientFactory
|
||||
{
|
||||
/// <summary>Creates a new FOCAS client instance.</summary>
|
||||
/// <returns>A new IFocasClient instance.</returns>
|
||||
IFocasClient Create();
|
||||
}
|
||||
|
||||
@@ -263,6 +292,9 @@ public interface IFocasClientFactory
|
||||
/// </summary>
|
||||
public sealed class UnimplementedFocasClientFactory : IFocasClientFactory
|
||||
{
|
||||
/// <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>
|
||||
public IFocasClient Create() => throw new NotSupportedException(
|
||||
"FOCAS driver backend is 'unimplemented'. Switch to 'Backend: \"wire\"' in driver config " +
|
||||
"once the CNC is provisioned — see docs/drivers/FOCAS.md.");
|
||||
|
||||
@@ -57,12 +57,14 @@ public enum FocasOperationMode : short
|
||||
public static class FocasOperationModeExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Canonical operator-facing label for an operation mode (e.g. <c>"AUTO"</c>,
|
||||
/// Gets the canonical operator-facing label for an operation mode (e.g. <c>"AUTO"</c>,
|
||||
/// <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.
|
||||
/// </summary>
|
||||
/// <param name="mode">The operation mode.</param>
|
||||
/// <returns>The operator-facing label.</returns>
|
||||
public static string ToText(this FocasOperationMode mode) =>
|
||||
FocasOpMode.ToText((short)mode);
|
||||
}
|
||||
@@ -74,6 +76,9 @@ public static class FocasOperationModeExtensions
|
||||
/// </summary>
|
||||
internal static class FocasPmcAreaLookup
|
||||
{
|
||||
/// <summary>Looks up a PMC area code by letter.</summary>
|
||||
/// <param name="letter">The PMC area letter (case-insensitive).</param>
|
||||
/// <returns>The corresponding FocasPmcArea, or null if not recognized.</returns>
|
||||
public static FocasPmcArea? FromLetter(string letter) => letter.ToUpperInvariant() switch
|
||||
{
|
||||
"G" => FocasPmcArea.G,
|
||||
@@ -98,6 +103,9 @@ internal static class FocasPmcAreaLookup
|
||||
/// </summary>
|
||||
internal static class FocasPmcDataTypeLookup
|
||||
{
|
||||
/// <summary>Maps a FocasDataType to the corresponding PMC wire data type.</summary>
|
||||
/// <param name="t">The FOCAS data type.</param>
|
||||
/// <returns>The PMC data type for wire communication.</returns>
|
||||
public static FocasPmcDataType FromFocasDataType(FocasDataType t) => t switch
|
||||
{
|
||||
FocasDataType.Bit or FocasDataType.Byte => FocasPmcDataType.Byte,
|
||||
|
||||
@@ -46,6 +46,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
/// Construct a disconnected client. Optional <paramref name="logger"/> receives
|
||||
/// <c>Debug</c>-level entries per response block (command ID, RC, payload length).
|
||||
/// </summary>
|
||||
/// <param name="logger">Optional logger for debug-level wire protocol entries.</param>
|
||||
public FocasWireClient(ILogger<FocasWireClient>? logger = null)
|
||||
{
|
||||
_logger = logger;
|
||||
@@ -65,6 +66,10 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
/// call while already connected is a no-op. Sub-second timeouts require the
|
||||
/// <see cref="ConnectAsync(string, int, TimeSpan, CancellationToken)"/> overload.
|
||||
/// </summary>
|
||||
/// <param name="host">The CNC hostname or IP address.</param>
|
||||
/// <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>
|
||||
public Task ConnectAsync(
|
||||
string host,
|
||||
int port,
|
||||
@@ -81,6 +86,10 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
/// <see cref="TimeSpan.Zero"/> to disable the timeout entirely (rely on the caller's
|
||||
/// <paramref name="cancellationToken"/> instead). Idempotent.
|
||||
/// </summary>
|
||||
/// <param name="host">The CNC hostname or IP address.</param>
|
||||
/// <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>
|
||||
public Task ConnectAsync(
|
||||
string host,
|
||||
int port,
|
||||
@@ -206,6 +215,9 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
/// Read CNC identity via <c>cnc_sysinfo</c>. Cached from the connect-time exchange
|
||||
/// unless a per-call <paramref name="pathId"/> override is supplied.
|
||||
/// </summary>
|
||||
/// <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>
|
||||
public async Task<FocasResult<WireSysInfo>> ReadSysInfoAsync(
|
||||
CancellationToken cancellationToken = default,
|
||||
TimeSpan? timeout = null,
|
||||
@@ -218,6 +230,9 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
}
|
||||
|
||||
/// <summary>Read CNC status bits via <c>cnc_statinfo</c> (3 command blocks aggregated into one <see cref="WireStatus"/>).</summary>
|
||||
/// <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>
|
||||
public async Task<FocasResult<WireStatus>> ReadStatusAsync(
|
||||
CancellationToken cancellationToken = default,
|
||||
TimeSpan? timeout = null,
|
||||
@@ -253,6 +268,10 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
}
|
||||
|
||||
/// <summary>Read configured axis names via <c>cnc_rdaxisname</c> (command <c>0x0089</c>).</summary>
|
||||
/// <param name="maxCount">Maximum number of axis records to return.</param>
|
||||
/// <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>
|
||||
public async Task<FocasResult<IReadOnlyList<WireAxisRecord>>> ReadAxisNamesAsync(
|
||||
short maxCount = 32,
|
||||
CancellationToken cancellationToken = default,
|
||||
@@ -265,6 +284,10 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
}
|
||||
|
||||
/// <summary>Read configured spindle names via <c>cnc_rdspdlname</c> (command <c>0x008a</c>).</summary>
|
||||
/// <param name="maxCount">Maximum number of spindle records to return.</param>
|
||||
/// <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>
|
||||
public async Task<FocasResult<IReadOnlyList<WireSpindleRecord>>> ReadSpindleNamesAsync(
|
||||
short maxCount = 8,
|
||||
CancellationToken cancellationToken = default,
|
||||
@@ -281,6 +304,10 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
/// one PDU and aggregates the replies — alarm flags, program/sequence numbers, feed
|
||||
/// and spindle actuals, plus the four-slot position quadruple.
|
||||
/// </summary>
|
||||
/// <param name="axis">The axis number to read from.</param>
|
||||
/// <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>
|
||||
public async Task<FocasResult<WireDynamic>> ReadDynamic2Async(
|
||||
short axis = 1,
|
||||
CancellationToken cancellationToken = default,
|
||||
@@ -322,6 +349,10 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
}
|
||||
|
||||
/// <summary>Read servo-meter load percentages via <c>cnc_rdsvmeter</c> (command <c>0x0056</c>).</summary>
|
||||
/// <param name="maxCount">Maximum number of servo meter records to return.</param>
|
||||
/// <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>
|
||||
public async Task<FocasResult<IReadOnlyList<WireServoMeter>>> ReadServoMeterAsync(
|
||||
short maxCount = 32,
|
||||
CancellationToken cancellationToken = default,
|
||||
@@ -355,6 +386,10 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
}
|
||||
|
||||
/// <summary>Read per-spindle load percentages via <c>cnc_rdspload</c> (command <c>0x0040</c> with arg1=0).</summary>
|
||||
/// <param name="spindleSelector">Spindle selector; -1 selects all spindles.</param>
|
||||
/// <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>
|
||||
public Task<FocasResult<IReadOnlyList<WireSpindleMetric>>> ReadSpindleLoadAsync(
|
||||
short spindleSelector = -1,
|
||||
CancellationToken cancellationToken = default,
|
||||
@@ -363,6 +398,10 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
=> ReadSpindleMetricAsync(0, spindleSelector, cancellationToken, timeout, pathId);
|
||||
|
||||
/// <summary>Read per-spindle maximum RPMs via <c>cnc_rdspmaxrpm</c> (command <c>0x0040</c> with arg1=1).</summary>
|
||||
/// <param name="spindleSelector">Spindle selector; -1 selects all spindles.</param>
|
||||
/// <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>
|
||||
public Task<FocasResult<IReadOnlyList<WireSpindleMetric>>> ReadSpindleMaxRpmAsync(
|
||||
short spindleSelector = -1,
|
||||
CancellationToken cancellationToken = default,
|
||||
@@ -375,6 +414,11 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
/// payload to the type declared in the per-series parameter catalog. <paramref name="axis"/>
|
||||
/// selects an axis-scoped parameter; <c>0</c> means global.
|
||||
/// </summary>
|
||||
/// <param name="dataNumber">The parameter data number.</param>
|
||||
/// <param name="axis">Axis selector; zero means a global parameter.</param>
|
||||
/// <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>
|
||||
public async Task<FocasResult<byte[]>> ReadParameterBytesAsync(
|
||||
short dataNumber,
|
||||
short axis = 0,
|
||||
@@ -389,6 +433,11 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
}
|
||||
|
||||
/// <summary>Typed Int32 parameter read — convenience over <see cref="ReadParameterBytesAsync"/>.</summary>
|
||||
/// <param name="dataNumber">The parameter data number.</param>
|
||||
/// <param name="type">The FOCAS parameter type code.</param>
|
||||
/// <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>
|
||||
public async Task<FocasResult<WireParameter>> ReadParameterAsync(
|
||||
short dataNumber,
|
||||
short type = 0,
|
||||
@@ -404,6 +453,11 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
}
|
||||
|
||||
/// <summary>Typed 8-bit parameter read.</summary>
|
||||
/// <param name="dataNumber">The parameter data number.</param>
|
||||
/// <param name="axis">Axis selector; zero means a global parameter.</param>
|
||||
/// <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>
|
||||
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);
|
||||
@@ -413,6 +467,11 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
}
|
||||
|
||||
/// <summary>Typed 16-bit parameter read.</summary>
|
||||
/// <param name="dataNumber">The parameter data number.</param>
|
||||
/// <param name="axis">Axis selector; zero means a global parameter.</param>
|
||||
/// <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>
|
||||
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);
|
||||
@@ -422,6 +481,11 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
}
|
||||
|
||||
/// <summary>Typed 32-bit parameter read.</summary>
|
||||
/// <param name="dataNumber">The parameter data number.</param>
|
||||
/// <param name="axis">Axis selector; zero means a global parameter.</param>
|
||||
/// <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>
|
||||
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);
|
||||
@@ -431,6 +495,11 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
}
|
||||
|
||||
/// <summary>Typed IEEE-754 single-precision parameter read.</summary>
|
||||
/// <param name="dataNumber">The parameter data number.</param>
|
||||
/// <param name="axis">Axis selector; zero means a global parameter.</param>
|
||||
/// <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>
|
||||
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);
|
||||
@@ -440,6 +509,11 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
}
|
||||
|
||||
/// <summary>Typed IEEE-754 double-precision parameter read.</summary>
|
||||
/// <param name="dataNumber">The parameter data number.</param>
|
||||
/// <param name="axis">Axis selector; zero means a global parameter.</param>
|
||||
/// <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>
|
||||
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);
|
||||
@@ -449,6 +523,10 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
}
|
||||
|
||||
/// <summary>Read a single macro variable via <c>cnc_rdmacro</c> (command <c>0x0015</c>).</summary>
|
||||
/// <param name="number">The macro variable number.</param>
|
||||
/// <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>
|
||||
public Task<FocasResult<WireMacro>> ReadMacroAsync(
|
||||
short number,
|
||||
CancellationToken cancellationToken = default,
|
||||
@@ -465,6 +543,13 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
/// is the width code (see <see cref="FocasPmcDataType"/>). Payload is decoded into
|
||||
/// <see cref="WirePmcRange.Values"/> — one entry per slot of the requested width.
|
||||
/// </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; must be greater than or equal to start.</param>
|
||||
/// <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>
|
||||
public async Task<FocasResult<WirePmcRange>> ReadPmcRangeAsync(
|
||||
short area,
|
||||
short dataType,
|
||||
@@ -510,6 +595,13 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
}
|
||||
|
||||
/// <summary>Typed overload for <see cref="ReadPmcRangeAsync(short, short, ushort, ushort, CancellationToken, TimeSpan?, ushort?)"/>.</summary>
|
||||
/// <param name="area">The PMC address-letter code.</param>
|
||||
/// <param name="dataType">The PMC data width code.</param>
|
||||
/// <param name="start">The starting address.</param>
|
||||
/// <param name="end">The ending address; must be greater than or equal to start.</param>
|
||||
/// <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>
|
||||
public Task<FocasResult<WirePmcRange>> ReadPmcRangeAsync(
|
||||
FocasPmcArea area,
|
||||
FocasPmcDataType dataType,
|
||||
@@ -525,6 +617,11 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
/// the 76-byte vendor <c>ODBALMMSG2_data</c> layout and the 80-byte legacy wire
|
||||
/// shape so the same managed surface works across firmware revisions.
|
||||
/// </summary>
|
||||
/// <param name="type">Alarm type filter; -1 reads all active alarms.</param>
|
||||
/// <param name="count">Maximum number of alarms to return.</param>
|
||||
/// <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>
|
||||
public async Task<FocasResult<IReadOnlyList<WireAlarm>>> ReadAlarmsAsync(
|
||||
short type = -1,
|
||||
short count = 32,
|
||||
@@ -541,6 +638,9 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
}
|
||||
|
||||
/// <summary>Read operation mode via <c>cnc_rdopmode</c>, returned as the typed <see cref="FocasOperationMode"/>.</summary>
|
||||
/// <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>
|
||||
public Task<FocasResult<FocasOperationMode>> ReadOperationModeAsync(
|
||||
CancellationToken cancellationToken = default,
|
||||
TimeSpan? timeout = null,
|
||||
@@ -555,6 +655,9 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
/// FOCAS <c>short</c> so callers storing the raw mode code (e.g. OtOpcUa's
|
||||
/// <c>FocasProgramInfo.Mode</c> int field) don't have to cast the enum.
|
||||
/// </summary>
|
||||
/// <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>
|
||||
public Task<FocasResult<short>> ReadOperationModeCodeAsync(
|
||||
CancellationToken cancellationToken = default,
|
||||
TimeSpan? timeout = null,
|
||||
@@ -565,6 +668,9 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
cancellationToken, timeout, EffectivePathId(pathId));
|
||||
|
||||
/// <summary>Read the currently-executing program name + O-number via <c>cnc_exeprgname2</c> (command <c>0x00fc</c>).</summary>
|
||||
/// <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>
|
||||
public Task<FocasResult<WireProgramName>> ReadExecutingProgramNameAsync(
|
||||
CancellationToken cancellationToken = default,
|
||||
TimeSpan? timeout = null,
|
||||
@@ -572,6 +678,9 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
=> ReadSingleWithTimeoutAsync(0x00fc, ParseProgramName, cancellationToken, timeout, EffectivePathId(pathId));
|
||||
|
||||
/// <summary>Read the executed block count via <c>cnc_rdblkcount</c>.</summary>
|
||||
/// <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>
|
||||
public Task<FocasResult<int>> ReadBlockCountAsync(
|
||||
CancellationToken cancellationToken = default,
|
||||
TimeSpan? timeout = null,
|
||||
@@ -585,6 +694,10 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
/// Read one cumulative timer via <c>cnc_rdtimer</c>. <paramref name="type"/> selects
|
||||
/// PowerOn / Operating / Cutting / Cycle per the FOCAS spec (0..3).
|
||||
/// </summary>
|
||||
/// <param name="type">Timer type selector (0=PowerOn, 1=Operating, 2=Cutting, 3=Cycle).</param>
|
||||
/// <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>
|
||||
public Task<FocasResult<WireTimer>> ReadTimerAsync(
|
||||
short type,
|
||||
CancellationToken cancellationToken = default,
|
||||
|
||||
@@ -26,11 +26,17 @@ public class FocasWireException : Exception
|
||||
/// </summary>
|
||||
public bool IsTransient { get; }
|
||||
|
||||
/// <summary>Initializes a new FOCAS wire exception with a message.</summary>
|
||||
/// <param name="message">The exception message.</param>
|
||||
public FocasWireException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Initializes a new FOCAS wire exception with message, return code, and transient flag.</summary>
|
||||
/// <param name="message">The exception message.</param>
|
||||
/// <param name="rc">The FOCAS return code, if available.</param>
|
||||
/// <param name="isTransient">Whether the transport was closed.</param>
|
||||
public FocasWireException(string message, short? rc, bool isTransient = false)
|
||||
: base(message)
|
||||
{
|
||||
@@ -38,11 +44,18 @@ public class FocasWireException : Exception
|
||||
IsTransient = isTransient;
|
||||
}
|
||||
|
||||
/// <summary>Initializes a new FOCAS wire exception with a message and inner exception.</summary>
|
||||
/// <param name="message">The exception message.</param>
|
||||
/// <param name="innerException">The inner exception.</param>
|
||||
public FocasWireException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Initializes a new FOCAS wire exception with message, inner exception, and transient flag.</summary>
|
||||
/// <param name="message">The exception message.</param>
|
||||
/// <param name="innerException">The inner exception.</param>
|
||||
/// <param name="isTransient">Whether the transport was closed.</param>
|
||||
public FocasWireException(string message, Exception innerException, bool isTransient)
|
||||
: base(message, innerException)
|
||||
{
|
||||
|
||||
@@ -29,6 +29,10 @@ internal static class FocasWireProtocol
|
||||
private static readonly byte[] Magic = [0xa0, 0xa0, 0xa0, 0xa0];
|
||||
|
||||
/// <summary>Assemble a full PDU (10-byte header + body) for transmission.</summary>
|
||||
/// <param name="type">The PDU type byte.</param>
|
||||
/// <param name="direction">The direction byte (request or response).</param>
|
||||
/// <param name="body">The PDU body bytes.</param>
|
||||
/// <returns>The complete PDU bytes including header and body.</returns>
|
||||
public static byte[] BuildPdu(byte type, byte direction, ReadOnlySpan<byte> body)
|
||||
{
|
||||
if (body.Length > ushort.MaxValue)
|
||||
@@ -49,6 +53,8 @@ internal static class FocasWireProtocol
|
||||
/// opens two TCP sockets in sequence and each sends its own initiate PDU carrying its
|
||||
/// index.
|
||||
/// </summary>
|
||||
/// <param name="socketIndex">The socket index (1 or 2).</param>
|
||||
/// <returns>The initiate body bytes.</returns>
|
||||
public static byte[] BuildInitiateBody(ushort socketIndex)
|
||||
{
|
||||
var body = new byte[2];
|
||||
@@ -57,6 +63,8 @@ internal static class FocasWireProtocol
|
||||
}
|
||||
|
||||
/// <summary>Assemble a type-<c>0x21</c> body carrying one or more request blocks.</summary>
|
||||
/// <param name="blocks">The request blocks to assemble.</param>
|
||||
/// <returns>The assembled body bytes.</returns>
|
||||
public static byte[] BuildRequestBody(IReadOnlyList<RequestBlock> blocks)
|
||||
{
|
||||
if (blocks.Count > ushort.MaxValue)
|
||||
@@ -79,6 +87,9 @@ internal static class FocasWireProtocol
|
||||
}
|
||||
|
||||
/// <summary>Async read of one full PDU off a stream. Throws <see cref="FocasWireException"/> on invalid magic / version / truncation.</summary>
|
||||
/// <param name="stream">The network stream to read from.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The read PDU.</returns>
|
||||
public static async Task<Pdu> ReadPduAsync(NetworkStream stream, CancellationToken cancellationToken)
|
||||
{
|
||||
var header = new byte[10];
|
||||
@@ -100,6 +111,8 @@ internal static class FocasWireProtocol
|
||||
}
|
||||
|
||||
/// <summary>Synchronous counterpart to <see cref="ReadPduAsync"/> — used by <see cref="FocasWireClient"/>'s sync dispose.</summary>
|
||||
/// <param name="stream">The network stream to read from.</param>
|
||||
/// <returns>The read PDU.</returns>
|
||||
public static Pdu ReadPdu(NetworkStream stream)
|
||||
{
|
||||
var header = new byte[10];
|
||||
@@ -149,6 +162,8 @@ internal static class FocasWireProtocol
|
||||
/// block carries the command ID, the FOCAS <c>EW_*</c> return code, and the payload
|
||||
/// bytes.
|
||||
/// </summary>
|
||||
/// <param name="body">The response body bytes.</param>
|
||||
/// <returns>The parsed response blocks.</returns>
|
||||
public static IReadOnlyList<ResponseBlock> ParseResponseBlocks(ReadOnlySpan<byte> body)
|
||||
{
|
||||
if (body.Length < 2)
|
||||
@@ -181,6 +196,8 @@ internal static class FocasWireProtocol
|
||||
}
|
||||
|
||||
/// <summary>Read an ASCII string out of a payload span, stopping at the first NUL and trimming trailing spaces.</summary>
|
||||
/// <param name="bytes">The bytes to decode.</param>
|
||||
/// <returns>The decoded ASCII string.</returns>
|
||||
public static string ReadAscii(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
var end = bytes.IndexOf((byte)0);
|
||||
@@ -193,6 +210,8 @@ internal static class FocasWireProtocol
|
||||
/// (spindle) slot. Trailing spaces and NULs are stripped so <c>"X "</c> becomes
|
||||
/// <c>"X"</c>.
|
||||
/// </summary>
|
||||
/// <param name="bytes">The bytes to decode.</param>
|
||||
/// <returns>The decoded name record.</returns>
|
||||
public static string ReadNameRecord(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
if (bytes.Length < 2) return string.Empty;
|
||||
|
||||
@@ -30,13 +30,19 @@ public sealed class WireFocasClient : IFocasClient
|
||||
/// <see cref="FocasWireClient"/> so the per-response Debug entries actually reach
|
||||
/// the host's logging pipeline (Driver.FOCAS-007).
|
||||
/// </summary>
|
||||
/// <param name="logger">Optional logger for debug output from wire client responses.</param>
|
||||
public WireFocasClient(ILogger<FocasWireClient>? logger)
|
||||
{
|
||||
_wire = new FocasWireClient(logger);
|
||||
}
|
||||
|
||||
/// <summary>Gets a value indicating whether the wire client is connected to the FOCAS host.</summary>
|
||||
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>
|
||||
public async Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_wire.IsConnected) return;
|
||||
@@ -48,6 +54,11 @@ 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>
|
||||
public async Task<(object? value, uint status)> ReadAsync(
|
||||
FocasAddress address, FocasDataType type, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -63,10 +74,19 @@ 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>
|
||||
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>
|
||||
public async Task<bool> ProbeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_wire.IsConnected) return false;
|
||||
@@ -81,6 +101,9 @@ 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>
|
||||
public async Task<IReadOnlyList<FocasActiveAlarm>> ReadAlarmsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_wire.IsConnected) return [];
|
||||
@@ -102,6 +125,9 @@ 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>
|
||||
public async Task<FocasSysInfo> GetSysInfoAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
RequireConnected();
|
||||
@@ -121,6 +147,9 @@ 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>
|
||||
public async Task<IReadOnlyList<FocasAxisName>> GetAxisNamesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_wire.IsConnected) return [];
|
||||
@@ -140,6 +169,9 @@ 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>
|
||||
public async Task<IReadOnlyList<FocasSpindleName>> GetSpindleNamesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_wire.IsConnected) return [];
|
||||
@@ -158,6 +190,10 @@ 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>
|
||||
public async Task<FocasDynamicSnapshot> ReadDynamicAsync(int axisIndex, CancellationToken cancellationToken)
|
||||
{
|
||||
RequireConnected();
|
||||
@@ -179,6 +215,9 @@ 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>
|
||||
public async Task<FocasProgramInfo> GetProgramInfoAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
RequireConnected();
|
||||
@@ -206,6 +245,10 @@ 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>
|
||||
public async Task<FocasTimer> GetTimerAsync(FocasTimerKind kind, CancellationToken cancellationToken)
|
||||
{
|
||||
RequireConnected();
|
||||
@@ -215,6 +258,9 @@ 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>
|
||||
public async Task<IReadOnlyList<FocasServoLoad>> GetServoLoadsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_wire.IsConnected) return [];
|
||||
@@ -226,9 +272,15 @@ 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>
|
||||
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>
|
||||
public Task<IReadOnlyList<int>> GetSpindleMaxRpmsAsync(CancellationToken cancellationToken) =>
|
||||
ReadSpindleMetricAsync((sel, ct) => _wire.ReadSpindleMaxRpmAsync(sel, ct), cancellationToken);
|
||||
|
||||
@@ -249,6 +301,7 @@ public sealed class WireFocasClient : IFocasClient
|
||||
return list;
|
||||
}
|
||||
|
||||
/// <summary>Disposes the wire client and releases all resources.</summary>
|
||||
public void Dispose() => _wire.Dispose();
|
||||
|
||||
// ---- PMC / Parameter / Macro read paths ------------------------------------------
|
||||
@@ -359,6 +412,7 @@ public sealed class WireFocasClientFactory : IFocasClientFactory
|
||||
{
|
||||
private readonly ILogger<FocasWireClient>? _logger;
|
||||
|
||||
/// <summary>Initializes a new instance of the WireFocasClientFactory without a logger.</summary>
|
||||
public WireFocasClientFactory() : this(logger: null) { }
|
||||
|
||||
/// <summary>
|
||||
@@ -367,10 +421,13 @@ public sealed class WireFocasClientFactory : IFocasClientFactory
|
||||
/// 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)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>Creates a new WireFocasClient instance.</summary>
|
||||
/// <returns>A new IFocasClient implementation.</returns>
|
||||
public IFocasClient Create() => new WireFocasClient(_logger);
|
||||
}
|
||||
|
||||
@@ -36,6 +36,9 @@ internal static class AlarmRefBuilder
|
||||
/// attribute's full reference (e.g. <c>"Tank1.Level.HiHi"</c>); the convention prefixes
|
||||
/// each suffix to it.
|
||||
/// </summary>
|
||||
/// <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>
|
||||
public static AlarmConditionInfo Build(
|
||||
string fullReference,
|
||||
AlarmSeverity initialSeverity = AlarmSeverity.Medium,
|
||||
|
||||
@@ -18,6 +18,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
|
||||
/// </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>
|
||||
public static DriverDataType Map(int mxDataType) => mxDataType switch
|
||||
{
|
||||
0 => DriverDataType.Boolean,
|
||||
|
||||
@@ -46,6 +46,9 @@ public sealed class DeployWatcher : IDisposable
|
||||
/// <inheritdoc cref="IRediscoverable.OnRediscoveryNeeded"/>
|
||||
public event EventHandler<RediscoveryEventArgs>? OnRediscoveryNeeded;
|
||||
|
||||
/// <summary>Initializes a new deploy watcher with default backoff parameters.</summary>
|
||||
/// <param name="source">The deploy watch source to subscribe to.</param>
|
||||
/// <param name="logger">Optional logger instance; defaults to null logger.</param>
|
||||
public DeployWatcher(IGalaxyDeployWatchSource source, ILogger? logger = null)
|
||||
: this(source, logger, DefaultInitialBackoff, DefaultMaxBackoff, jitter: null)
|
||||
{
|
||||
@@ -55,6 +58,11 @@ public sealed class DeployWatcher : IDisposable
|
||||
/// Test-only ctor lets tests collapse the retry backoff so a fault-injection
|
||||
/// scenario doesn't sit in <see cref="Task.Delay(TimeSpan, CancellationToken)"/>.
|
||||
/// </summary>
|
||||
/// <param name="source">The deploy watch source to subscribe to.</param>
|
||||
/// <param name="logger">Optional logger instance.</param>
|
||||
/// <param name="initialBackoff">Initial backoff duration for retry.</param>
|
||||
/// <param name="maxBackoff">Maximum backoff duration for retry.</param>
|
||||
/// <param name="jitter">Optional function to apply jitter to backoff intervals.</param>
|
||||
internal DeployWatcher(
|
||||
IGalaxyDeployWatchSource source,
|
||||
ILogger? logger,
|
||||
@@ -74,6 +82,8 @@ public sealed class DeployWatcher : IDisposable
|
||||
/// has been scheduled — the loop itself runs until <see cref="StopAsync"/> or
|
||||
/// the supplied <paramref name="cancellationToken"/> is signaled.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Token to signal cancellation of the watch loop.</param>
|
||||
/// <returns>A task that completes when the loop has been scheduled.</returns>
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (Interlocked.Exchange(ref _started, 1) != 0)
|
||||
@@ -112,6 +122,7 @@ public sealed class DeployWatcher : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Disposes the watcher and stops the background loop.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_loopTask is null) return;
|
||||
|
||||
@@ -26,6 +26,8 @@ public sealed class GalaxyDiscoverer
|
||||
{
|
||||
private readonly IGalaxyHierarchySource _source;
|
||||
|
||||
/// <summary>Initializes a new GalaxyDiscoverer with the specified hierarchy source.</summary>
|
||||
/// <param name="source">The Galaxy hierarchy source to use for discovery.</param>
|
||||
public GalaxyDiscoverer(IGalaxyHierarchySource source)
|
||||
{
|
||||
_source = source ?? throw new ArgumentNullException(nameof(source));
|
||||
@@ -35,6 +37,8 @@ public sealed class GalaxyDiscoverer
|
||||
/// Drive the supplied builder with one folder + N variables per Galaxy object the
|
||||
/// gateway returns. Idempotent — caller can re-invoke after a redeploy event.
|
||||
/// </summary>
|
||||
/// <param name="builder">The address space builder to populate with discovery results.</param>
|
||||
/// <param name="cancellationToken">The cancellation token for the operation.</param>
|
||||
public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
|
||||
@@ -15,11 +15,17 @@ public sealed class GatewayGalaxyDeployWatchSource : IGalaxyDeployWatchSource
|
||||
{
|
||||
private readonly GalaxyRepositoryClient _client;
|
||||
|
||||
/// <summary>Initializes a new instance of the GatewayGalaxyDeployWatchSource class.</summary>
|
||||
/// <param name="client">The Galaxy repository client.</param>
|
||||
public GatewayGalaxyDeployWatchSource(GalaxyRepositoryClient client)
|
||||
{
|
||||
_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>
|
||||
public IAsyncEnumerable<DeployEvent> WatchAsync(
|
||||
DateTimeOffset? lastSeenDeployTime, CancellationToken cancellationToken)
|
||||
=> _client.WatchDeployEventsAsync(lastSeenDeployTime, cancellationToken);
|
||||
|
||||
@@ -11,11 +11,19 @@ public sealed class GatewayGalaxyHierarchySource : IGalaxyHierarchySource
|
||||
{
|
||||
private readonly GalaxyRepositoryClient _client;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="GatewayGalaxyHierarchySource"/> class.
|
||||
/// </summary>
|
||||
/// <param name="client">The gateway's Galaxy repository client for discovering the object hierarchy.</param>
|
||||
public GatewayGalaxyHierarchySource(GalaxyRepositoryClient client)
|
||||
{
|
||||
_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>
|
||||
public Task<IReadOnlyList<GalaxyObject>> GetHierarchyAsync(CancellationToken cancellationToken)
|
||||
=> _client.DiscoverHierarchyAsync(cancellationToken);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ public interface IGalaxyDeployWatchSource
|
||||
/// <see cref="DeployWatcher"/> still suppresses the first event it observes locally
|
||||
/// so a transport reconnect doesn't re-fire on identical state.
|
||||
/// </summary>
|
||||
/// <param name="lastSeenDeployTime">The last seen deploy time, or null to receive a bootstrap event.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
IAsyncEnumerable<DeployEvent> WatchAsync(
|
||||
DateTimeOffset? lastSeenDeployTime, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -15,5 +15,6 @@ public interface IGalaxyHierarchySource
|
||||
/// internally; this interface deliberately exposes only the post-paging shape so
|
||||
/// callers don't reimplement paging.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
Task<IReadOnlyList<GalaxyObject>> GetHierarchyAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
|
||||
/// </summary>
|
||||
internal static class SecurityMap
|
||||
{
|
||||
/// <summary>Maps a Galaxy security_classification code to a SecurityClassification enum.</summary>
|
||||
/// <param name="mxSec">The Galaxy security classification code.</param>
|
||||
/// <returns>The corresponding SecurityClassification.</returns>
|
||||
public static SecurityClassification Map(int mxSec) => mxSec switch
|
||||
{
|
||||
0 => SecurityClassification.FreeAccess,
|
||||
|
||||
@@ -11,6 +11,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
|
||||
/// </summary>
|
||||
internal sealed class TracedGalaxyHierarchySource(IGalaxyHierarchySource inner, string clientName) : IGalaxyHierarchySource
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<GalaxyObject>> GetHierarchyAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
using var activity = GalaxyTelemetry.ActivitySource.StartActivity("galaxy.get_hierarchy");
|
||||
|
||||
@@ -129,6 +129,10 @@ public sealed class GalaxyDriver
|
||||
/// <inheritdoc />
|
||||
public event EventHandler<AlarmEventArgs>? OnAlarmEvent;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="GalaxyDriver"/> class.</summary>
|
||||
/// <param name="driverInstanceId">The unique identifier for this driver instance.</param>
|
||||
/// <param name="options">The Galaxy driver configuration options.</param>
|
||||
/// <param name="logger">Optional logger instance for diagnostics.</param>
|
||||
public GalaxyDriver(
|
||||
string driverInstanceId,
|
||||
GalaxyDriverOptions options,
|
||||
@@ -145,6 +149,15 @@ public sealed class GalaxyDriver
|
||||
/// <see cref="SubscribeAsync"/> can be exercised against canned data without
|
||||
/// building real gRPC channels.
|
||||
/// </summary>
|
||||
/// <param name="driverInstanceId">The unique identifier for this driver instance.</param>
|
||||
/// <param name="options">The Galaxy driver configuration options.</param>
|
||||
/// <param name="hierarchySource">Optional custom hierarchy source for testing.</param>
|
||||
/// <param name="dataReader">Optional custom data reader for testing.</param>
|
||||
/// <param name="dataWriter">Optional custom data writer for testing.</param>
|
||||
/// <param name="subscriber">Optional custom subscriber for testing.</param>
|
||||
/// <param name="alarmAcknowledger">Optional custom alarm acknowledger for testing.</param>
|
||||
/// <param name="alarmFeed">Optional custom alarm feed for testing.</param>
|
||||
/// <param name="logger">Optional logger instance for diagnostics.</param>
|
||||
internal GalaxyDriver(
|
||||
string driverInstanceId,
|
||||
GalaxyDriverOptions options,
|
||||
@@ -188,6 +201,7 @@ public sealed class GalaxyDriver
|
||||
/// <see cref="GalaxyReconnectOptions.ReplayOnSessionLost"/> branch can be
|
||||
/// asserted deterministically (Driver.Galaxy-013).
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token for the replay operation.</param>
|
||||
internal Task InvokeReplayForTestAsync(CancellationToken cancellationToken) =>
|
||||
ReplayAsync(cancellationToken);
|
||||
|
||||
@@ -434,6 +448,7 @@ public sealed class GalaxyDriver
|
||||
/// A future PR can swap any of these arms for a DPAPI-backed lookup without
|
||||
/// changing the call site.
|
||||
/// </summary>
|
||||
/// <param name="secretRef">The secret reference string to resolve.</param>
|
||||
internal static string ResolveApiKey(string secretRef) => ResolveApiKey(secretRef, logger: null);
|
||||
|
||||
/// <summary>
|
||||
@@ -442,6 +457,8 @@ public sealed class GalaxyDriver
|
||||
/// API key in <c>DriverConfig</c> JSON). The <c>dev:</c> prefix is the explicit
|
||||
/// opt-in path that doesn't warn.
|
||||
/// </summary>
|
||||
/// <param name="secretRef">The secret reference string to resolve.</param>
|
||||
/// <param name="logger">Optional logger for warning on cleartext keys.</param>
|
||||
internal static string ResolveApiKey(string secretRef, ILogger? logger)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(secretRef);
|
||||
@@ -1261,15 +1278,26 @@ 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>
|
||||
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>
|
||||
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>
|
||||
public void AddProperty(string browseName, DriverDataType dataType, object? value)
|
||||
=> inner.AddProperty(browseName, dataType, value);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,9 @@ public static class GalaxyDriverFactoryExtensions
|
||||
{
|
||||
public const string DriverTypeName = "GalaxyMxGateway";
|
||||
|
||||
/// <summary>Registers the Galaxy driver factory with the given registry and optional logger factory.</summary>
|
||||
/// <param name="registry">The driver factory registry to register with.</param>
|
||||
/// <param name="loggerFactory">The optional logger factory for creating drivers.</param>
|
||||
public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
@@ -30,9 +33,15 @@ 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>
|
||||
public static GalaxyDriver CreateInstance(string driverInstanceId, string driverConfigJson)
|
||||
=> CreateInstance(driverInstanceId, driverConfigJson, loggerFactory: null);
|
||||
|
||||
/// <summary>Creates a Galaxy driver instance from configuration JSON with optional logger factory.</summary>
|
||||
/// <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>
|
||||
public static GalaxyDriver CreateInstance(
|
||||
string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory)
|
||||
{
|
||||
@@ -81,43 +90,68 @@ public static class GalaxyDriverFactoryExtensions
|
||||
AllowTrailingCommas = true,
|
||||
};
|
||||
|
||||
/// <summary>Data transfer object for Galaxy driver configuration JSON.</summary>
|
||||
internal sealed class GalaxyDriverConfigDto
|
||||
{
|
||||
/// <summary>Gets or sets the gateway configuration.</summary>
|
||||
public GatewayDto? Gateway { get; init; }
|
||||
/// <summary>Gets or sets the MX Access configuration.</summary>
|
||||
public MxAccessDto? MxAccess { get; init; }
|
||||
/// <summary>Gets or sets the repository configuration.</summary>
|
||||
public RepositoryDto? Repository { get; init; }
|
||||
/// <summary>Gets or sets the reconnect configuration.</summary>
|
||||
public ReconnectDto? Reconnect { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Gateway configuration section.</summary>
|
||||
internal sealed class GatewayDto
|
||||
{
|
||||
/// <summary>Gets or sets the gateway endpoint address.</summary>
|
||||
public string? Endpoint { get; init; }
|
||||
/// <summary>Gets or sets the API key secret reference.</summary>
|
||||
public string? ApiKeySecretRef { get; init; }
|
||||
/// <summary>Gets or sets whether to use TLS.</summary>
|
||||
public bool? UseTls { get; init; }
|
||||
/// <summary>Gets or sets the CA certificate path.</summary>
|
||||
public string? CaCertificatePath { get; init; }
|
||||
/// <summary>Gets or sets the connection timeout in seconds.</summary>
|
||||
public int? ConnectTimeoutSeconds { get; init; }
|
||||
/// <summary>Gets or sets the default call timeout in seconds.</summary>
|
||||
public int? DefaultCallTimeoutSeconds { get; init; }
|
||||
/// <summary>Gets or sets the stream timeout in seconds.</summary>
|
||||
public int? StreamTimeoutSeconds { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>MX Access configuration section.</summary>
|
||||
internal sealed class MxAccessDto
|
||||
{
|
||||
/// <summary>Gets or sets the client name.</summary>
|
||||
public string? ClientName { get; init; }
|
||||
/// <summary>Gets or sets the publishing interval in milliseconds.</summary>
|
||||
public int? PublishingIntervalMs { get; init; }
|
||||
/// <summary>Gets or sets the write user ID.</summary>
|
||||
public int? WriteUserId { get; init; }
|
||||
/// <summary>Gets or sets the event pump channel capacity.</summary>
|
||||
public int? EventPumpChannelCapacity { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Repository configuration section.</summary>
|
||||
internal sealed class RepositoryDto
|
||||
{
|
||||
/// <summary>Gets or sets the discover page size.</summary>
|
||||
public int? DiscoverPageSize { get; init; }
|
||||
/// <summary>Gets or sets whether to watch deploy events.</summary>
|
||||
public bool? WatchDeployEvents { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Reconnect configuration section.</summary>
|
||||
internal sealed class ReconnectDto
|
||||
{
|
||||
/// <summary>Gets or sets the initial backoff in milliseconds.</summary>
|
||||
public int? InitialBackoffMs { get; init; }
|
||||
/// <summary>Gets or sets the maximum backoff in milliseconds.</summary>
|
||||
public int? MaxBackoffMs { get; init; }
|
||||
/// <summary>Gets or sets whether to replay on session lost.</summary>
|
||||
public bool? ReplayOnSessionLost { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,10 @@ public sealed class HostConnectivityForwarder : IDisposable
|
||||
private readonly ILogger _logger;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>Initializes a new instance of HostConnectivityForwarder with the given client name, aggregator, and optional logger.</summary>
|
||||
/// <param name="clientName">The client name for the MxAccess connection.</param>
|
||||
/// <param name="aggregator">The host status aggregator to push connectivity state to.</param>
|
||||
/// <param name="logger">The optional logger for diagnostic messages.</param>
|
||||
public HostConnectivityForwarder(string clientName, HostStatusAggregator aggregator, ILogger? logger = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(clientName);
|
||||
@@ -39,6 +43,7 @@ public sealed class HostConnectivityForwarder : IDisposable
|
||||
/// Push a transport state into the aggregator. Idempotent at the aggregator layer —
|
||||
/// repeated calls with the same state don't fan out duplicate transitions.
|
||||
/// </summary>
|
||||
/// <param name="state">The host connectivity state to push.</param>
|
||||
public void SetTransport(HostState state)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
@@ -49,6 +54,7 @@ public sealed class HostConnectivityForwarder : IDisposable
|
||||
_clientName, state);
|
||||
}
|
||||
|
||||
/// <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
|
||||
|
||||
@@ -53,6 +53,7 @@ public sealed class HostStatusAggregator
|
||||
/// state value differs from the last cached entry. Re-asserting the same
|
||||
/// state is silent.
|
||||
/// </summary>
|
||||
/// <param name="status">The host connectivity status to upsert.</param>
|
||||
public void Update(HostConnectivityStatus status)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(status);
|
||||
@@ -87,6 +88,8 @@ public sealed class HostStatusAggregator
|
||||
/// is fired — observers only react to live transitions, not topology
|
||||
/// reductions. Returns <c>true</c> when the host was tracked.
|
||||
/// </summary>
|
||||
/// <param name="hostName">The name of the host to remove.</param>
|
||||
/// <returns>True if the host was tracked and removed; false if it was not tracked.</returns>
|
||||
public bool Remove(string hostName)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(hostName);
|
||||
|
||||
@@ -45,6 +45,11 @@ public sealed class PerPlatformProbeWatcher : IDisposable
|
||||
private readonly Lock _syncLock = new();
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>Initializes a new instance of the PerPlatformProbeWatcher class.</summary>
|
||||
/// <param name="subscriber">The Galaxy subscriber for managing probe subscriptions.</param>
|
||||
/// <param name="aggregator">The host status aggregator for tracking platform connectivity.</param>
|
||||
/// <param name="logger">Optional logger for diagnostic messages.</param>
|
||||
/// <param name="bufferedUpdateIntervalMs">Buffered update interval in milliseconds; must be >= 0.</param>
|
||||
public PerPlatformProbeWatcher(
|
||||
IGalaxySubscriber subscriber,
|
||||
HostStatusAggregator aggregator,
|
||||
@@ -70,6 +75,8 @@ public sealed class PerPlatformProbeWatcher : IDisposable
|
||||
/// Subscribes new entries, unsubscribes dropped ones. Calling with the same set is
|
||||
/// a no-op.
|
||||
/// </summary>
|
||||
/// <param name="platformTagNames">The platform tag names to synchronize.</param>
|
||||
/// <param name="cancellationToken">A cancellation token to cancel the operation.</param>
|
||||
public async Task SyncPlatformsAsync(
|
||||
IEnumerable<string> platformTagNames, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -149,6 +156,9 @@ public sealed class PerPlatformProbeWatcher : IDisposable
|
||||
/// <see cref="ProbeSuffix"/>, or a probe for a platform we're not tracking) are
|
||||
/// silently dropped.
|
||||
/// </summary>
|
||||
/// <param name="fullReference">The full reference path of the probe attribute.</param>
|
||||
/// <param name="value">The probe value to decode.</param>
|
||||
/// <param name="qualityByte">The quality byte for the value.</param>
|
||||
public void OnProbeValueChanged(string fullReference, object? value, byte qualityByte)
|
||||
{
|
||||
if (_disposed) return;
|
||||
@@ -166,6 +176,9 @@ public sealed class PerPlatformProbeWatcher : IDisposable
|
||||
/// Decode a ScanState value + raw quality byte to a <see cref="HostState"/>.
|
||||
/// Public for tests that want to pin the decoding table.
|
||||
/// </summary>
|
||||
/// <param name="value">The probe value to decode.</param>
|
||||
/// <param name="qualityByte">The quality byte for the value.</param>
|
||||
/// <returns>The decoded host state.</returns>
|
||||
public static HostState DecodeState(object? value, byte qualityByte)
|
||||
{
|
||||
if (qualityByte < 192) return HostState.Unknown;
|
||||
@@ -182,6 +195,7 @@ public sealed class PerPlatformProbeWatcher : IDisposable
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Disposes the probe watcher and unsubscribes all tracked platforms.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
|
||||
@@ -61,8 +61,17 @@ internal sealed class EventPump : IAsyncDisposable
|
||||
private Task? _dispatchLoop;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>Occurs when a data change event is received from the Galaxy subscriber.</summary>
|
||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||
|
||||
/// <summary>Initializes a new instance of the EventPump class.</summary>
|
||||
/// <param name="subscriber">The Galaxy subscriber to consume events from.</param>
|
||||
/// <param name="registry">The subscription registry for resolving subscribers.</param>
|
||||
/// <param name="logger">The logger instance; if null, uses NullLogger.</param>
|
||||
/// <param name="handleFactory">The factory for creating subscription handles; if null, uses GalaxySubscriptionHandle.</param>
|
||||
/// <param name="channelCapacity">The bounded channel capacity for buffering events.</param>
|
||||
/// <param name="clientName">The client name for metric tagging; if null, uses "<unknown>".</param>
|
||||
/// <param name="onStreamFault">Optional callback invoked when the stream faults.</param>
|
||||
public EventPump(
|
||||
IGalaxySubscriber subscriber,
|
||||
SubscriptionRegistry registry,
|
||||
@@ -234,6 +243,8 @@ internal sealed class EventPump : IAsyncDisposable
|
||||
ServerTimestampUtc: DateTime.UtcNow);
|
||||
}
|
||||
|
||||
/// <summary>Disposes the event pump and cancels all running tasks.</summary>
|
||||
/// <returns>A value task representing the asynchronous disposal operation.</returns>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed) return;
|
||||
|
||||
@@ -11,6 +11,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||
/// </summary>
|
||||
internal sealed class GalaxyAlarmSubscriptionHandle : IAlarmSubscriptionHandle
|
||||
{
|
||||
/// <summary>Initializes a new instance of the GalaxyAlarmSubscriptionHandle class.</summary>
|
||||
/// <param name="diagnosticId">The diagnostic ID for the subscription.</param>
|
||||
public GalaxyAlarmSubscriptionHandle(string diagnosticId)
|
||||
{
|
||||
DiagnosticId = diagnosticId;
|
||||
|
||||
@@ -30,12 +30,16 @@ public sealed class GalaxyMxSession : IAsyncDisposable
|
||||
private int _serverHandle;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>Initializes a new instance of the GalaxyMxSession class.</summary>
|
||||
/// <param name="options">MX Access configuration options.</param>
|
||||
/// <param name="logger">Optional logger instance; uses NullLogger if not provided.</param>
|
||||
public GalaxyMxSession(GalaxyMxAccessOptions options, ILogger? logger = null)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? NullLogger.Instance;
|
||||
}
|
||||
|
||||
/// <summary>Gets a value indicating whether the session is connected.</summary>
|
||||
public bool IsConnected => _session is not null;
|
||||
|
||||
/// <summary>
|
||||
@@ -49,6 +53,8 @@ public sealed class GalaxyMxSession : IAsyncDisposable
|
||||
/// configured client name. Idempotent — second calls are no-ops while
|
||||
/// <see cref="IsConnected"/> is true.
|
||||
/// </summary>
|
||||
/// <param name="clientOptions">The MX gateway client options.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
public async Task ConnectAsync(MxGatewayClientOptions clientOptions, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
@@ -67,6 +73,8 @@ public sealed class GalaxyMxSession : IAsyncDisposable
|
||||
/// fake). Skips the gateway-client construction so tests can drive the session
|
||||
/// surface without spinning a real gRPC channel. Caller retains client ownership.
|
||||
/// </summary>
|
||||
/// <param name="session">The MX gateway session to attach.</param>
|
||||
/// <param name="serverHandle">The server handle value to use.</param>
|
||||
internal void AttachForTests(MxGatewaySession session, int serverHandle)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
@@ -80,6 +88,7 @@ public sealed class GalaxyMxSession : IAsyncDisposable
|
||||
/// </summary>
|
||||
public MxGatewaySession? Session => _session;
|
||||
|
||||
/// <summary>Disposes the session and underlying gateway client resources.</summary>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed) return;
|
||||
|
||||
@@ -8,5 +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>
|
||||
public string DiagnosticId => $"galaxy-sub-{SubscriptionId}";
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ internal static class GalaxyTelemetry
|
||||
/// Tag a span with a failure reason and set its status to <c>Error</c>. Helper
|
||||
/// so the decorators don't repeat the four-line idiom on every catch block.
|
||||
/// </summary>
|
||||
/// <param name="activity">The activity to tag with error information.</param>
|
||||
/// <param name="ex">The exception to record.</param>
|
||||
public static void RecordError(this Activity? activity, Exception ex)
|
||||
{
|
||||
if (activity is null) return;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user