Final two of the five driver test clients. Pattern carried forward from #249 (Modbus) + #250 (AB CIP, AB Legacy) — each CLI inherits Driver.Cli.Common for DriverCommandBase + SnapshotFormatter and adds a protocol-specific CommandBase + 4 commands (probe / read / write / subscribe). New projects: - src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/ — otopcua-s7-cli. S7CommandBase carries host/port/cpu/rack/slot/timeout. Handles all S7 atomic types (Bool, Byte, Int16..UInt64, Float32/64, String, DateTime). DateTime parses via RoundtripKind so "2026-04-21T12:34:56Z" works. - src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/ — otopcua-twincat-cli. TwinCATCommandBase carries ams-net-id + ams-port + --poll-only toggle (flips UseNativeNotifications=false). Covers the full IEC 61131-3 atomic set: Bool, SInt/USInt, Int/UInt, DInt/UDInt, LInt/ULInt, Real, LReal, String, WString, Time/Date/DateTime/TimeOfDay. Structure writes refused as out-of-scope (same as AB CIP). IEC time/date variants marshal as UDINT on the wire per IEC spec. Subscribe banner announces "ADS notification" vs "polling" so the mechanism is obvious in bug reports. Tests (49 new, 122 cumulative driver-CLI): - S7: 22 tests. Every S7DataType has a happy-path + bounds case. DateTime round-trips an ISO-8601 string. Tag-name synthesis round-trips every S7 address form (DB / M / I / Q, bit/word/dword, strings). - TwinCAT: 27 tests. Full IEC type matrix including WString UTF-8 pass- through + the four IEC time/date variants landing on UDINT. Structure rejection case. Tag-name synthesis for Program scope, GVL scope, nested UDT members, and array elements. Docs: - docs/Driver.S7.Cli.md — address grammar cheat sheet + the PUT/GET-must- be-enabled gotcha every S7-1200/1500 operator hits. - docs/Driver.TwinCAT.Cli.md — AMS router prerequisite (XAR / standalone Router NuGet / remote AMS route) + per-command examples. Wiring: - ZB.MOM.WW.OtOpcUa.slnx grew 4 entries (2 src + 2 tests). Full-solution build clean. Both --help outputs verified end-to-end. Driver CLI suite complete: 5 CLIs (otopcua-{modbus,abcip,ablegacy,s7,twincat}-cli) sharing a common base + formatter. 122 CLI tests cumulative. Every driver family shipped in v2 now has a shell-level ad-hoc validation tool. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
95 lines
4.3 KiB
C#
95 lines
4.3 KiB
C#
using System.Globalization;
|
|
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.TwinCAT.Cli.Commands;
|
|
|
|
/// <summary>
|
|
/// Write one value to a TwinCAT symbol. Structure writes refused — drop to driver config
|
|
/// JSON for those.
|
|
/// </summary>
|
|
[Command("write", Description = "Write a single TwinCAT symbol.")]
|
|
public sealed class WriteCommand : TwinCATCommandBase
|
|
{
|
|
[CommandOption("symbol", 's', Description =
|
|
"Symbol path — same format as `read`.", IsRequired = true)]
|
|
public string SymbolPath { get; init; } = default!;
|
|
|
|
[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;
|
|
|
|
[CommandOption("value", 'v', Description =
|
|
"Value to write. Parsed per --type (booleans accept true/false/1/0).",
|
|
IsRequired = true)]
|
|
public string Value { get; init; } = default!;
|
|
|
|
public override async ValueTask ExecuteAsync(IConsole console)
|
|
{
|
|
ConfigureLogging();
|
|
var ct = console.RegisterCancellationHandler();
|
|
|
|
if (DataType == TwinCATDataType.Structure)
|
|
throw new CliFx.Exceptions.CommandException(
|
|
"Structure (UDT) writes need an explicit member layout — drop to the driver's " +
|
|
"config JSON for those. The CLI covers atomic types only.");
|
|
|
|
var tagName = ReadCommand.SynthesiseTagName(SymbolPath, DataType);
|
|
var tag = new TwinCATTagDefinition(
|
|
Name: tagName,
|
|
DeviceHostAddress: Gateway,
|
|
SymbolPath: SymbolPath,
|
|
DataType: DataType,
|
|
Writable: true);
|
|
var options = BuildOptions([tag]);
|
|
|
|
var parsed = ParseValue(Value, DataType);
|
|
|
|
await using var driver = new TwinCATDriver(options, DriverInstanceId);
|
|
try
|
|
{
|
|
await driver.InitializeAsync("{}", ct);
|
|
var results = await driver.WriteAsync([new WriteRequest(tagName, parsed)], ct);
|
|
await console.Output.WriteLineAsync(SnapshotFormatter.FormatWrite(SymbolPath, results[0]));
|
|
}
|
|
finally
|
|
{
|
|
await driver.ShutdownAsync(CancellationToken.None);
|
|
}
|
|
}
|
|
|
|
/// <summary>Parse <c>--value</c> per <see cref="TwinCATDataType"/>, invariant culture.</summary>
|
|
internal static object ParseValue(string raw, TwinCATDataType type) => type switch
|
|
{
|
|
TwinCATDataType.Bool => ParseBool(raw),
|
|
TwinCATDataType.SInt => sbyte.Parse(raw, CultureInfo.InvariantCulture),
|
|
TwinCATDataType.USInt => byte.Parse(raw, CultureInfo.InvariantCulture),
|
|
TwinCATDataType.Int => short.Parse(raw, CultureInfo.InvariantCulture),
|
|
TwinCATDataType.UInt => ushort.Parse(raw, CultureInfo.InvariantCulture),
|
|
TwinCATDataType.DInt => int.Parse(raw, CultureInfo.InvariantCulture),
|
|
TwinCATDataType.UDInt => uint.Parse(raw, CultureInfo.InvariantCulture),
|
|
TwinCATDataType.LInt => long.Parse(raw, CultureInfo.InvariantCulture),
|
|
TwinCATDataType.ULInt => ulong.Parse(raw, CultureInfo.InvariantCulture),
|
|
TwinCATDataType.Real => float.Parse(raw, CultureInfo.InvariantCulture),
|
|
TwinCATDataType.LReal => double.Parse(raw, CultureInfo.InvariantCulture),
|
|
TwinCATDataType.String or TwinCATDataType.WString => raw,
|
|
// IEC 61131-3 time/date types are stored as UDINT on the wire — accept a numeric raw
|
|
// value + let the caller handle the encoding semantics.
|
|
TwinCATDataType.Time or TwinCATDataType.Date
|
|
or TwinCATDataType.DateTime or TwinCATDataType.TimeOfDay
|
|
=> uint.Parse(raw, CultureInfo.InvariantCulture),
|
|
_ => throw new CliFx.Exceptions.CommandException($"Unsupported DataType '{type}' for write."),
|
|
};
|
|
|
|
private static bool ParseBool(string raw) => raw.Trim().ToLowerInvariant() switch
|
|
{
|
|
"1" or "true" or "on" or "yes" => true,
|
|
"0" or "false" or "off" or "no" => false,
|
|
_ => throw new CliFx.Exceptions.CommandException(
|
|
$"Boolean value '{raw}' is not recognised. Use true/false, 1/0, on/off, or yes/no."),
|
|
};
|
|
}
|