mxaccesscli: read/write/subscribe System Platform tags via MxAccess

New tool wrapping ArchestrA.MxAccess.LMXProxyServerClass (the same COM
proxy aaObjectViewer / WindowViewer use) as a CliFx CLI for LLM-driven
debugging.

Commands:
- mxa info      — loaded MxAccess assembly identity, supported value
                   types, MxStatusCategory enum.
- mxa read      — fetch one or more tag values; subscribes briefly,
                   captures first OnDataChange per tag, tears down.
- mxa write     — write a value with optional --type coercion; advises
                   first to resolve the attribute type, then waits for
                   OnWriteComplete with a per-call timeout.
- mxa subscribe — stream OnDataChange events for --seconds; JSON Lines
                   under --llm-json for piped agent consumption.
- mxa diag      — minimal smoke test on a private STA thread; bypasses
                   the CliFx pipeline for diagnosing apartment / pump
                   issues.

Implementation notes documented in docs/api-notes.md (reverse-engineered
because AVEVA does not publish a single canonical MxAccess reference):

- Net48 / x86 / [STAThread] are non-negotiable. The CLI runs the entire
  CliFx pipeline on a dedicated STA thread.
- COM events are dispatched as Win32 messages; AutoResetEvent.WaitOne
  alone does not pump them on this configuration. MxSession.WaitForUpdate
  loops Application.DoEvents() + drain + Sleep(20ms) instead.
- Write requires the target attribute's type to be resolved first.
  WriteCommand advises and waits for the initial OnDataChange before
  calling LMXProxyServerClass.Write to avoid ArgumentException
  "Value does not fall within the expected range".
- Errors carry the full MXSTATUS_PROXY[] from MxAccess (Success,
  Category, DetectedBy, Detail) so an agent can tell exactly which
  layer rejected a request.

Verified against the live ZB galaxy with a writeable tag identified
via grdb (TestChildObject.TestInt, mx_attribute_category=10):
  read:      99 (q=192, MxCategoryOk)
  write 7:   round-tripped — read returned 7 — written back to 99
  write str: TestChildObject.TestString round-tripped a timestamp
  subscribe: captured initial value plus subsequent change from a
             separate process

The vendored ArchestrA.MxAccess.dll is gitignored — it is copied from
C:\Program Files (x86)\ArchestrA\Framework\Bin\ on any System Platform
install per the README.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-03 20:02:51 -04:00
parent 5a78ec5a76
commit ab202a1fa1
20 changed files with 1466 additions and 0 deletions
@@ -0,0 +1,144 @@
using System;
using System.Threading.Tasks;
using CliFx;
using CliFx.Attributes;
using CliFx.Exceptions;
using CliFx.Infrastructure;
using MxAccess.Cli.Mx;
using MxAccess.Cli.Output;
namespace MxAccess.Cli.Commands
{
[Command("write", Description = "Write a value to a tag and wait for OnWriteComplete.")]
public sealed class WriteCommand : ICommand
{
[CommandParameter(0, Name = "tag", Description = "Tag reference to write to.")]
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; }
[CommandOption("type", Description = "Force the .NET type used for the value: 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).")]
public double TimeoutSeconds { get; init; } = 5.0;
[CommandOption("user-id", Description = "Authenticated user id passed to Write(). 0 = unauthenticated.")]
public int UserId { get; init; }
[CommandOption("client", Description = "MxAccess client name. Default 'mxa'.")]
public string ClientName { get; init; } = "mxa";
[CommandOption("llm-json", Description = "Emit the JSON envelope instead of human-readable status.")]
public bool LlmJson { get; init; }
public ValueTask ExecuteAsync(IConsole console)
{
if (string.IsNullOrWhiteSpace(Tag))
throw new CommandException("Tag reference is required.", 2);
if (RawValue == null)
throw new CommandException("Value is required.", 2);
if (TimeoutSeconds <= 0)
throw new CommandException("--timeout must be positive.", 2);
var coerced = ValueCoercion.Coerce(RawValue, TypeHint);
var query = new
{
command = "write",
tag = Tag,
value = coerced,
type = string.IsNullOrEmpty(TypeHint) ? coerced.GetType().Name : TypeHint,
timeout_s = TimeoutSeconds,
user_id = UserId,
client = ClientName,
};
using var session = new MxSession(ClientName);
MxItem item = null;
try
{
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".
item.Advise();
var resolveTimeout = TimeSpan.FromSeconds(TimeoutSeconds);
if (!session.WaitForUpdate(
u => u.Kind == MxUpdateKind.DataChange && u.ItemHandle == item.Handle,
resolveTimeout, out _))
{
EmitFailure(console, query, "timeout-resolving-type");
Environment.ExitCode = 1;
return default;
}
item.Write(coerced, UserId);
var got = session.WaitForUpdate(
u => u.Kind == MxUpdateKind.WriteComplete && u.ItemHandle == item.Handle,
TimeSpan.FromSeconds(TimeoutSeconds),
out var ack);
bool ok;
object[] results;
if (!got)
{
ok = false;
results = new object[] { new { tag = Tag, ok = false, error = "timeout", statuses = Array.Empty<MxStatusInfo>() } };
}
else
{
ok = ack.IsOk;
results = new object[]
{
new
{
tag = Tag,
ok = ack.IsOk,
error = ack.IsOk ? null : "write-failed",
statuses = ack.Statuses,
}
};
}
if (LlmJson)
{
Envelope.Write(console, query, ok, results);
}
else if (ok)
{
console.Output.WriteLine($"[OK ] write {Tag} = {coerced}");
}
else
{
var err = (string)((dynamic)results[0]).error ?? "unknown";
console.Error.WriteLine($"[ERR] write {Tag} = {coerced}: {err}");
}
if (!ok) Environment.ExitCode = 1;
}
finally
{
item?.Dispose();
}
return default;
}
private void EmitFailure(IConsole console, object query, string error)
{
if (LlmJson)
{
Envelope.Write(console, query, ok: false,
results: new object[] { new { tag = Tag, ok = false, error, statuses = Array.Empty<MxStatusInfo>() } });
}
else
{
console.Error.WriteLine($"[ERR] write {Tag}: {error}");
}
}
}
}