Files
lmxopcua/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/SubscribeCommand.cs
T
Joseph Doherty 80ef8806e0 fix(driver-modbus-cli): resolve Low code-review findings (Driver.Modbus.Cli-003,004,005,006,007,008)
- Driver.Modbus.Cli-003: ModbusCommandBase.ValidateEndpoint rejects
  --port outside 1..65535, non-positive --timeout-ms, and --unit-id
  outside 1..247.
- Driver.Modbus.Cli-004: wrapped SubscribeCommand's OnDataChange handler
  body in a try/catch (warn-and-swallow) and serialised the console
  write through a lock.
- Driver.Modbus.Cli-005: Probe / Read / Write now catch the
  cancellation-during-init OperationCanceledException and print
  'Cancelled.' instead of dumping a stack trace.
- Driver.Modbus.Cli-006: ProbeCommand.ComputeVerdict derives the headline
  from BOTH the driver state and the probe snapshot's OPC UA quality
  class so the headline can't disagree with the wire result.
- Driver.Modbus.Cli-007: docs/Driver.Modbus.Cli.md carries an explicit
  'CLI scope' callout — the address-string grammar is a DriverConfig
  JSON feature; the CLI takes the structured triple only.
- Driver.Modbus.Cli-008: pinned BuildOptions, ValidateEndpoint, the
  region-validation guards, ComputeVerdict, and the cancellation-during-
  initialize paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 08:35:05 -04:00

131 lines
5.7 KiB
C#

using CliFx.Attributes;
using CliFx.Infrastructure;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Commands;
/// <summary>
/// Long-running poll of one Modbus register via the driver's <c>ISubscribable</c> surface
/// (under the hood: <c>PollGroupEngine</c>). Prints each data-change event until the
/// operator Ctrl+C's the CLI. Useful for watching a changing PLC signal during
/// commissioning or while reproducing a customer bug.
/// </summary>
[Command("subscribe", Description = "Watch a Modbus register via polled subscription until Ctrl+C.")]
public sealed class SubscribeCommand : ModbusCommandBase
{
[CommandOption("region", 'r', Description =
"Coils / DiscreteInputs / InputRegisters / HoldingRegisters", IsRequired = true)]
public ModbusRegion Region { get; init; }
[CommandOption("address", 'a', Description = "Zero-based address within the region.", IsRequired = true)]
public ushort Address { get; init; }
[CommandOption("type", 't', Description =
"Bool / Int16 / UInt16 / Int32 / UInt32 / Int64 / UInt64 / Float32 / Float64 / " +
"BitInRegister / String / Bcd16 / Bcd32", IsRequired = true)]
public ModbusDataType DataType { get; init; }
[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;
[CommandOption("byte-order", Description =
"BigEndian (default) or WordSwap.")]
public ModbusByteOrder ByteOrder { get; init; } = ModbusByteOrder.BigEndian;
// 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.
[CommandOption("bit-index", Description =
"For type=BitInRegister: which bit of the holding register (0-15, LSB-first).")]
public byte BitIndex { get; init; }
[CommandOption("string-length", Description =
"For type=String: character count (2 per register, rounded up).")]
public ushort StringLength { get; init; }
[CommandOption("string-byte-order", Description =
"For type=String: HighByteFirst (standard) or LowByteFirst (DirectLOGIC).")]
public ModbusStringByteOrder StringByteOrder { get; init; } = ModbusStringByteOrder.HighByteFirst;
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
ValidateEndpoint();
var ct = console.RegisterCancellationHandler();
var tagName = ReadCommand.SynthesiseTagName(Region, Address, DataType);
var tag = new ModbusTagDefinition(
Name: tagName,
Region: Region,
Address: Address,
DataType: DataType,
Writable: false,
ByteOrder: ByteOrder,
BitIndex: BitIndex,
StringLength: StringLength,
StringByteOrder: StringByteOrder);
var options = BuildOptions([tag]);
await using var driver = new ModbusDriver(options, DriverInstanceId);
// Driver.Modbus.Cli-004: serialize console writes from the PollGroupEngine background
// thread so overlapping poll ticks can't interleave partial lines.
var writeLock = new object();
ISubscriptionHandle? handle = null;
try
{
await driver.InitializeAsync("{}", ct);
// Route every data-change event to the CliFx console (not System.Console — the
// analyzer flags it + IConsole is the testable abstraction).
driver.OnDataChange += (_, e) =>
{
// Driver.Modbus.Cli-004: swallow + log write failures so a transient stdout
// error (closed pipe, IO exception on a redirected stream) cannot tear down
// the poll-engine background loop. Without this guard the unhandled
// exception would fault the long-running subscribe.
try
{
var line = $"[{DateTime.UtcNow:HH:mm:ss.fff}] " +
$"{e.FullReference} = {SnapshotFormatter.FormatValue(e.Snapshot.Value)} " +
$"({SnapshotFormatter.FormatStatus(e.Snapshot.StatusCode)})";
lock (writeLock)
{
console.Output.WriteLine(line);
}
}
catch (Exception ex)
{
Serilog.Log.Logger.Warning(ex,
"SubscribeCommand: console write failed for {Tag}; continuing poll loop.",
e.FullReference);
}
};
handle = await driver.SubscribeAsync([tagName], TimeSpan.FromMilliseconds(IntervalMs), ct);
await console.Output.WriteLineAsync(
$"Subscribed to {tagName} @ {IntervalMs}ms. Ctrl+C to stop.");
try
{
await Task.Delay(System.Threading.Timeout.InfiniteTimeSpan, ct);
}
catch (OperationCanceledException)
{
// Expected on Ctrl+C — fall through to the unsubscribe in finally.
}
}
finally
{
if (handle is not null)
{
try { await driver.UnsubscribeAsync(handle, CancellationToken.None); }
catch { /* teardown best-effort */ }
}
await driver.ShutdownAsync(CancellationToken.None);
}
}
}