diff --git a/mxaccesscli/docs/usage.md b/mxaccesscli/docs/usage.md index 920a033..67bbd4d 100644 --- a/mxaccesscli/docs/usage.md +++ b/mxaccesscli/docs/usage.md @@ -131,8 +131,8 @@ Verified end-to-end against the live `ZB` galaxy (System Platform 2017 Express, | `MxQualifiedStruct` (14) | – | – | – | Access via dotted member names: `..`. | | `MxInternationalizedString` (15) | ❓ | ❓ | (likely string) | No accessible instance. | | `MxBigString` (16) | ❓ | ❓ | JSON string | No accessible instance. | -| **Array (any type), bulk read** | ✅ | – | JSON array of element type | Reference syntax `.[]` — **empty square brackets**. Verified on `MESReceiver_001.MoveInPartNumbers[]` (String[50]) and `MoveOutWorkOrderNumbers[]`. Returns the entire array as a single value with `MxCategoryOk`. | -| **Array (bare reference)** | ❌ | – | — | The plain `.` (no brackets) returns `MxCategoryCommunicationError, Detail=1003`. Use `[]` instead. | +| **Array (any type), bulk read/write via `[]`** | ✅ | ✅ | JSON array of element type | Reference syntax `.[]` — **empty square brackets**. Read returns the entire array as a single value. Write takes one positional value per element (`mxa write '.[]' v1 v2 v3 ...`). **A bulk write resizes the array to the count provided** (verified: 50 → 25 → 50 round-trip on `MoveInPartNumbers`). | +| **Array (bare reference)** | ❌ | ❌ | — | The plain `.` (no brackets) returns `MxCategoryCommunicationError, Detail=1003`. Always use `[]` for bulk operations. | | **Array element by index** | ✅ | ✅ | scalar of element type | Reference syntax `.[]`. **1-based**, runs from `[1]` to `[NumElements]`. `[0]` is invalid. | Legend: ✅ verified live, ⚠️ wiring present but no live instance to write, ❓ wiring present but no live instance found, ❌ not supported by MxAccess at this layer, – not applicable. @@ -191,6 +191,27 @@ mxa read 'MESReceiver_001.MoveInPartNumbers[2]' --llm-json Indices are **1-based**: `[1]` is the first element, `[NumElements]` is the last. `[0]` is invalid. Single-element reads are also writeable: `mxa write '.[N]' `. +### Whole array write — also via `[]` + +Pass one positional value per element after the tag. The CLI bundles them into a strongly-typed array (`string[]`, `int[]`, `bool[]`, …) before writing. + +```powershell +# Write a 50-element string array +mxa write 'MESReceiver_001.MoveInPartNumbers[]' \ + "" "11111" "" "" "" "" "" "" "" "" \ + "" "" "" "" "" "15" "" "" "" "" \ + "" "" "" "" "" "" "" "" "" "" \ + "" "" "" "" "" "" "" "" "" "" \ + "" "" "" "" "" "" "" "" "" "" + +# Write a typed array +mxa write 'SomeObj.SomeFloats[]' 1.0 2.5 3.14 --type float +``` + +> ⚠️ **A bulk write resizes the array to the count provided.** If the configured `array_dimension` is 50 and you supply 25 values, after the write `mxa read '...[]'` returns **25** elements, not 50. The trailing slots are deallocated, not zero-filled. Always supply the full element count when you want to preserve the array's logical size — fetch the current count via `mxa read '...[]' --llm-json` first, or read it from `array_dimension` in [`../../grdb/queries/attributes.sql`](../../grdb/queries/attributes.sql). +> +> Mixing scalar / array forms is guarded: passing multiple values without `[]` exits 2 with a clear error message. + ### What does *not* work ```powershell @@ -198,14 +219,14 @@ mxa read 'MESReceiver_001.MoveInPartNumbers' # bare ref, no brackets # → MxCategoryCommunicationError, Detail=1003 ``` -The plain reference (no `[]`, no `[N]`) is rejected by the proxy. Always include the brackets — empty for whole-array, indexed for element. +The plain reference (no `[]`, no `[N]`) is rejected by the proxy on both read and write. Always include the brackets — empty for whole-array, indexed for element. ### Discovering array length The CLI doesn't (yet) auto-discover element count. Two ways to find it: -1. Read with `[]` and count the returned values. -2. Query the Galaxy Repository's [`../../grdb/queries/attributes.sql`](../../grdb/queries/attributes.sql) — the `array_dimension` column reports the configured size from the template. +1. Read with `[]` and count the returned values (this is the **runtime** length, which may have been resized by a previous bulk write). +2. Query the Galaxy Repository's [`../../grdb/queries/attributes.sql`](../../grdb/queries/attributes.sql) — the `array_dimension` column reports the **configured** size from the template at deploy time. ## Picking a tag for a smoke test diff --git a/mxaccesscli/src/MxAccess.Cli/Commands/WriteCommand.cs b/mxaccesscli/src/MxAccess.Cli/Commands/WriteCommand.cs index fd0e63e..13ae9bb 100644 --- a/mxaccesscli/src/MxAccess.Cli/Commands/WriteCommand.cs +++ b/mxaccesscli/src/MxAccess.Cli/Commands/WriteCommand.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using CliFx; using CliFx.Attributes; @@ -9,16 +11,16 @@ using MxAccess.Cli.Output; namespace MxAccess.Cli.Commands { - [Command("write", Description = "Write a value to a tag and wait for OnWriteComplete.")] + [Command("write", Description = "Write a scalar or array value to a tag and wait for OnWriteComplete.")] public sealed class WriteCommand : ICommand { - [CommandParameter(0, Name = "tag", Description = "Tag reference to write to.")] + [CommandParameter(0, Name = "tag", Description = "Tag reference to write to. Use '.[]' to write a whole array.")] public string Tag { get; init; } - [CommandParameter(1, Name = "value", Description = "Value to write. Inferred as bool / int / double / string unless --type is set.")] - public string RawValue { get; init; } + [CommandParameter(1, Name = "values", Description = "Value(s) to write. One value for a scalar tag; multiple values for an array tag (the count must match the configured array dimension). Inferred as bool / int / double / string unless --type is set.")] + public IReadOnlyList Values { get; init; } - [CommandOption("type", Description = "Force the .NET type used for the value: bool, byte, short, int, long, float, double, string, datetime.")] + [CommandOption("type", Description = "Force the .NET type used for the value(s): bool, byte, short, int, long, float, double, string, datetime.")] public string TypeHint { get; init; } [CommandOption("timeout", 't', Description = "Seconds to wait for OnWriteComplete (and for the initial OnDataChange resolving the type).")] @@ -37,19 +39,32 @@ namespace MxAccess.Cli.Commands { if (string.IsNullOrWhiteSpace(Tag)) throw new CommandException("Tag reference is required.", 2); - if (RawValue == null) - throw new CommandException("Value is required.", 2); + if (Values == null || Values.Count == 0) + throw new CommandException("At least one value is required.", 2); if (TimeoutSeconds <= 0) throw new CommandException("--timeout must be positive.", 2); - var coerced = ValueCoercion.Coerce(RawValue, TypeHint); + // Decide scalar vs. array. The tag form `.[]` is the + // explicit array reference for whole-array writes (matches the + // read-side bracket convention). Multiple values without `[]` + // is an error: an indexed `[N]` reference takes one value. + bool isArrayWrite = Tag.EndsWith("[]", StringComparison.Ordinal); + if (!isArrayWrite && Values.Count > 1) + throw new CommandException( + "Multiple values supplied for a non-array reference. Use '[]' to write a whole array, or supply a single value for an indexed / scalar tag.", 2); + + object coerced = isArrayWrite + ? ValueCoercion.CoerceArray(Values, TypeHint) + : ValueCoercion.Coerce(Values[0], TypeHint); var query = new { command = "write", tag = Tag, + array = isArrayWrite, value = coerced, type = string.IsNullOrEmpty(TypeHint) ? coerced.GetType().Name : TypeHint, + count = Values.Count, timeout_s = TimeoutSeconds, user_id = UserId, client = ClientName, @@ -61,17 +76,29 @@ namespace MxAccess.Cli.Commands { item = session.AddItem(Tag); - // Advise + wait for first OnDataChange to ensure the proxy has the - // attribute type / data quality resolved. Calling Write before - // resolution returns ArgumentException "Value does not fall within - // the expected range". + // Advise + wait for first OnDataChange to ensure the proxy has + // the attribute type / data quality resolved. Without this the + // first Write() throws ArgumentException "Value does not fall + // within the expected range" because the proxy doesn't yet + // know the destination type. + // + // Caveat: a bare-array reference (no brackets) will return + // MxCategoryCommunicationError, Detail=1003 here — same as on + // a `read` of that form. Tag the user-facing error so the + // failure mode is recognizable. item.Advise(); var resolveTimeout = TimeSpan.FromSeconds(TimeoutSeconds); if (!session.WaitForUpdate( u => u.Kind == MxUpdateKind.DataChange && u.ItemHandle == item.Handle, - resolveTimeout, out _)) + resolveTimeout, out var resolveUpdate)) { - EmitFailure(console, query, "timeout-resolving-type"); + EmitFailure(console, query, "timeout-resolving-type", Array.Empty()); + Environment.ExitCode = 1; + return default; + } + if (!resolveUpdate.IsOk) + { + EmitFailure(console, query, "type-resolution-failed", resolveUpdate.Statuses); Environment.ExitCode = 1; return default; } @@ -111,12 +138,15 @@ namespace MxAccess.Cli.Commands } else if (ok) { - console.Output.WriteLine($"[OK ] write {Tag} = {coerced}"); + var rendered = isArrayWrite + ? $"[{string.Join(", ", Values)}] ({Values.Count} elements)" + : Values[0]; + console.Output.WriteLine($"[OK ] write {Tag} = {rendered}"); } else { var err = (string)((dynamic)results[0]).error ?? "unknown"; - console.Error.WriteLine($"[ERR] write {Tag} = {coerced}: {err}"); + console.Error.WriteLine($"[ERR] write {Tag}: {err}"); } if (!ok) Environment.ExitCode = 1; @@ -128,12 +158,12 @@ namespace MxAccess.Cli.Commands return default; } - private void EmitFailure(IConsole console, object query, string error) + private void EmitFailure(IConsole console, object query, string error, MxStatusInfo[] statuses) { if (LlmJson) { Envelope.Write(console, query, ok: false, - results: new object[] { new { tag = Tag, ok = false, error, statuses = Array.Empty() } }); + results: new object[] { new { tag = Tag, ok = false, error, statuses } }); } else { diff --git a/mxaccesscli/src/MxAccess.Cli/Mx/ValueCoercion.cs b/mxaccesscli/src/MxAccess.Cli/Mx/ValueCoercion.cs index e5739a5..4ddd3c0 100644 --- a/mxaccesscli/src/MxAccess.Cli/Mx/ValueCoercion.cs +++ b/mxaccesscli/src/MxAccess.Cli/Mx/ValueCoercion.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Globalization; namespace MxAccess.Cli.Mx @@ -38,6 +39,64 @@ namespace MxAccess.Cli.Mx } } + /// Build a strongly-typed array (`bool[]`, `int[]`, `string[]`, …) from + /// a list of raw string values. The CLR marshals strongly-typed arrays + /// to a COM SAFEARRAY of the matching VARTYPE, which is what MxAccess + /// expects for an array attribute write. + public static object CoerceArray(IReadOnlyList values, string typeHint) + { + if (values == null) throw new ArgumentNullException(nameof(values)); + + // Default array element type when --type isn't specified: peek at + // the first value's inferred type and assume the rest match. For + // an empty list, fall back to string[]. + string effectiveHint = typeHint; + if (string.IsNullOrEmpty(effectiveHint)) + { + if (values.Count == 0) effectiveHint = "string"; + else + { + var first = InferAndCoerce(values[0]); + effectiveHint = first switch + { + bool _ => "bool", + int _ => "int", + long _ => "long", + double _ => "double", + _ => "string", + }; + } + } + + switch (effectiveHint.Trim().ToLowerInvariant()) + { + case "bool": return Convert(values, s => ParseBool(s)); + case "byte": return Convert(values, s => byte.Parse(s, CultureInfo.InvariantCulture)); + case "short": return Convert(values, s => short.Parse(s, CultureInfo.InvariantCulture)); + case "int": + case "int32": return Convert(values, s => int.Parse(s, CultureInfo.InvariantCulture)); + case "long": + case "int64": return Convert(values, s => long.Parse(s, CultureInfo.InvariantCulture)); + case "float": + case "single": return Convert(values, s => float.Parse(s, CultureInfo.InvariantCulture)); + case "double": return Convert(values, s => double.Parse(s, CultureInfo.InvariantCulture)); + case "string": return Convert(values, s => s); + case "time": + case "datetime": + return Convert(values, s => DateTime.Parse(s, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal)); + default: + throw new ArgumentException( + $"Unknown --type '{effectiveHint}'. Supported: bool, byte, short, int, long, float, double, string, datetime."); + } + } + + private static T[] Convert(IReadOnlyList raw, Func parse) + { + var arr = new T[raw.Count]; + for (int i = 0; i < raw.Count; i++) arr[i] = parse(raw[i]); + return arr; + } + private static object InferAndCoerce(string raw) { if (ParseBool(raw, out var b)) return b;