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

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:
Joseph Doherty
2026-05-28 08:10:17 -04:00
parent f9fc7dd2e1
commit 64e3fbe035
756 changed files with 9876 additions and 96 deletions
@@ -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 &lt;tag&gt;: 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"/> &lt;= 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 "&lt;unknown&gt;".</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