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; + } + } +}