cc140593a0
FocasDataType.String was authorable on any address, but no area read returns text: PMC fell through to a byte read, PARAM to Int32, MACRO to a double, and the String node received a boxed number at Good. One rule, FocasTagDefinitionFactory.DescribeDataTypeMismatch, now backs every path: - FromTagConfig rejects the pair, so the driver skips + reports it (BadNodeIdUnknown, never a Good number) and Inspect names the reason at deploy; - FocasTagConfigModel.Validate refuses it on the typed fields; - the AdminUI editor only offers String where it is readable and shows the rule's text inline (kept visible for a loaded blob that already carries it); - the CLI read/subscribe/probe/write commands refuse it at the boundary. String stays valid on FIXED: paths (Identity/Program name/mode text) and is ignored on ALARM: tags; an unparseable address keeps its own diagnosis. Razor change (FocasTagConfigEditor.razor) needs the Wave 4 live check. Refs #740 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
130 lines
6.1 KiB
C#
130 lines
6.1 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.FOCAS.Cli.Commands;
|
|
|
|
/// <summary>
|
|
/// Watch a FOCAS address via polled subscription until Ctrl+C. FOCAS has no push
|
|
/// model; <c>PollGroupEngine</c> handles the tick loop.
|
|
/// </summary>
|
|
[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). String only on FIXED: paths.")]
|
|
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();
|
|
// Validate numeric option ranges (including the subscribe-only
|
|
// --interval-ms) before any driver work so a zero/negative interval surfaces as a
|
|
// clean CommandException rather than a tight-spinning poll loop.
|
|
ValidateOptions(IntervalMs);
|
|
ValidateDataType(Address, DataType);
|
|
var ct = console.RegisterCancellationHandler();
|
|
|
|
var tagName = ReadCommand.SynthesiseTagName(Address, DataType);
|
|
var tag = new FocasTagDefinition(
|
|
Name: tagName,
|
|
DeviceHostAddress: HostAddress,
|
|
Address: Address,
|
|
DataType: DataType,
|
|
Writable: false);
|
|
var options = BuildOptions([tag]);
|
|
|
|
// `await using` is the sole driver-disposal mechanism — FocasDriver.DisposeAsync
|
|
// already invokes ShutdownAsync, so a redundant ShutdownAsync(CancellationToken.None) in finally
|
|
// ran shutdown twice. Only UnsubscribeAsync stays in the finally block — that's a subscription
|
|
// lifecycle concern that is not part of driver disposal.
|
|
await using var driver = new FocasDriver(options, DriverInstanceId);
|
|
// Serialize console writes from the PollGroupEngine background
|
|
// thread so overlapping poll ticks (and the "Subscribed to ..." banner from the CliFx
|
|
// invocation thread) can't interleave partial lines.
|
|
var writeLock = new object();
|
|
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).
|
|
// The handler is synchronous because OnDataChange is raised from a driver
|
|
// background thread; the IConsole.Output writer is not documented as thread-safe
|
|
// so we serialize against the banner write via writeLock.
|
|
driver.OnDataChange += (_, e) =>
|
|
{
|
|
// 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);
|
|
|
|
// Hold the lock around the banner write so the first
|
|
// poll-driven change line from the driver tick thread can't interleave with
|
|
// this banner.
|
|
lock (writeLock)
|
|
{
|
|
console.Output.WriteLine(
|
|
$"Subscribed to {Address} @ {IntervalMs}ms. Ctrl+C to stop.");
|
|
}
|
|
try
|
|
{
|
|
await Task.Delay(System.Threading.Timeout.InfiniteTimeSpan, ct);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// Expected on Ctrl+C.
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
if (handle is not null)
|
|
{
|
|
// Stop the subscription before disposal — UnsubscribeAsync
|
|
// halts the poll-group ticker so no further OnDataChange events fire. The
|
|
// anonymous handler is never explicitly removed via -=; instead, driver disposal
|
|
// (via `await using` immediately after this finally) tears down the PollGroupEngine,
|
|
// which is the real cleanup. The unsubscribe here is a best-effort subscription
|
|
// lifecycle step so the CNC-side session is cleanly released before teardown.
|
|
try { await driver.UnsubscribeAsync(handle, CancellationToken.None); }
|
|
catch { /* teardown best-effort */ }
|
|
}
|
|
// Flush Serilog before process exit so buffered log output emitted during driver
|
|
// shutdown is not silently dropped (matching DriverCommandBase docs and every
|
|
// sibling CLI — Modbus / AbCip / AbLegacy / TwinCAT).
|
|
FlushLogging();
|
|
}
|
|
}
|
|
}
|