From ab202a1fa192c221931b04c85a8ae933206421d8 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 3 May 2026 20:02:51 -0400 Subject: [PATCH] mxaccesscli: read/write/subscribe System Platform tags via MxAccess MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .gitignore | 5 + CLAUDE.md | 2 + mxaccesscli/AGENTS.md | 82 +++++++++ mxaccesscli/MxAccess.Cli.slnx | 5 + mxaccesscli/README.md | 72 ++++++++ mxaccesscli/docs/api-notes.md | 157 ++++++++++++++++ mxaccesscli/docs/usage.md | 142 +++++++++++++++ .../src/MxAccess.Cli/Commands/DiagCommand.cs | 98 ++++++++++ .../src/MxAccess.Cli/Commands/InfoCommand.cs | 30 ++++ .../src/MxAccess.Cli/Commands/ReadCommand.cs | 145 +++++++++++++++ .../MxAccess.Cli/Commands/SubscribeCommand.cs | 108 +++++++++++ .../src/MxAccess.Cli/Commands/WriteCommand.cs | 144 +++++++++++++++ .../src/MxAccess.Cli/IsExternalInit.cs | 5 + mxaccesscli/src/MxAccess.Cli/Mx/MxItem.cs | 58 ++++++ mxaccesscli/src/MxAccess.Cli/Mx/MxSession.cs | 167 ++++++++++++++++++ mxaccesscli/src/MxAccess.Cli/Mx/MxUpdate.cs | 66 +++++++ .../src/MxAccess.Cli/Mx/ValueCoercion.cs | 66 +++++++ .../src/MxAccess.Cli/MxAccess.Cli.csproj | 35 ++++ .../src/MxAccess.Cli/Output/Envelope.cs | 40 +++++ mxaccesscli/src/MxAccess.Cli/Program.cs | 39 ++++ 20 files changed, 1466 insertions(+) create mode 100644 mxaccesscli/AGENTS.md create mode 100644 mxaccesscli/MxAccess.Cli.slnx create mode 100644 mxaccesscli/README.md create mode 100644 mxaccesscli/docs/api-notes.md create mode 100644 mxaccesscli/docs/usage.md create mode 100644 mxaccesscli/src/MxAccess.Cli/Commands/DiagCommand.cs create mode 100644 mxaccesscli/src/MxAccess.Cli/Commands/InfoCommand.cs create mode 100644 mxaccesscli/src/MxAccess.Cli/Commands/ReadCommand.cs create mode 100644 mxaccesscli/src/MxAccess.Cli/Commands/SubscribeCommand.cs create mode 100644 mxaccesscli/src/MxAccess.Cli/Commands/WriteCommand.cs create mode 100644 mxaccesscli/src/MxAccess.Cli/IsExternalInit.cs create mode 100644 mxaccesscli/src/MxAccess.Cli/Mx/MxItem.cs create mode 100644 mxaccesscli/src/MxAccess.Cli/Mx/MxSession.cs create mode 100644 mxaccesscli/src/MxAccess.Cli/Mx/MxUpdate.cs create mode 100644 mxaccesscli/src/MxAccess.Cli/Mx/ValueCoercion.cs create mode 100644 mxaccesscli/src/MxAccess.Cli/MxAccess.Cli.csproj create mode 100644 mxaccesscli/src/MxAccess.Cli/Output/Envelope.cs create mode 100644 mxaccesscli/src/MxAccess.Cli/Program.cs diff --git a/.gitignore b/.gitignore index 0dc92d5..0f7b755 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,8 @@ packages/ # aalogcli/lib/build/ per the README recipe. Keep the build template, # upstream license, and gitkeep so the folder structure is preserved. aalogcli/lib/aaLogReader.dll + +# Vendored copy of System Platform's MxAccess interop assembly. The +# README points users to copy it from C:\Program Files (x86)\ArchestrA\ +# Framework\Bin\ on a System Platform host. Don't redistribute via git. +mxaccesscli/lib/ArchestrA.MxAccess.dll diff --git a/CLAUDE.md b/CLAUDE.md index bb2b874..94382df 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,6 +23,7 @@ When in doubt about where content belongs, default to pushing it deeper. `DOCS-G - [`graccesscli/`](graccesscli/README.md) — `.NET Framework 4.8 / x86` CliFx-based CLI for automating Galaxy configuration through the ArchestrA GRAccess COM interop. - [`grdb/`](grdb/README.md) — SQL/DDL exploration of the Galaxy Repository SQL database (queries, schema, hierarchy/tag-name translation). - [`histdb/`](histdb/README.md) — LLM-oriented reference for AVEVA Historian retrieval (extension tables, `wwXxx` time-domain extensions, retrieval modes/options, alarm-event SQL, REST API). Distilled from the official Historian Retrieval Guide. +- [`mxaccesscli/`](mxaccesscli/README.md) — `.NET Framework 4.8 / x86` CliFx-based CLI for reading, writing, and subscribing to System Platform tags via the **MxAccess** COM proxy (`LMXProxyServerClass`). ## Tool / resource index @@ -33,6 +34,7 @@ When in doubt about where content belongs, default to pushing it deeper. `DOCS-G | Automate Galaxy configuration via GRAccess COM (CLI usage, session daemon, mutations, LLM integration) | [`graccesscli/README.md`](graccesscli/README.md) | | Galaxy Repository SQL — connect, schema, hierarchy queries, contained-name ↔ tag-name translation | [`grdb/README.md`](grdb/README.md) | | AVEVA Historian retrieval — SQL via `INSQL`, `wwXxx` extensions, retrieval modes/options, alarm/event SQL, REST API | [`histdb/README.md`](histdb/README.md) | +| Read / write / subscribe to System Platform tags via MxAccess (timeouts, error categories, JSON envelope) | [`mxaccesscli/README.md`](mxaccesscli/README.md) | ## Maintaining this index diff --git a/mxaccesscli/AGENTS.md b/mxaccesscli/AGENTS.md new file mode 100644 index 0000000..b745794 --- /dev/null +++ b/mxaccesscli/AGENTS.md @@ -0,0 +1,82 @@ +# AGENTS.md + +Guidance for coding agents working in the `mxaccesscli` folder. + +## Project Snapshot + +`mxaccesscli` (assembly name `mxa`) is a `.NET Framework 4.8 / x86` CliFx CLI that wraps `ArchestrA.MxAccess.LMXProxyServerClass` to read, write, and subscribe to AVEVA System Platform tags. Output as human-readable lines or as a stable `{ query, ok, results }` JSON envelope under `--llm-json`. + +For end-to-end usage and examples, read [`docs/usage.md`](docs/usage.md). For the underlying MxAccess COM API surface (assembly is shipped without public documentation), read [`docs/api-notes.md`](docs/api-notes.md). + +## Key Documentation + +All paths relative to this `mxaccesscli/` folder. + +- [`README.md`](README.md) — tool entry point, hard constraints, build instructions. +- [`docs/usage.md`](docs/usage.md) — every command, every option, examples, JSON envelope shape. +- [`docs/api-notes.md`](docs/api-notes.md) — reverse-engineered MxAccess API reference (types, events, threading model, status semantics). + +## Repository Layout + +```text +mxaccesscli/ + MxAccess.Cli.slnx + lib/ArchestrA.MxAccess.dll (copied from System Platform's Framework\Bin) + src/MxAccess.Cli/ + MxAccess.Cli.csproj + Program.cs [STAThread] entry; wires CliFx commands + IsExternalInit.cs net48 polyfill for C# 9 init accessors + Commands/ + ReadCommand.cs `read` — first OnDataChange per tag, timed + WriteCommand.cs `write` — write + OnWriteComplete, timed + SubscribeCommand.cs `subscribe` — stream OnDataChange for N seconds + InfoCommand.cs `info` — assembly identity + value-type catalog + Mx/ + MxSession.cs Register/Unregister + event pump + queue + MxItem.cs AddItem / Advise / UnAdvise / RemoveItem pairing + MxUpdate.cs typed wrapper over OnDataChange / OnWriteComplete + ValueCoercion.cs --type bool|int|double|... → boxed object for Write + Output/ + Envelope.cs { query, ok, results } JSON shape +``` + +## Build And Test + +Run from this `mxaccesscli` folder. + +```powershell +dotnet build src/MxAccess.Cli/MxAccess.Cli.csproj -p:Platform=x86 -c Release +dotnet run --project src/MxAccess.Cli/MxAccess.Cli.csproj -- +``` + +There is no test project. If you add one, mirror `graccesscli`'s pattern: `tests/MxAccess.Cli.Tests/`, `net48`, `x86`, xunit + Shouldly. Pure unit-testing of `MxSession` is hard because `LMXProxyServerClass` is sealed and COM-bound; use a thin `IMxSession` interface if you need test seams. + +## Implementation Rules + +- **Stay on `net48` / `x86` / `[STAThread]`.** MxAccess is a 32-bit COM proxy and events fire on the apartment that called `Register()`. Crossing apartments will silently lose callbacks. +- **Always pair Register / Unregister, AddItem / RemoveItem, Advise / UnAdvise.** `MxSession.Dispose` and `MxItem.Dispose` already do this — leverage `using` blocks. +- **Reads are not synchronous in MxAccess.** Implement "read" as a brief Advise → first OnDataChange → tear down. Document the timeout semantic in user-facing surfaces. +- **Surface `MxStatusCategory` on every error.** A non-Ok / non-Pending category in any element of `MXSTATUS_PROXY[]` means failure. Don't collapse the array — emit it whole in the JSON envelope so an agent can tell *why* something failed. +- **`Write` uses an `int userId`.** Use 0 for unauthenticated operations; for secured attributes call `AuthenticateUser()` first (the CLI does not yet expose this — add it through `WriteCommand` if needed and document it). +- **Don't reuse `LMXProxyServerClass` instances across runs.** Each `MxSession` constructs and disposes one. The proxy is cheap enough that one-shot CLI invocations are fine; for many writes back-to-back, build a session-mode subcommand modeled on `graccesscli`. +- **CliFx command pattern.** Commands implement `ICommand`, decorate with `[Command]`, parameters with `[CommandParameter]`, options with `[CommandOption]`. C# 9 `init` accessors via `IsExternalInit.cs` polyfill; no `required`. +- **JSON envelope contract.** Every `--llm-json` command emits `{ query: {...}, ok: bool, results: [...] }`. `query` echoes the resolved invocation (including coerced value types). `results` is always an array, even for single-tag commands. Adding fields to a `result` is non-breaking; renaming/removing is. + +## Output Contracts + +| Mode | Trigger | Shape | +| --- | --- | --- | +| Human (read/write) | default | One line per tag: `[OK ] = (q=, t=)` or `[ERR] : ` | +| Human (subscribe) | default | One line per event: `[] [OK ] = (q=)` | +| LLM-JSON (read/write) | `--llm-json` | `{ query, ok, results: [ { tag, ok, value?, quality?, timestamp?, statuses, error? } ] }` | +| LLM-JSON (subscribe) | `--llm-json` | **JSON Lines** — one JSON object per OnDataChange event, no envelope wrapper. Allows piped consumption. | + +The subscribe-stream JSON Lines format is intentionally different from the read/write envelope — streams need to be consumable line-by-line. + +## Adding A New Command + +1. Add `Commands/Command.cs` implementing `ICommand`. +2. Construct an `MxSession` in a `using` block; build `MxItem` instances; dispose them before `MxSession`. +3. Use `MxSession.WaitForUpdate(predicate, timeout, out update)` for one-shot waits, or `DrainUpdates()` + `WaitOnce()` in a loop for streams. +4. Add a row in [`README.md`](README.md) and a section in [`docs/usage.md`](docs/usage.md). Do not modify `../CLAUDE.md` — the root index points at the README and that is sufficient. +5. Keep envelope shape `{ query, ok, results }` consistent across read/write commands. diff --git a/mxaccesscli/MxAccess.Cli.slnx b/mxaccesscli/MxAccess.Cli.slnx new file mode 100644 index 0000000..a0052d9 --- /dev/null +++ b/mxaccesscli/MxAccess.Cli.slnx @@ -0,0 +1,5 @@ + + + + + diff --git a/mxaccesscli/README.md b/mxaccesscli/README.md new file mode 100644 index 0000000..de93a0c --- /dev/null +++ b/mxaccesscli/README.md @@ -0,0 +1,72 @@ +# mxaccesscli + +A `.NET Framework 4.8 / x86` CliFx-based CLI for **reading, writing, and subscribing to AVEVA System Platform tags via MxAccess** (`ArchestrA.MxAccess.LMXProxyServerClass`). Output as scannable text or as a stable JSON envelope for LLM consumption. Built-in per-tag and per-call timeouts; structured surface of MxStatus categories on every error. + +## Hard constraints + +- **MxAccess is a 32-bit COM proxy.** The CLI must stay on `net48` / `x86` / `[STAThread]`. Do not retarget to .NET 10 or x64 — `LMXProxyServerClass` is registered for the WoW64 server only. +- **System Platform must be installed locally** so that `ArchestrA.MxAccess` is registered with COM and the LMX runtime is reachable. Without it `Register()` fails with a COM error. +- **MxAccess events fire on the registering apartment.** Calls **must** originate from an STA thread, which is why `Program.Main` is `[STAThread]`. If you call from a non-STA thread, events queue but never get pumped and your timeouts will always fire. +- **Reads are not synchronous in MxAccess.** "Read" is implemented as a brief subscribe → wait for first `OnDataChange` → tear down. If a tag never delivers a `DataChange` (offscan, wrong reference, security denied), the read times out — that's the correct semantic, not a CLI bug. +- **Writes need user identity for secured attributes.** Default `--user-id 0` is "unauthenticated"; secured attributes will reject. Use `--user-id ` after calling `AuthenticateUser()` from another path (or, for a CLI smoke test, target a non-secured `Writeable_USC_*` attribute). + +## Layout + +```text +mxaccesscli/ + README.md this file + AGENTS.md agent rules for working in this folder + MxAccess.Cli.slnx + lib/ + ArchestrA.MxAccess.dll (copied from System Platform's Framework\Bin) + src/MxAccess.Cli/ + MxAccess.Cli.csproj + Program.cs [STAThread] entry point + IsExternalInit.cs net48 polyfill for C# 9 init accessors + Commands/ read, write, subscribe, info + Mx/ MxSession, MxItem, MxUpdate, ValueCoercion + Output/ LLM-JSON envelope writer + docs/ + usage.md command surface, examples, JSON envelope contract + api-notes.md reverse-engineered MxAccess API reference (since AVEVA's + online docs are sparse): types, events, threading, status semantics +``` + +## Resource index — by task + +| Task | Go to | +| --- | --- | +| Agent rules for editing this CLI | [`AGENTS.md`](AGENTS.md) | +| Run the CLI / option reference / examples | [`docs/usage.md`](docs/usage.md) | +| MxAccess API surface, threading model, MxStatus semantics | [`docs/api-notes.md`](docs/api-notes.md) | +| Find a writeable tag in the live galaxy (so smoke tests have a target) | [`../grdb/README.md`](../grdb/README.md) | +| Read tag values via SQL retrieval (an alternative path) | [`../histdb/README.md`](../histdb/README.md) | + +### External documentation + +AVEVA does not publish a single canonical MxAccess reference online. The closest official sources: + +- `https://docs.aveva.com/` — search "MxAccess", "LMXProxyServer", "Object Viewer". Coverage is partial and depends on which product portal you land on. +- AVEVA Knowledge Base (subscriber-only): TID-based articles on `LMX` registration, secured writes, and `WriteSecured2`. +- The shipped `MXAccess32.tlb` type library at `C:\Program Files (x86)\ArchestrA\Framework\Bin\` is the most authoritative source for method signatures. [`docs/api-notes.md`](docs/api-notes.md) summarizes what it exposes. + +## Build & run + +```powershell +dotnet build src/MxAccess.Cli/MxAccess.Cli.csproj -p:Platform=x86 -c Release + +# Read two tags with a 3-second timeout, JSON envelope: +dotnet run --project src/MxAccess.Cli/MxAccess.Cli.csproj -- read TestMachine_001.Speed Reactor1.Level -t 3 --llm-json + +# Write a value with explicit type: +dotnet run --project src/MxAccess.Cli/MxAccess.Cli.csproj -- write TestMachine_001.Setpoint 42.5 --type double + +# Subscribe to a tag for 30 seconds, JSON Lines for streaming: +dotnet run --project src/MxAccess.Cli/MxAccess.Cli.csproj -- subscribe TestMachine_001.Speed -s 30 --llm-json +``` + +The built executable is `bin\x86\Release\net48\mxa.exe`. Drop on `PATH` and use `mxa read ...`. + +## Maintenance + +This README follows the doctrine in [`../DOCS-GUIDE.md`](../DOCS-GUIDE.md). When you add a command, an option, or a new field on the JSON envelope, update [`docs/usage.md`](docs/usage.md) in the same change. Update [`docs/api-notes.md`](docs/api-notes.md) only if the underlying MxAccess assembly changes (different System Platform version, etc.). The root [`../CLAUDE.md`](../CLAUDE.md) carries one row pointing at this README — it should not need to change unless the tool's task surface changes. diff --git a/mxaccesscli/docs/api-notes.md b/mxaccesscli/docs/api-notes.md new file mode 100644 index 0000000..3262b70 --- /dev/null +++ b/mxaccesscli/docs/api-notes.md @@ -0,0 +1,157 @@ +# MxAccess API notes + +Reverse-engineered from the shipped `ArchestrA.MxAccess.dll` (assembly version `3.2.0.0`, file at `C:\Program Files (x86)\ArchestrA\Framework\Bin\ArchestrA.MXAccess.dll`) plus its sibling type libraries `MXAccess.tlb` / `MXAccess20.tlb` / `MXAccess32.tlb`. AVEVA does not publish a single canonical online reference for this assembly, which is why this file exists. + +The assembly is registered in the GAC at `C:\Windows\assembly\GAC_MSIL\ArchestrA.MxAccess\3.2.0.0__23106a86e706d0ae\`. Strong name: `PublicKeyToken=23106a86e706d0ae`. + +## Threading model + +`LMXProxyServerClass` is a COM proxy. **All calls — and the events the proxy fires — run on the apartment that called `Register`.** The CLI's `Program.Main` is `[STAThread]` for this reason; if you call into MxAccess from a non-STA thread you'll observe: + +- `Register()` returns a handle but events never fire. +- `Write()` queues but `OnWriteComplete` never reaches your handler. + +A deeper STA pump is unnecessary for one-shot CLI invocations because CliFx's `ICommand.ExecuteAsync` runs on the entry thread synchronously, and the COM marshaller pumps the calling apartment while waiting on `WaitOne`. + +## Type catalog + +The full public surface from reflection: + +```text +class LMXProxyServerClass -- the entry point (newable) +class LMXProxyServer -- alias / synonym +iface ILMXProxyServer -- v1 method set (Register, AddItem, Advise, Write, WriteSecured, AuthenticateUser) +iface ILMXProxyServer2 -- adds ArchestrAUserToId +iface ILMXProxyServer3 -- adds AddItem2 +iface ILMXProxyServer4 -- adds Write2, WriteSecured2, Suspend, Activate, AdviseSupervisory +iface ILMXProxyServer5 -- adds AddBufferedItem, SetBufferedUpdateInterval + +iface _ILMXProxyServerEvents -- OnDataChange, OnWriteComplete, OperationComplete +iface _ILMXProxyServerEvents2 -- OnBufferedDataChange + +struct MxStatus -- success:int16, category:MxStatusCategory, detectedBy:MxStatusSource, detail:int16 +struct MXSTATUS_PROXY -- same fields; passed to event handlers as an array +enum MxStatusCategory -- 10 values; see `mxa info` +enum MxStatusSource -- 7 values; "who detected this status" +enum MxDataType -- MxNoData / MxBoolean / MxInteger / MxFloat / MxDouble / MxString / MxTime / MxElapsedTime / MxReferenceType / MxStatusType / MxDataTypeEnum / MxSecurityClassificationEnum / MxDataQualityType / MxQualifiedEnum / MxQualifiedStruct / MxInternationalizedString / MxBigString / MxDataTypeEND / MxDataTypeUnknown +``` + +## Lifecycle methods + +```csharp +int Register(string clientName); // returns hServer +void Unregister(int hServer); + +int AddItem(int hServer, string itemRef); // returns hItem +int AddItem2(int hServer, string itemRef, string itemContext); +int AddBufferedItem(int hServer, string itemRef, string itemContext); +void RemoveItem(int hServer, int hItem); + +void Advise(int hServer, int hItem); +void UnAdvise(int hServer, int hItem); +void AdviseSupervisory(int hServer, int hItem); // higher-rate / mxsupv updates +void SetBufferedUpdateInterval(int hServer, int intervalMs); +``` + +`Suspend` / `Activate` toggle update delivery on a per-item basis without losing the AddItem handle. + +## Write methods + +```csharp +void Write (int hServer, int hItem, object value, int userId); +void Write2 (int hServer, int hItem, object value, object timestamp, int userId); +void WriteSecured (int hServer, int hItem, int currentUserId, int verifierUserId, object value); +void WriteSecured2 (int hServer, int hItem, int currentUserId, int verifierUserId, object value, object timestamp); +``` + +`userId = 0` is unauthenticated. For attributes with **Operate**, **Tune**, **Configure**, **View Only**, **Free Access**, **Secured Write**, or **Verified Write** classifications (see [`../../aot/dev-guide/appendix-e-security-classifications.md`](../../aot/dev-guide/appendix-e-security-classifications.md)): + +- **Free Access** — `Write` succeeds with `userId = 0`. +- **Operate / Tune / Configure** — `Write` requires a real `userId` from `AuthenticateUser`. +- **Secured Write** — must call `WriteSecured` with the same user as both `currentUserId` and `verifierUserId` (or use `Write` after `AuthenticateUser` if Galaxy security allows). +- **Verified Write** — must call `WriteSecured` with two distinct authenticated user ids (operator + verifier). + +## Authentication helpers + +```csharp +int AuthenticateUser(int hServer, string verifyUser, string verifyUserPwd); +int ArchestrAUserToId(int hServer, string userIdGuid); +``` + +`AuthenticateUser` returns a non-zero user id when credentials match the Galaxy security configuration, or 0 on failure. `ArchestrAUserToId` resolves a Galaxy user GUID to its numeric id. + +## Events + +All events fire on the registering apartment. The CLI bridges them to a `ConcurrentQueue` plus an `AutoResetEvent` so command code uses `WaitForUpdate(predicate, timeout, out update)` rather than dealing with the COM event signature directly. + +```csharp +event OnDataChange(int hServer, int hItem, + object value, int quality, object timestamp, + ref MXSTATUS_PROXY[] statuses); + +event OnWriteComplete(int hServer, int hItem, ref MXSTATUS_PROXY[] statuses); + +event OperationComplete(int hServer, int hItem, ref MXSTATUS_PROXY[] statuses); + // fires on AddItem completion etc. + +event OnBufferedDataChange(int hServer, int hItem, MxDataType dataType, + object value, object quality, object timestamp, + ref MXSTATUS_PROXY[] statuses); + // only when AddBufferedItem was used +``` + +`timestamp` arrives as a Win32 `FILETIME` boxed inside an `object` (decimal/Int64-shaped). The CLI converts via `DateTime.FromFileTimeUtc(Convert.ToInt64(...))` and exposes it as a local-time `DateTime?`; if conversion fails the field is `null`. + +`quality` on `OnDataChange` is the legacy 16-bit OPC quality value (e.g. `192 = 0xC0` for "Good"). The richer state lives in the `statuses[]` array, especially `Category` and `DetectedBy`. + +## Status semantics + +`MxStatusCategory` values, in roughly ascending severity: + +| Category | Meaning | +| --- | --- | +| `MxCategoryOk` | Operation succeeded. | +| `MxCategoryPending` | Operation accepted, working on it; not an error. | +| `MxCategoryWarning` | Soft issue (e.g. value clamped); operation proceeded. | +| `MxCategoryCommunicationError` | LMX/NMX / engine reach failure. | +| `MxCategoryConfigurationError` | Reference unresolved, attribute missing, type mismatch. | +| `MxCategoryOperationalError` | Object not OnScan, attribute read-only, etc. | +| `MxCategorySecurityError` | Secured / Verified attribute and credentials insufficient. | +| `MxCategorySoftwareError` | Bug in the LMX runtime — usually rare. | +| `MxCategoryOtherError` | Catch-all. | +| `MxStatusCategoryUnknown` | Couldn't classify. | + +`MxStatusSource` tells you *who* set the category — useful when the same code (e.g. `MxCategoryConfigurationError`) can come from the requesting LMX (your client side) versus the responding automation object (the server side): + +``` +MxSourceRequestingLmx - this CLI's local LMX +MxSourceRespondingLmx - the LMX hosting the target +MxSourceRequestingNmx - cross-node messaging on this side +MxSourceRespondingNmx - cross-node messaging on the target side +MxSourceRequestingAutomationObject +MxSourceRespondingAutomationObject +MxSourceUnknown +``` + +A `statuses` array often has multiple entries — one per layer that touched the request. The CLI emits all of them so an agent can pinpoint which layer rejected the call. + +## Buffered items + +`AddBufferedItem` returns updates batched at `SetBufferedUpdateInterval` (milliseconds) intervals via `OnBufferedDataChange`. Useful for high-rate tags where you want time-bucketed snapshots instead of every tick. The CLI does not expose this surface yet — adding a `subscribe --buffered ` option is straightforward. + +## Type libraries + +| File | Use | +| --- | --- | +| `MXAccess.tlb` | v1 — minimal interface, legacy clients. | +| `MXAccess20.tlb` | adds events. | +| `MXAccess32.tlb` | full v3.x surface — what `ArchestrA.MxAccess.dll 3.2.0.0` projects. | + +If you need to consume MxAccess from a different language (C++, Python via comtypes, etc.), import `MXAccess32.tlb` rather than the older versions — they expose subsets. + +## What the CLI does not (yet) cover + +- `Suspend` / `Activate` — easy to add as a sub-command. +- `AddItem2` with explicit context — the CLI passes a single tag reference; if your environment needs context (cross-galaxy, role-based) wrap it. +- `WriteSecured` / `WriteSecured2` — adding a `--verifier-id ` option to `WriteCommand` is the natural extension. +- `AdviseSupervisory` and `AddBufferedItem` — opt-in performance modes. diff --git a/mxaccesscli/docs/usage.md b/mxaccesscli/docs/usage.md new file mode 100644 index 0000000..f58a104 --- /dev/null +++ b/mxaccesscli/docs/usage.md @@ -0,0 +1,142 @@ +# mxa — usage + +Read, write, and subscribe to AVEVA System Platform tags via MxAccess. The CLI runs in-process: each invocation registers an `LMXProxyServer`, executes, and unregisters cleanly. Errors carry the underlying `MxStatusCategory` so an agent can decide whether the failure is transient (Pending), configurational, or operational. + +## Common notes + +- **Tag references** are full attribute paths: `.` (e.g. `TestMachine_001.Speed`). For `Galaxy:` references, follow the convention used in InTouch / Object Viewer. +- **`--client `** sets the client name passed to MxAccess `Register()`. Defaults to `mxa`. Most install logs key on this string. +- **Timeouts are per-call.** They control how long the CLI waits for a `OnDataChange` (read) or `OnWriteComplete` (write). The default is 5 seconds. +- **First-event latency.** LMX has to resolve the reference and bind to the hosting engine on each fresh client connection. Empirically the first `OnDataChange` arrives **3-8 seconds** after `Advise()`. Set timeouts and `subscribe --seconds` accordingly: a 3-second `read` may legitimately time out on first contact, then succeed on the next try because LMX has cached the binding. +- **Subsequent events are fast.** Once a tag is bound, value-change updates propagate within ~100 ms. +- **Exit codes:** `0` on success, `1` if any operation timed out or returned a non-Ok / non-Pending `MxStatusCategory`, `2` on argument-validation errors. + +## `mxa info` + +Print the loaded `ArchestrA.MxAccess` assembly identity, supported `--type` values, and the full `MxStatusCategory` enum. No tag access. + +```powershell +mxa info +``` + +## `mxa read [...]` + +Reads one or more tags by briefly subscribing and capturing the first `OnDataChange` per tag. + +| Option | Default | Notes | +| --- | --- | --- | +| `-t`, `--timeout ` | `5` | Per-tag timeout. Tags that don't deliver a `DataChange` within the window are reported with `error: timeout`. | +| `--client ` | `mxa` | Passed to `Register()`. | +| `--llm-json` | off | Emit the JSON envelope. | + +Examples: + +```powershell +mxa read TestMachine_001.Speed +mxa read TestMachine_001.Speed Reactor1.Level -t 3 +mxa read TestMachine_001.Speed Reactor1.Level --llm-json +``` + +LLM-JSON envelope: + +```json +{ + "query": { "command": "read", "tags": ["TestMachine_001.Speed"], "timeout_s": 5.0, "client": "mxa" }, + "ok": true, + "results": [ + { + "tag": "TestMachine_001.Speed", + "ok": true, + "value": 1234.5, + "quality": 192, + "timestamp": "2026-05-03T19:42:18.001", + "statuses": [ + { "Success": 0, "Category": "MxCategoryOk", "DetectedBy": "MxSourceRespondingAutomationObject", "Detail": 0 } + ] + } + ] +} +``` + +## `mxa write ` + +Writes one value to one tag and waits for `OnWriteComplete`. + +| Option | Default | Notes | +| --- | --- | --- | +| `--type ` | inferred | Force the .NET type used for the boxed value. One of `bool`, `byte`, `short`, `int`, `long`, `float`, `double`, `string`, `datetime`. | +| `-t`, `--timeout ` | `5` | How long to wait for `OnWriteComplete`. | +| `--user-id ` | `0` | Authenticated user id. `0` is unauthenticated; secured attributes will reject. | +| `--client ` | `mxa` | Passed to `Register()`. | +| `--llm-json` | off | Emit the JSON envelope. | + +Type inference rules (when `--type` is not set): `true`/`false`/`yes`/`no`/`on`/`off`/`1`/`0` → bool; pure integer → `int` (then `long`); decimals → `double`; everything else → `string`. + +Examples: + +```powershell +mxa write TestMachine_001.Setpoint 42.5 --type double +mxa write TestMachine_001.RunFlag true +mxa write TestMachine_001.Label "Hello world" +mxa write Reactor1.Setpoint 100 --type int -t 10 --llm-json +``` + +The same JSON envelope shape as `read`, with `results[0]` containing `{ tag, ok, error?, statuses }`. No `value`/`quality`/`timestamp` on the write result — consult a follow-up `mxa read` to confirm. + +## `mxa subscribe [...]` + +Streams `OnDataChange` events for a duration. + +| Option | Default | Notes | +| --- | --- | --- | +| `-s`, `--seconds ` | `10` | Wall-clock duration of the subscription. | +| `--max ` | `1000` | Hard cap on emitted events. | +| `--client ` | `mxa` | Passed to `Register()`. | +| `--llm-json` | off | **JSON Lines** mode — one JSON object per line, no outer envelope. | + +Human output: + +```text +[INFO] Subscribed to 1 tag(s). Streaming for 30.0s. Ctrl-C to stop early. +[19:42:18.001] [OK ] TestMachine_001.Speed = 1234.5 (q=192) +[19:42:19.002] [OK ] TestMachine_001.Speed = 1245.7 (q=192) +... +[INFO] 30 event(s) emitted; subscription closed. +``` + +LLM-JSON output (one event per line, no surrounding `[ ... ]`): + +```jsonl +{"tag":"TestMachine_001.Speed","ok":true,"value":1234.5,"quality":192,"timestamp":"2026-05-03T19:42:18.001","statuses":[{...}]} +{"tag":"TestMachine_001.Speed","ok":true,"value":1245.7,"quality":192,"timestamp":"2026-05-03T19:42:19.002","statuses":[{...}]} +``` + +JSON Lines lets a downstream consumer parse events incrementally rather than buffering the whole stream — the right shape for indefinite or long-running subscriptions. + +## Errors and statuses + +Every `result` carries a `statuses` array — the elements of the COM `MXSTATUS_PROXY[]` MxAccess passes back. Field names match the C# struct exactly: + +| Field | Type | Meaning | +| --- | --- | --- | +| `Success` | int16 | 0 = Ok, non-zero = error code | +| `Category` | enum | `MxCategoryOk`, `MxCategoryPending`, `MxCategoryWarning`, `MxCategoryCommunicationError`, `MxCategoryConfigurationError`, `MxCategoryOperationalError`, `MxCategorySecurityError`, `MxCategorySoftwareError`, `MxCategoryOtherError`, `MxStatusCategoryUnknown` | +| `DetectedBy` | enum | `MxSourceRequestingLmx`, `MxSourceRespondingLmx`, `MxSourceRequestingNmx`, `MxSourceRespondingNmx`, `MxSourceRequestingAutomationObject`, `MxSourceRespondingAutomationObject`, `MxSourceUnknown` | +| `Detail` | int16 | Additional error-code detail | + +A result is considered `ok` only if every `statuses` element has `Category in (MxCategoryOk, MxCategoryPending)`. + +Common failure shapes: + +- **`Category: MxCategoryConfigurationError`** — usually a typo'd reference or the attribute doesn't exist on the deployed instance. Sanity-check via `graccesscli object snapshot`. +- **`Category: MxCategoryCommunicationError`** — engine isn't running, object is OffScan, or LMX can't reach the platform hosting the object. +- **`Category: MxCategorySecurityError`** — secured attribute, `--user-id 0`. Use `WriteSecured` semantics (not yet exposed by this CLI) or target a `Writeable_USC_*` attribute. +- **Timeout** — most likely the tag is genuinely silent (no value updates) or the reference is wrong. With `--llm-json` you'll see `"error": "timeout"` and an empty `statuses`. + +## Picking a tag for a smoke test + +If the live galaxy is not familiar: + +1. Connect to the Galaxy Repository SQL — see [`../../grdb/connectioninfo.md`](../../grdb/connectioninfo.md). +2. Find a deployed instance with a writeable UDA — [`../../grdb/queries/attributes.sql`](../../grdb/queries/attributes.sql) lists user-defined attributes with their data type. Filter on a `Writeable_*` security classification (see [`../../aot/dev-guide/appendix-e-security-classifications.md`](../../aot/dev-guide/appendix-e-security-classifications.md)). +3. The reference for MxAccess is `.`. diff --git a/mxaccesscli/src/MxAccess.Cli/Commands/DiagCommand.cs b/mxaccesscli/src/MxAccess.Cli/Commands/DiagCommand.cs new file mode 100644 index 0000000..a66037a --- /dev/null +++ b/mxaccesscli/src/MxAccess.Cli/Commands/DiagCommand.cs @@ -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; + } + } +} diff --git a/mxaccesscli/src/MxAccess.Cli/Commands/InfoCommand.cs b/mxaccesscli/src/MxAccess.Cli/Commands/InfoCommand.cs new file mode 100644 index 0000000..55dfdd1 --- /dev/null +++ b/mxaccesscli/src/MxAccess.Cli/Commands/InfoCommand.cs @@ -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; + } + } +} diff --git a/mxaccesscli/src/MxAccess.Cli/Commands/ReadCommand.cs b/mxaccesscli/src/MxAccess.Cli/Commands/ReadCommand.cs new file mode 100644 index 0000000..71267ce --- /dev/null +++ b/mxaccesscli/src/MxAccess.Cli/Commands/ReadCommand.cs @@ -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 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(); + 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(); + try + { + foreach (var t in Tags) + { + var item = session.AddItem(t); + item.Advise(); + items.Add(item); + } + + var pending = new HashSet(items.Select(i => i.Handle)); + var captured = new Dictionary(); + 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().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(), + }; + } + 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 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})"); + } + } + } +} diff --git a/mxaccesscli/src/MxAccess.Cli/Commands/SubscribeCommand.cs b/mxaccesscli/src/MxAccess.Cli/Commands/SubscribeCommand.cs new file mode 100644 index 0000000..72eda2b --- /dev/null +++ b/mxaccesscli/src/MxAccess.Cli/Commands/SubscribeCommand.cs @@ -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 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(); + 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})"); + } + } + } +} diff --git a/mxaccesscli/src/MxAccess.Cli/Commands/WriteCommand.cs b/mxaccesscli/src/MxAccess.Cli/Commands/WriteCommand.cs new file mode 100644 index 0000000..fd0e63e --- /dev/null +++ b/mxaccesscli/src/MxAccess.Cli/Commands/WriteCommand.cs @@ -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() } }; + } + 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() } }); + } + else + { + console.Error.WriteLine($"[ERR] write {Tag}: {error}"); + } + } + } +} diff --git a/mxaccesscli/src/MxAccess.Cli/IsExternalInit.cs b/mxaccesscli/src/MxAccess.Cli/IsExternalInit.cs new file mode 100644 index 0000000..89e8e6a --- /dev/null +++ b/mxaccesscli/src/MxAccess.Cli/IsExternalInit.cs @@ -0,0 +1,5 @@ +// Polyfill so C# 9.0 `init` accessors compile on net48. +namespace System.Runtime.CompilerServices +{ + internal static class IsExternalInit { } +} diff --git a/mxaccesscli/src/MxAccess.Cli/Mx/MxItem.cs b/mxaccesscli/src/MxAccess.Cli/Mx/MxItem.cs new file mode 100644 index 0000000..ce9ac7d --- /dev/null +++ b/mxaccesscli/src/MxAccess.Cli/Mx/MxItem.cs @@ -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); + } + } +} diff --git a/mxaccesscli/src/MxAccess.Cli/Mx/MxSession.cs b/mxaccesscli/src/MxAccess.Cli/Mx/MxSession.cs new file mode 100644 index 0000000..75497e0 --- /dev/null +++ b/mxaccesscli/src/MxAccess.Cli/Mx/MxSession.cs @@ -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 _itemsByHandle = new Dictionary(); + private readonly ConcurrentQueue _updates = new ConcurrentQueue(); + 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 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 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 { } + } + } +} diff --git a/mxaccesscli/src/MxAccess.Cli/Mx/MxUpdate.cs b/mxaccesscli/src/MxAccess.Cli/Mx/MxUpdate.cs new file mode 100644 index 0000000..e07591c --- /dev/null +++ b/mxaccesscli/src/MxAccess.Cli/Mx/MxUpdate.cs @@ -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(); + 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})"; + } +} diff --git a/mxaccesscli/src/MxAccess.Cli/Mx/ValueCoercion.cs b/mxaccesscli/src/MxAccess.Cli/Mx/ValueCoercion.cs new file mode 100644 index 0000000..e5739a5 --- /dev/null +++ b/mxaccesscli/src/MxAccess.Cli/Mx/ValueCoercion.cs @@ -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."); + } + } +} diff --git a/mxaccesscli/src/MxAccess.Cli/MxAccess.Cli.csproj b/mxaccesscli/src/MxAccess.Cli/MxAccess.Cli.csproj new file mode 100644 index 0000000..e6522c5 --- /dev/null +++ b/mxaccesscli/src/MxAccess.Cli/MxAccess.Cli.csproj @@ -0,0 +1,35 @@ + + + + Exe + net48 + x86 + x86 + true + MxAccess.Cli + mxa + 9.0 + disable + + + + + + + + + + + + + + + + + ..\..\lib\ArchestrA.MxAccess.dll + true + false + + + + diff --git a/mxaccesscli/src/MxAccess.Cli/Output/Envelope.cs b/mxaccesscli/src/MxAccess.Cli/Output/Envelope.cs new file mode 100644 index 0000000..78103aa --- /dev/null +++ b/mxaccesscli/src/MxAccess.Cli/Output/Envelope.cs @@ -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 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)); + } + } +} diff --git a/mxaccesscli/src/MxAccess.Cli/Program.cs b/mxaccesscli/src/MxAccess.Cli/Program.cs new file mode 100644 index 0000000..034abd3 --- /dev/null +++ b/mxaccesscli/src/MxAccess.Cli/Program.cs @@ -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; + } + } +}