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,98 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;
using ArchestrA.MxAccess;
using CliFx;
using CliFx.Attributes;
using CliFx.Infrastructure;
namespace MxAccess.Cli.Commands
{
/// Minimal MxAccess smoke test — bypasses MxSession, MxItem and the
/// SyncContext shenanigans. Spins a fresh STA thread, registers, advises
/// one tag, and pumps the message loop with Application.DoEvents until
/// either an event arrives or the timeout fires. Used to isolate whether
/// COM event dispatch is reaching this process at all.
[Command("diag", Description = "Diagnostic: minimal MxAccess smoke test on a private STA thread.")]
public sealed class DiagCommand : ICommand
{
[CommandParameter(0, Name = "tag", Description = "Tag reference to probe.")]
public string Tag { get; init; }
[CommandOption("seconds", 's', Description = "How long to pump the message loop. Default 5.")]
public double Seconds { get; init; } = 5.0;
public ValueTask ExecuteAsync(IConsole console)
{
var done = new ManualResetEventSlim(false);
string result = null;
var t = new Thread(() =>
{
try
{
var proxy = new LMXProxyServerClass();
int eventCount = 0;
LMXProxyServer __ = null; // keep delegate alive
proxy.OnDataChange += (int hSrv, int hItem, object value, int quality, object ts, ref MXSTATUS_PROXY[] vars) =>
{
eventCount++;
Console.WriteLine($" [DataChange] hItem={hItem} value={value} quality={quality}");
if (vars != null)
foreach (var v in vars)
Console.WriteLine($" status: cat={v.category} success={v.success} detectedBy={v.detectedBy} detail={v.detail}");
};
proxy.OperationComplete += (int hSrv, int hItem, ref MXSTATUS_PROXY[] vars) =>
{
Console.WriteLine($" [OperationComplete] hItem={hItem}");
if (vars != null)
foreach (var v in vars)
Console.WriteLine($" status: cat={v.category} success={v.success} detectedBy={v.detectedBy} detail={v.detail}");
};
int hSrv = proxy.Register("mxa-diag");
Console.WriteLine($"Register hServer = {hSrv}");
int hItem = proxy.AddItem(hSrv, Tag);
Console.WriteLine($"AddItem hItem = {hItem}");
proxy.Advise(hSrv, hItem);
Console.WriteLine($"Advise sent.");
// Pump WinForms-style for the duration. Application.DoEvents
// empties COM/window messages on each iteration.
var deadline = DateTime.UtcNow.AddSeconds(Seconds);
while (DateTime.UtcNow < deadline)
{
Application.DoEvents();
Thread.Sleep(50);
}
Console.WriteLine($"Done. Total events: {eventCount}");
proxy.UnAdvise(hSrv, hItem);
proxy.RemoveItem(hSrv, hItem);
proxy.Unregister(hSrv);
result = $"events={eventCount}";
}
catch (Exception ex)
{
result = $"EXCEPTION: {ex.GetType().Name}: {ex.Message}";
Console.Error.WriteLine(result);
}
finally
{
done.Set();
}
});
t.SetApartmentState(ApartmentState.STA);
t.IsBackground = false;
t.Start();
done.Wait();
t.Join();
console.Output.WriteLine($"diag result: {result}");
return default;
}
}
}
@@ -0,0 +1,30 @@
using System;
using System.Threading.Tasks;
using CliFx;
using CliFx.Attributes;
using CliFx.Infrastructure;
namespace MxAccess.Cli.Commands
{
[Command("info", Description = "Print runtime info: loaded MxAccess assembly, supported value types, status enum names.")]
public sealed class InfoCommand : ICommand
{
public ValueTask ExecuteAsync(IConsole console)
{
var asm = typeof(ArchestrA.MxAccess.LMXProxyServerClass).Assembly;
console.Output.WriteLine($"MxAccess assembly : {asm.GetName().FullName}");
console.Output.WriteLine($" location : {asm.Location}");
console.Output.WriteLine();
console.Output.WriteLine("Supported --type values for write:");
console.Output.WriteLine(" bool, byte, short, int (int32), long (int64),");
console.Output.WriteLine(" float (single), double, string, datetime");
console.Output.WriteLine();
console.Output.WriteLine("MxStatusCategory values:");
foreach (var n in Enum.GetNames(typeof(ArchestrA.MxAccess.MxStatusCategory)))
console.Output.WriteLine(" " + n);
console.Output.WriteLine();
console.Output.WriteLine("LLM-JSON envelope shape: { query, ok, results: [...] }");
return default;
}
}
}
@@ -0,0 +1,145 @@
using System;
using System.Collections.Generic;
using System.Linq;
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("read", Description = "Read one or more tag values. Subscribes briefly, captures the first OnDataChange per tag, then tears down.")]
public sealed class ReadCommand : ICommand
{
[CommandParameter(0, Name = "tags", Description = "One or more tag references (e.g. 'TestMachine_001.Speed').")]
public IReadOnlyList<string> Tags { get; init; }
[CommandOption("timeout", 't', Description = "Per-tag timeout in seconds while waiting for the first value. Default 5.")]
public double TimeoutSeconds { get; init; } = 5.0;
[CommandOption("client", Description = "MxAccess client name passed to Register(). Default 'mxa'.")]
public string ClientName { get; init; } = "mxa";
[CommandOption("llm-json", Description = "Emit the JSON envelope { query, ok, results } instead of human-readable lines.")]
public bool LlmJson { get; init; }
public ValueTask ExecuteAsync(IConsole console)
{
if (Tags == null || Tags.Count == 0)
throw new CommandException("At least one tag reference is required.", 2);
if (TimeoutSeconds <= 0)
throw new CommandException("--timeout must be positive.", 2);
var query = new
{
command = "read",
tags = Tags.ToArray(),
timeout_s = TimeoutSeconds,
client = ClientName,
};
var results = new List<object>();
using var session = new MxSession(ClientName);
// Add and advise everything up front so notifications can begin streaming
// while we drain. AddItem is sync; Advise is async (item handle becomes
// valid for the OnDataChange dispatch on the proxy callback path).
var items = new List<MxItem>();
try
{
foreach (var t in Tags)
{
var item = session.AddItem(t);
item.Advise();
items.Add(item);
}
var pending = new HashSet<int>(items.Select(i => i.Handle));
var captured = new Dictionary<int, MxUpdate>();
var deadline = DateTime.UtcNow.AddSeconds(TimeoutSeconds);
while (pending.Count > 0)
{
var remaining = deadline - DateTime.UtcNow;
if (remaining <= TimeSpan.Zero) break;
if (!session.WaitForUpdate(
u => u.Kind == MxUpdateKind.DataChange && pending.Contains(u.ItemHandle),
remaining,
out var update))
break;
captured[update.ItemHandle] = update;
pending.Remove(update.ItemHandle);
}
foreach (var item in items)
{
if (captured.TryGetValue(item.Handle, out var u))
{
results.Add(BuildResult(item.Reference, u, timedOut: false));
}
else
{
results.Add(BuildResult(item.Reference, null, timedOut: true));
}
}
bool overallOk = results.Cast<dynamic>().All(r => (bool)r.ok);
if (LlmJson)
Envelope.Write(console, query, overallOk, results);
else
WriteHuman(console, results);
}
finally
{
foreach (var item in items) item.Dispose();
}
return default;
}
private static object BuildResult(string reference, MxUpdate u, bool timedOut)
{
if (timedOut)
{
return new
{
tag = reference,
ok = false,
error = "timeout",
value = (object)null,
quality = (int?)null,
timestamp = (DateTime?)null,
statuses = Array.Empty<MxStatusInfo>(),
};
}
return new
{
tag = reference,
ok = u.IsOk,
value = u.Value,
quality = u.Quality,
timestamp = u.Timestamp,
statuses = u.Statuses,
};
}
private static void WriteHuman(IConsole console, List<object> results)
{
foreach (dynamic r in results)
{
if (!(bool)r.ok)
{
console.Output.WriteLine($"[ERR] {r.tag}: {(string)(r.error ?? "bad-status")}");
continue;
}
var ts = r.timestamp == null ? "" : ((DateTime)r.timestamp).ToString("yyyy-MM-dd HH:mm:ss.fff");
console.Output.WriteLine($"[OK ] {r.tag} = {r.value} (q={r.quality}, t={ts})");
}
}
}
}
@@ -0,0 +1,108 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using CliFx;
using CliFx.Attributes;
using CliFx.Exceptions;
using CliFx.Infrastructure;
using MxAccess.Cli.Mx;
using MxAccess.Cli.Output;
using Newtonsoft.Json;
namespace MxAccess.Cli.Commands
{
[Command("subscribe", Description = "Subscribe to one or more tags and stream OnDataChange events for a duration.")]
public sealed class SubscribeCommand : ICommand
{
[CommandParameter(0, Name = "tags", Description = "One or more tag references.")]
public IReadOnlyList<string> Tags { get; init; }
[CommandOption("seconds", 's', Description = "How long to keep the subscription open, in seconds. Default 10.")]
public double Seconds { get; init; } = 10.0;
[CommandOption("max", Description = "Hard cap on emitted events. Default 1000.")]
public int Max { get; init; } = 1000;
[CommandOption("client", Description = "MxAccess client name. Default 'mxa'.")]
public string ClientName { get; init; } = "mxa";
[CommandOption("llm-json", Description = "Emit a JSON Lines stream of events (one JSON object per line) instead of human-readable lines.")]
public bool LlmJson { get; init; }
public ValueTask ExecuteAsync(IConsole console)
{
if (Tags == null || Tags.Count == 0)
throw new CommandException("At least one tag reference is required.", 2);
if (Seconds <= 0)
throw new CommandException("--seconds must be positive.", 2);
var deadline = DateTime.UtcNow.AddSeconds(Seconds);
var emitted = 0;
using var session = new MxSession(ClientName);
var items = new List<MxItem>();
try
{
foreach (var t in Tags)
{
var item = session.AddItem(t);
item.Advise();
items.Add(item);
}
if (!LlmJson)
console.Output.WriteLine($"[INFO] Subscribed to {items.Count} tag(s). Streaming for {Seconds:F1}s. Ctrl-C to stop early.");
// Note: the first OnDataChange arrives ~3-8s after Advise() while
// LMX resolves the reference and binds to the engine. Plan windows
// accordingly — short --seconds values may miss the initial value.
while (DateTime.UtcNow < deadline && emitted < Max)
{
var remaining = deadline - DateTime.UtcNow;
if (remaining <= TimeSpan.Zero) break;
// Re-use the same wait primitive that read uses. Match every
// DataChange event so the loop emits each one as it arrives.
if (!session.WaitForUpdate(
u => u.Kind == MxUpdateKind.DataChange,
remaining, out var u))
break;
EmitOne(console, u);
emitted++;
}
if (!LlmJson)
console.Output.WriteLine($"[INFO] {emitted} event(s) emitted; subscription closed.");
}
finally
{
foreach (var item in items) item.Dispose();
}
return default;
}
private void EmitOne(IConsole console, MxUpdate u)
{
if (LlmJson)
{
var obj = new
{
tag = u.ItemReference,
ok = u.IsOk,
value = u.Value,
quality = u.Quality,
timestamp = u.Timestamp,
statuses = u.Statuses,
};
console.Output.WriteLine(JsonConvert.SerializeObject(obj, Formatting.None));
}
else
{
var ts = u.Timestamp.HasValue ? u.Timestamp.Value.ToString("HH:mm:ss.fff") : "??:??:??.???";
var flag = u.IsOk ? "OK " : "ERR";
console.Output.WriteLine($"[{ts}] [{flag}] {u.ItemReference} = {u.Value} (q={u.Quality})");
}
}
}
}
@@ -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}");
}
}
}
}
@@ -0,0 +1,5 @@
// Polyfill so C# 9.0 `init` accessors compile on net48.
namespace System.Runtime.CompilerServices
{
internal static class IsExternalInit { }
}
+58
View File
@@ -0,0 +1,58 @@
using System;
using ArchestrA.MxAccess;
namespace MxAccess.Cli.Mx
{
/// One AddItem-handle. Owns Advise/UnAdvise pairing so a Dispose tears
/// down the subscription cleanly even if the caller forgets.
public sealed class MxItem : IDisposable
{
private readonly MxSession _session;
private readonly LMXProxyServerClass _proxy;
private readonly int _hServer;
private bool _advised;
private bool _disposed;
public int Handle { get; }
public string Reference { get; }
internal MxItem(MxSession session, LMXProxyServerClass proxy, int hServer, int hItem, string reference)
{
_session = session;
_proxy = proxy;
_hServer = hServer;
Handle = hItem;
Reference = reference;
}
public void Advise()
{
if (_advised) return;
_proxy.Advise(_hServer, Handle);
_advised = true;
}
public void UnAdvise()
{
if (!_advised) return;
try { _proxy.UnAdvise(_hServer, Handle); } catch { /* best effort */ }
_advised = false;
}
/// `Write` blocks neither the caller nor the proxy — it queues a write and
/// returns. Use MxSession.WaitForUpdate() to await OnWriteComplete.
/// `userId = 0` means "unauthenticated"; OK for simple writes when galaxy
/// security allows it.
public void Write(object value, int userId = 0) =>
_proxy.Write(_hServer, Handle, value, userId);
public void Dispose()
{
if (_disposed) return;
_disposed = true;
try { UnAdvise(); } catch { }
try { _proxy.RemoveItem(_hServer, Handle); } catch { }
_session.RemoveItem(Handle);
}
}
}
@@ -0,0 +1,167 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Threading;
using System.Windows.Forms;
using ArchestrA.MxAccess;
namespace MxAccess.Cli.Mx
{
/// Wraps the MxAccess COM proxy with caller-friendly primitives.
///
/// MxAccess events are dispatched as COM messages on the apartment that
/// called Register. Pure Monitor / AutoResetEvent waits do *not* drain
/// those messages reliably even on STA, so MxSession exposes a
/// `WaitForUpdate` that polls Application.DoEvents() instead. This is the
/// pattern Object Viewer / WindowViewer / aaTagViewer all use under the
/// hood — see docs/api-notes.md "Threading model".
public sealed class MxSession : IDisposable
{
private readonly LMXProxyServerClass _proxy;
private readonly int _hServer;
private readonly object _lock = new object();
private readonly Dictionary<int, MxItem> _itemsByHandle = new Dictionary<int, MxItem>();
private readonly ConcurrentQueue<MxUpdate> _updates = new ConcurrentQueue<MxUpdate>();
private bool _disposed;
public MxSession(string clientName)
{
_proxy = new LMXProxyServerClass();
_proxy.OnDataChange += OnDataChange;
_proxy.OnWriteComplete += OnWriteComplete;
_proxy.OperationComplete += OnOperationComplete;
_hServer = _proxy.Register(string.IsNullOrWhiteSpace(clientName) ? "mxa" : clientName);
}
public int ServerHandle => _hServer;
public MxItem AddItem(string itemRef)
{
if (string.IsNullOrWhiteSpace(itemRef))
throw new ArgumentException("Item reference must be non-empty.", nameof(itemRef));
var hItem = _proxy.AddItem(_hServer, itemRef);
var item = new MxItem(this, _proxy, _hServer, hItem, itemRef);
lock (_lock) _itemsByHandle[hItem] = item;
return item;
}
/// Pump COM messages while watching for an update that matches the predicate.
/// Returns true when one is captured, false on timeout.
public bool WaitForUpdate(Predicate<MxUpdate> match, TimeSpan timeout, out MxUpdate captured)
{
captured = null;
var deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
Application.DoEvents();
while (_updates.TryDequeue(out var u))
{
if (match(u)) { captured = u; return true; }
}
Thread.Sleep(20);
}
// One last drain after the deadline so we don't miss an event that arrived
// between the final Sleep and the loop exit.
Application.DoEvents();
while (_updates.TryDequeue(out var u))
{
if (match(u)) { captured = u; return true; }
}
return false;
}
/// Drain all pending updates without blocking. Caller must call PumpOnce()
/// in their own loop to keep the COM message queue moving.
public IEnumerable<MxUpdate> DrainUpdates()
{
while (_updates.TryDequeue(out var u)) yield return u;
}
/// Pump COM messages once. Used by streaming subscribers between drains.
public void PumpOnce(TimeSpan slice)
{
Application.DoEvents();
if (slice > TimeSpan.Zero) Thread.Sleep(slice);
}
internal void RemoveItem(int hItem)
{
lock (_lock) _itemsByHandle.Remove(hItem);
}
// ---- Event plumbing ----
private void OnDataChange(int hServer, int hItem, object value, int quality, object timestamp,
ref MXSTATUS_PROXY[] vars)
{
string itemRef;
lock (_lock) itemRef = _itemsByHandle.TryGetValue(hItem, out var it) ? it.Reference : null;
_updates.Enqueue(new MxUpdate
{
Kind = MxUpdateKind.DataChange,
ItemHandle = hItem,
ItemReference = itemRef,
Value = value,
Quality = quality,
Timestamp = TryFiletimeToDateTime(timestamp),
Statuses = MxStatusInfo.From(vars),
});
}
private void OnWriteComplete(int hServer, int hItem, ref MXSTATUS_PROXY[] vars)
{
string itemRef;
lock (_lock) itemRef = _itemsByHandle.TryGetValue(hItem, out var it) ? it.Reference : null;
_updates.Enqueue(new MxUpdate
{
Kind = MxUpdateKind.WriteComplete,
ItemHandle = hItem,
ItemReference = itemRef,
Statuses = MxStatusInfo.From(vars),
});
}
private void OnOperationComplete(int hServer, int hItem, ref MXSTATUS_PROXY[] vars)
{
string itemRef;
lock (_lock) itemRef = _itemsByHandle.TryGetValue(hItem, out var it) ? it.Reference : null;
_updates.Enqueue(new MxUpdate
{
Kind = MxUpdateKind.OperationComplete,
ItemHandle = hItem,
ItemReference = itemRef,
Statuses = MxStatusInfo.From(vars),
});
}
private static DateTime? TryFiletimeToDateTime(object ft)
{
if (ft == null) return null;
try
{
var asLong = Convert.ToInt64(ft);
return DateTime.FromFileTimeUtc(asLong).ToLocalTime();
}
catch { return null; }
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
MxItem[] items;
lock (_lock)
{
items = new MxItem[_itemsByHandle.Count];
_itemsByHandle.Values.CopyTo(items, 0);
_itemsByHandle.Clear();
}
foreach (var it in items)
{
try { it.Dispose(); } catch { }
}
try { _proxy.Unregister(_hServer); } catch { }
}
}
}
@@ -0,0 +1,66 @@
using System;
using ArchestrA.MxAccess;
namespace MxAccess.Cli.Mx
{
public enum MxUpdateKind
{
DataChange,
WriteComplete,
OperationComplete,
}
public sealed class MxUpdate
{
public MxUpdateKind Kind { get; init; }
public int ItemHandle { get; init; }
public string ItemReference { get; init; }
public object Value { get; init; }
public int Quality { get; init; }
public DateTime? Timestamp { get; init; }
public MxStatusInfo[] Statuses { get; init; }
public bool IsOk
{
get
{
if (Statuses == null || Statuses.Length == 0) return true;
foreach (var s in Statuses)
{
if (s.Category != MxStatusCategory.MxCategoryOk &&
s.Category != MxStatusCategory.MxCategoryPending)
return false;
}
return true;
}
}
}
public sealed class MxStatusInfo
{
public short Success { get; init; }
public MxStatusCategory Category { get; init; }
public MxStatusSource DetectedBy { get; init; }
public short Detail { get; init; }
public static MxStatusInfo[] From(MXSTATUS_PROXY[] raw)
{
if (raw == null) return Array.Empty<MxStatusInfo>();
var output = new MxStatusInfo[raw.Length];
for (int i = 0; i < raw.Length; i++)
{
output[i] = new MxStatusInfo
{
Success = raw[i].success,
Category = raw[i].category,
DetectedBy = raw[i].detectedBy,
Detail = raw[i].detail,
};
}
return output;
}
public override string ToString() =>
$"{Category} (success={Success}, detail={Detail}, detectedBy={DetectedBy})";
}
}
@@ -0,0 +1,66 @@
using System;
using System.Globalization;
namespace MxAccess.Cli.Mx
{
/// MxAccess's Write() takes an object and the LMX proxy figures out the
/// type by looking at the destination attribute. The CLI accepts strings
/// and either trusts the proxy (default) or coerces to a caller-specified
/// .NET type up-front so the LMX side gets exactly what we mean.
public static class ValueCoercion
{
public static object Coerce(string raw, string typeHint)
{
if (raw == null) throw new ArgumentNullException(nameof(raw));
if (string.IsNullOrEmpty(typeHint))
return InferAndCoerce(raw);
switch (typeHint.Trim().ToLowerInvariant())
{
case "bool": return ParseBool(raw);
case "byte": return byte.Parse(raw, CultureInfo.InvariantCulture);
case "short": return short.Parse(raw, CultureInfo.InvariantCulture);
case "int":
case "int32": return int.Parse(raw, CultureInfo.InvariantCulture);
case "long":
case "int64": return long.Parse(raw, CultureInfo.InvariantCulture);
case "float":
case "single": return float.Parse(raw, CultureInfo.InvariantCulture);
case "double": return double.Parse(raw, CultureInfo.InvariantCulture);
case "string": return raw;
case "time":
case "datetime":
return DateTime.Parse(raw, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal);
default:
throw new ArgumentException(
$"Unknown --type '{typeHint}'. Supported: bool, byte, short, int, long, float, double, string, datetime.");
}
}
private static object InferAndCoerce(string raw)
{
if (ParseBool(raw, out var b)) return b;
if (int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var i)) return i;
if (long.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var l)) return l;
if (double.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out var d)) return d;
return raw;
}
private static bool ParseBool(string raw, out bool result)
{
switch (raw.Trim().ToLowerInvariant())
{
case "true": case "1": case "on": case "yes": result = true; return true;
case "false": case "0": case "off": case "no": result = false; return true;
default: result = false; return false;
}
}
private static bool ParseBool(string raw)
{
if (ParseBool(raw, out var b)) return b;
throw new ArgumentException($"Cannot parse '{raw}' as bool. Use true/false, 1/0, on/off, yes/no.");
}
}
}
@@ -0,0 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net48</TargetFramework>
<Platforms>x86</Platforms>
<PlatformTarget>x86</PlatformTarget>
<Prefer32Bit>true</Prefer32Bit>
<RootNamespace>MxAccess.Cli</RootNamespace>
<AssemblyName>mxa</AssemblyName>
<LangVersion>9.0</LangVersion>
<Nullable>disable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CliFx" Version="2.3.5" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
<ItemGroup>
<!-- Required for `dynamic` on net48 SDK-style projects. -->
<Reference Include="Microsoft.CSharp" />
<!-- Application.DoEvents() lives in System.Windows.Forms. -->
<Reference Include="System.Windows.Forms" />
</ItemGroup>
<ItemGroup>
<Reference Include="ArchestrA.MxAccess">
<HintPath>..\..\lib\ArchestrA.MxAccess.dll</HintPath>
<Private>true</Private>
<EmbedInteropTypes>false</EmbedInteropTypes>
</Reference>
</ItemGroup>
</Project>
@@ -0,0 +1,40 @@
using System.Collections.Generic;
using CliFx.Infrastructure;
using Newtonsoft.Json;
namespace MxAccess.Cli.Output
{
/// Single shape every command emits under --llm-json:
/// { "query": {...}, "ok": true|false, "results": [ {...}, ... ] }
/// Errors keep the same shape so an agent never has to special-case the parser.
public static class Envelope
{
private static readonly JsonSerializerSettings Settings = new JsonSerializerSettings
{
NullValueHandling = NullValueHandling.Include,
Formatting = Formatting.Indented,
};
public static void Write(IConsole console, object query, bool ok, IEnumerable<object> results)
{
var payload = new
{
query,
ok,
results,
};
console.Output.WriteLine(JsonConvert.SerializeObject(payload, Settings));
}
public static void WriteError(IConsole console, object query, string error)
{
var payload = new
{
query,
ok = false,
error,
};
console.Output.WriteLine(JsonConvert.SerializeObject(payload, Settings));
}
}
}
+39
View File
@@ -0,0 +1,39 @@
using System;
using System.Threading;
using CliFx;
namespace MxAccess.Cli
{
public static class Program
{
// The whole CLI runs on a dedicated STA thread. The [STAThread] attribute
// on Main is necessary but not sufficient: CliFx awaits ICommand.ExecuteAsync,
// and any thread switch off the COM apartment puts our LMXProxyServer events
// out of reach. A dedicated thread we own and join eliminates that risk.
//
// The COM event pump itself is driven by Application.DoEvents() inside
// MxSession.WaitForUpdate / PumpOnce — see Mx/MxSession.cs.
[STAThread]
public static int Main(string[] args)
{
int exitCode = 0;
var thread = new Thread(() =>
{
exitCode = new CliApplicationBuilder()
.SetTitle("mxa")
.SetExecutableName("mxa")
.SetDescription("Read / write / subscribe AVEVA System Platform tags via MxAccess.")
.AddCommandsFromThisAssembly()
.Build()
.RunAsync(args)
.GetAwaiter()
.GetResult();
});
thread.SetApartmentState(ApartmentState.STA);
thread.IsBackground = false;
thread.Start();
thread.Join();
return exitCode;
}
}
}