diff --git a/clients/proto/proto-inputs.json b/clients/proto/proto-inputs.json index 4feba3c..d9d8fc9 100644 --- a/clients/proto/proto-inputs.json +++ b/clients/proto/proto-inputs.json @@ -3,7 +3,7 @@ "contractName": "mxaccess-gateway", "gatewayProtocolVersion": 3, "workerProtocolVersion": 1, - "protoRoot": "src/MxGateway.Contracts/Protos", + "protoRoot": "src/ZB.MOM.WW.MxGateway.Contracts/Protos", "sourceFiles": [ { "path": "mxaccess_gateway.proto", diff --git a/docs/AlarmClientDiscovery.md b/docs/AlarmClientDiscovery.md index da565bb..056bb75 100644 --- a/docs/AlarmClientDiscovery.md +++ b/docs/AlarmClientDiscovery.md @@ -1,7 +1,7 @@ # aaAlarmManagedClient discovery — public surface, 2026-05-01 Result of running -`MxGateway.Worker.Tests.AlarmClientDiscoveryTests.DumpAlarmClientPublicSurface` +`ZB.MOM.WW.MxGateway.Worker.Tests.AlarmClientDiscoveryTests.DumpAlarmClientPublicSurface` against the deployed AVEVA assembly: - File: @@ -68,7 +68,7 @@ list. ## What this means The architecture comment on -`src/MxGateway.Worker/MxAccess/AlarmClientConsumer.cs` (PR A.5) is +`src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmClientConsumer.cs` (PR A.5) is **wrong against this deployed assembly**: > "The AVEVA alarm-manager surface (`IAlarmMgrDataProvider`) exposes @@ -89,7 +89,7 @@ never gets invoked at runtime. Until A.2 lands a WM_APP pump, ## Live runtime probe — 2026-05-01 -`MxGateway.Worker.Tests.AlarmClientWmProbeTests.ProbeAlarmClientWmMessages` +`ZB.MOM.WW.MxGateway.Worker.Tests.AlarmClientWmProbeTests.ProbeAlarmClientWmMessages` is a Skip-gated runtime probe that creates a real message-only window, calls `AlarmClient.RegisterConsumer(hWnd, …)` + `Subscribe(@"\Galaxy!", …)`, and pumps for 20s while logging every @@ -505,7 +505,7 @@ Interop.WNWRAPCONSUMERLib.dll`). The COM class is registered in Apartment` — `new wwAlarmConsumerClass()` succeeds via `CoCreateInstance`. -The probe `MxGateway.Worker.Tests/WnWrapConsumerProbeTests.cs` +The probe `ZB.MOM.WW.MxGateway.Worker.Tests/WnWrapConsumerProbeTests.cs` (Skip-gated, archival) drove the captured run. Lifecycle: 1. `new wwAlarmConsumerClass()` — instantiated. @@ -622,7 +622,7 @@ Replacing `aaAlarmManagedClient.AlarmClient` with alarm-consumer surface unblocks A.2 fully. Outline: 1. **Reference path:** drop `aaAlarmManagedClient.dll` reference - from `MxGateway.Worker.csproj`; add `Interop.WNWRAPCONSUMERLib.dll` + from `ZB.MOM.WW.MxGateway.Worker.csproj`; add `Interop.WNWRAPCONSUMERLib.dll` reference from `mxaccessgw/lib/`. (Or commit the interop dll in-tree under `lib/` and reference relatively.) 2. **`AlarmClientConsumer` → `WnWrapAlarmConsumer`:** rewrite diff --git a/docs/Authentication.md b/docs/Authentication.md index 79d524f..4ad9f87 100644 --- a/docs/Authentication.md +++ b/docs/Authentication.md @@ -107,29 +107,20 @@ The gateway keeps API key state in a dedicated SQLite database. SQLite is suffic ### Connection factory -`AuthSqliteConnectionFactory` reads `GatewayOptions.Authentication.SqlitePath`, ensures the parent directory exists, and opens the connection in `ReadWriteCreate` mode so first-run installations can create the file without manual provisioning: +`AuthSqliteConnectionFactory` reads `GatewayOptions.Authentication.SqlitePath`, ensures the parent directory exists, and builds a connection string in `ReadWriteCreate` mode so first-run installations can create the file without manual provisioning. Connection pooling is enabled and the connection string carries a non-zero `DefaultTimeout`: ```csharp -public SqliteConnection CreateConnection() +SqliteConnectionStringBuilder builder = new() { - string sqlitePath = options.Value.Authentication.SqlitePath; - string? directory = Path.GetDirectoryName(sqlitePath); - - if (!string.IsNullOrWhiteSpace(directory)) - { - Directory.CreateDirectory(directory); - } - - SqliteConnectionStringBuilder builder = new() - { - DataSource = sqlitePath, - Mode = SqliteOpenMode.ReadWriteCreate - }; - - return new SqliteConnection(builder.ToString()); -} + DataSource = sqlitePath, + Mode = SqliteOpenMode.ReadWriteCreate, + Pooling = true, + DefaultTimeout = (int)BusyTimeout.TotalSeconds, +}; ``` +Every store opens its connection through `OpenConnectionAsync`, which opens the connection and then applies `PRAGMA journal_mode=WAL` and `PRAGMA busy_timeout`. WAL is a persistent database-level setting so re-applying it per connection is a cheap no-op; `busy_timeout` is per-connection state. Because `MarkKeyUsedAsync` runs on every authenticated request and `SqliteApiKeyAuditStore` appends on every denial, this lets concurrent readers and writers retry briefly instead of surfacing `SQLITE_BUSY` as a hard failure on the request path. + ### Schema `SqliteAuthSchema` declares table names and the current schema version as constants. Three tables are involved: @@ -166,6 +157,8 @@ public static ApiKeyRecord Read(SqliteDataReader reader) `SqliteApiKeyAdminStore` (`IApiKeyAdminStore`) implements administrative mutations: `CreateAsync` accepts an `ApiKeyCreateRequest`, `RevokeAsync` sets `revoked_utc` only when not already revoked, and `RotateAsync` replaces `secret_hash`, clears `last_used_utc`, and clears `revoked_utc` so a rotated key is immediately usable. +Because `RotateAsync` clears `revoked_utc`, rotating a previously revoked key reactivates it. The dashboard API Keys page therefore offers the Rotate (and Revoke) action only for keys whose status is `Active`; a revoked key shows no actions, so an operator cannot un-revoke a deliberately disabled key as a side effect of a rotation. + ### Audit trail `SqliteApiKeyAuditStore` (`IApiKeyAuditStore`) appends `ApiKeyAuditEntry` values to the `api_key_audit` table and stamps each row with a UTC timestamp inside the store rather than trusting the caller. `ListRecentAsync` returns the most recent rows ordered by `audit_id` descending and projects them into `ApiKeyAuditRecord`. Rows are kept even after the referenced key is revoked because the audit history is the durable record of administrative action; the `key_id` column is nullable to accommodate non-key-scoped events such as `init-db`. @@ -223,6 +216,10 @@ constraints remain fully unconstrained after migration. Key ids are restricted by the parser to ASCII letters, digits, periods, and hyphens so they remain safe to embed in the token format and in URL paths used by administrative tooling. +The CLI is not the only management surface: the dashboard API Keys page +creates, rotates, and revokes keys through the same `IApiKeyAdminStore`. See +[Gateway Dashboard Design](./GatewayDashboardDesign.md#api-keys-page). + ## Scope Serialization Scopes are persisted as a single TEXT column rather than a join table because the set is small, never queried by membership at the database level, and changes atomically with the owning row. `ApiKeyScopeSerializer.Serialize` writes a JSON array sorted with `StringComparer.Ordinal` so equivalent scope sets produce byte-identical column values, which makes audit diffing and database comparisons deterministic: @@ -276,4 +273,5 @@ Singletons are safe because each operation opens its own short-lived `SqliteConn - [Gateway Configuration](./GatewayConfiguration.md) - [Authorization](./Authorization.md) +- [Gateway Dashboard Design](./GatewayDashboardDesign.md) - [Diagnostics](./Diagnostics.md) diff --git a/docs/Authorization.md b/docs/Authorization.md index 97eff1a..c693317 100644 --- a/docs/Authorization.md +++ b/docs/Authorization.md @@ -8,7 +8,7 @@ what an authenticated API key can browse, read, or write inside the Galaxy. Authorization runs as a single gRPC server interceptor registered for every call on the gateway. It pulls the authenticated identity for the current request, derives the scope that the request type requires, and either lets the call continue or fails the call with a gRPC status. The pipeline keeps service classes free of cross-cutting checks, which matches the `gateway.md` "thin gRPC layer" rule that service handlers translate between contracts and domain code without owning policy. -The participating types live under `src/MxGateway.Server/Security/Authorization/`: +The participating types live under `src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/`: - `GatewayGrpcAuthorizationInterceptor` runs the authenticate-then-authorize pipeline for unary and server-streaming calls. - `GatewayGrpcScopeResolver` maps a request message (and, for `MxCommandRequest`, the inner `MxCommandKind`) to the scope string that must be present on the caller. @@ -102,12 +102,18 @@ public string ResolveRequiredScope(object request) CloseSessionRequest => GatewayScopes.SessionClose, StreamEventsRequest => GatewayScopes.EventsRead, MxCommandRequest commandRequest => ResolveCommandScope(commandRequest.Command?.Kind ?? MxCommandKind.Unspecified), + AcknowledgeAlarmRequest => GatewayScopes.InvokeWrite, + StreamAlarmsRequest => GatewayScopes.EventsRead, + TestConnectionRequest or + GetLastDeployTimeRequest or + DiscoverHierarchyRequest or + WatchDeployEventsRequest => GatewayScopes.MetadataRead, _ => GatewayScopes.Admin }; } ``` -The `_ => GatewayScopes.Admin` fallback is intentional: any future request type that the resolver does not recognize fails closed, requiring the strongest scope until the resolver is updated. +The `_ => GatewayScopes.Admin` fallback is intentional: any future request type that the resolver does not recognize fails closed, requiring the strongest scope until the resolver is updated. `AcknowledgeAlarm` is treated as a write — it mutates alarm state, mirroring `MxCommandKind.Write*` — and `StreamAlarms` shares the alarm/event surface with `StreamEvents` and `MxCommandKind.DrainEvents`, so it carries `events:read`. Both alarm RPCs are session-less: the scope check is the only authorization gate, since there is no per-session ownership to enforce. `MxCommandRequest` is special because it multiplexes many MxAccess operations through a single RPC. The resolver inspects the embedded `MxCommandKind` so each operation gets its own scope: @@ -117,10 +123,14 @@ private static string ResolveCommandScope(MxCommandKind kind) return kind switch { MxCommandKind.Write or - MxCommandKind.Write2 => GatewayScopes.InvokeWrite, + MxCommandKind.Write2 or + MxCommandKind.WriteBulk or + MxCommandKind.Write2Bulk => GatewayScopes.InvokeWrite, MxCommandKind.WriteSecured or MxCommandKind.WriteSecured2 or + MxCommandKind.WriteSecuredBulk or + MxCommandKind.WriteSecured2Bulk or MxCommandKind.AuthenticateUser => GatewayScopes.InvokeSecure, MxCommandKind.ArchestraUserToId or @@ -135,7 +145,7 @@ private static string ResolveCommandScope(MxCommandKind kind) } ``` -Reads (`Register`, `AddItem`, `Advise`, and any other unspecified kind) fall through to `InvokeRead`, which keeps the matrix small while still separating reads from writes, secured writes, metadata lookups, event drains, and worker shutdown. +Reads (`Register`, `AddItem`, `Advise`, `ReadBulk`, and any other unspecified kind) fall through to `InvokeRead`, which keeps the matrix small while still separating reads from writes, secured writes, metadata lookups, event drains, and worker shutdown. The four bulk-write families (`WriteBulk`, `Write2Bulk`, `WriteSecuredBulk`, `WriteSecured2Bulk`) are mapped explicitly so a missing arm cannot silently demote a bulk write to a read scope. ## Constraint Enforcement @@ -161,12 +171,25 @@ Glob matching is anchored, case-insensitive, and supports `*` and `?`. Subtree and tag glob lists are alternatives: matching either list allows that scope dimension. Empty lists mean unconstrained for that dimension. +Constraints are set when a key is created — through the `apikey create-key` +flags (see [Authentication](./Authentication.md)) or the dashboard API Keys +page create dialog (see +[Gateway Dashboard Design](./GatewayDashboardDesign.md#api-keys-page)). The +dashboard API Keys page also renders each key's effective constraints. + The service checks read constraints for `AddItem`, `AddItem2`, `AddItemBulk`, -`SubscribeBulk`, and `AdviseItemBulk`. It checks write constraints for -`Write`, `Write2`, `WriteSecured`, and `WriteSecured2`. Successful item -registrations are tracked per session so later item-handle commands resolve -back to the original tag address. If a constrained key presents an unknown item -handle, the gateway fails closed. +`SubscribeBulk`, `AdviseItemBulk`, and `ReadBulk`. It checks write constraints +for `Write`, `Write2`, `WriteSecured`, `WriteSecured2`, `WriteBulk`, +`Write2Bulk`, `WriteSecuredBulk`, and `WriteSecured2Bulk`. Bulk commands run +through `BulkConstraintPlan` (`ReadBulkConstraintPlan`, +`WriteBulkConstraintPlan`, `SubscribeBulkConstraintPlan`), which preserves the +caller's input order: each entry is evaluated against the constraint surface, +and `BulkConstraintPlan.MergeDeniedInto` re-merges denied entries back into +their original index positions so the reply slot at `entries[i]` always +corresponds to the request slot at `entries[i]`. Successful item registrations +are tracked per session so later item-handle commands resolve back to the +original tag address. If a constrained key presents an unknown item handle, +the gateway fails closed. Non-bulk constraint failures return gRPC `PermissionDenied`. Bulk read commands preserve input order and return a failed `SubscribeResult` for each @@ -182,10 +205,10 @@ blocking constraint; secured values and raw credentials are never logged. |----------|-------|--------------| | `SessionOpen` | `session:open` | `OpenSessionRequest` | | `SessionClose` | `session:close` | `CloseSessionRequest` | -| `EventsRead` | `events:read` | `StreamEventsRequest`, `MxCommandKind.DrainEvents` | -| `InvokeRead` | `invoke:read` | `MxCommandRequest` for read-style command kinds (`Register`, `AddItem`, `Advise`, and any kind not otherwise mapped) | -| `InvokeWrite` | `invoke:write` | `MxCommandKind.Write`, `MxCommandKind.Write2` | -| `InvokeSecure` | `invoke:secure` | `MxCommandKind.WriteSecured`, `MxCommandKind.WriteSecured2`, `MxCommandKind.AuthenticateUser` | +| `EventsRead` | `events:read` | `StreamEventsRequest`, `StreamAlarmsRequest`, `MxCommandKind.DrainEvents` | +| `InvokeRead` | `invoke:read` | `MxCommandRequest` for read-style command kinds (`Register`, `AddItem`, `Advise`, `ReadBulk`, and any kind not otherwise mapped) | +| `InvokeWrite` | `invoke:write` | `AcknowledgeAlarmRequest`, `MxCommandKind.Write`, `MxCommandKind.Write2`, `MxCommandKind.WriteBulk`, `MxCommandKind.Write2Bulk` | +| `InvokeSecure` | `invoke:secure` | `MxCommandKind.WriteSecured`, `MxCommandKind.WriteSecured2`, `MxCommandKind.WriteSecuredBulk`, `MxCommandKind.WriteSecured2Bulk`, `MxCommandKind.AuthenticateUser` | | `MetadataRead` | `metadata:read` | `MxCommandKind.ArchestraUserToId`, `MxCommandKind.GetSessionState`, `MxCommandKind.GetWorkerInfo`, `GalaxyRepository.TestConnection`, `GalaxyRepository.GetLastDeployTime`, `GalaxyRepository.DiscoverHierarchy`, `GalaxyRepository.WatchDeployEvents` | | `Admin` | `admin` | `MxCommandKind.ShutdownWorker`, the default for any unrecognized request type, and the dashboard authorization policy | @@ -252,6 +275,7 @@ Singleton lifetimes are appropriate because none of the three classes hold per-r ## Related Documentation - [Authentication](./Authentication.md) +- [Gateway Dashboard Design](./GatewayDashboardDesign.md) - [Grpc](./Grpc.md) - [GatewayConfiguration](./GatewayConfiguration.md) - [Galaxy Repository Browse](./GalaxyRepository.md) diff --git a/docs/ClientLibrariesDesign.md b/docs/ClientLibrariesDesign.md index 7da378c..f0652ff 100644 --- a/docs/ClientLibrariesDesign.md +++ b/docs/ClientLibrariesDesign.md @@ -398,7 +398,7 @@ README.md examples/ ``` -Generated code should be reproducible from `src/MxGateway.Contracts/Protos/`. +Generated code should be reproducible from `src/ZB.MOM.WW.MxGateway.Contracts/Protos/`. Do not hand-edit generated code. The stable client proto manifest defines the generated-code directories: diff --git a/docs/ClientPackaging.md b/docs/ClientPackaging.md index bb29f28..d434ea4 100644 --- a/docs/ClientPackaging.md +++ b/docs/ClientPackaging.md @@ -8,7 +8,7 @@ in [Toolchain Links](./ToolchainLinks.md) when a command is missing from ## Shared Inputs All clients generate bindings from the shared protobuf files under -`src/MxGateway.Contracts/Protos`. Regenerate the published client descriptor +`src/ZB.MOM.WW.MxGateway.Contracts/Protos`. Regenerate the published client descriptor after changing either `.proto` file or `clients/proto/proto-inputs.json`: ```powershell @@ -35,37 +35,37 @@ machine boundary or uses a production certificate. ## .NET The .NET client uses .NET 10 and references -`src/MxGateway.Contracts/MxGateway.Contracts.csproj` for generated C# contract +`src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj` for generated C# contract types. `clients/dotnet/generated` remains reserved for client-local generator output if the client later decouples from the contracts project. Regenerate the generated C# contract types: ```powershell -dotnet build src/MxGateway.Contracts/MxGateway.Contracts.csproj +dotnet build src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj ``` Build and test from the repository root: ```powershell -dotnet build clients/dotnet/MxGateway.Client.sln -dotnet test clients/dotnet/MxGateway.Client.sln --no-build +dotnet build clients/dotnet/ZB.MOM.WW.MxGateway.Client.sln +dotnet test clients/dotnet/ZB.MOM.WW.MxGateway.Client.sln --no-build ``` Create local package artifacts: ```powershell $dotnetPackageOutput = Join-Path (Get-Location) 'artifacts/clients/dotnet' -dotnet pack clients/dotnet/MxGateway.Client/MxGateway.Client.csproj -c Release -p:PackageOutputPath="$dotnetPackageOutput" -dotnet publish clients/dotnet/MxGateway.Client.Cli/MxGateway.Client.Cli.csproj -c Release -o artifacts/clients/dotnet/mxgw-dotnet +dotnet pack clients/dotnet/ZB.MOM.WW.MxGateway.Client/ZB.MOM.WW.MxGateway.Client.csproj -c Release -p:PackageOutputPath="$dotnetPackageOutput" +dotnet publish clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/ZB.MOM.WW.MxGateway.Client.Cli.csproj -c Release -o artifacts/clients/dotnet/mxgw-dotnet ``` Run the CLI from source: ```powershell -dotnet run --project clients/dotnet/MxGateway.Client.Cli -- version --json -dotnet run --project clients/dotnet/MxGateway.Client.Cli -- smoke --endpoint "http://$env:MXGATEWAY_ENDPOINT" --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json -dotnet run --project clients/dotnet/MxGateway.Client.Cli -- smoke --endpoint "https://mxgateway.example.local:5001" --tls --ca-file C:\certs\mxgateway-ca.pem --server-name mxgateway.example.local --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json +dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- version --json +dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- smoke --endpoint "http://$env:MXGATEWAY_ENDPOINT" --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json +dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- smoke --endpoint "https://mxgateway.example.local:5001" --tls --ca-file C:\certs\mxgateway-ca.pem --server-name mxgateway.example.local --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json ``` ## Go diff --git a/docs/ClientProtoGeneration.md b/docs/ClientProtoGeneration.md index c45763a..306209d 100644 --- a/docs/ClientProtoGeneration.md +++ b/docs/ClientProtoGeneration.md @@ -21,9 +21,9 @@ records: The source files listed by the manifest are: -- `src/MxGateway.Contracts/Protos/mxaccess_gateway.proto` -- `src/MxGateway.Contracts/Protos/mxaccess_worker.proto` -- `src/MxGateway.Contracts/Protos/galaxy_repository.proto` +- `src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto` +- `src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_worker.proto` +- `src/ZB.MOM.WW.MxGateway.Contracts/Protos/galaxy_repository.proto` `mxaccess_gateway.proto` defines the public gRPC service and shared DTOs. `mxaccess_worker.proto` is included in the descriptor because worker-aware @@ -86,7 +86,7 @@ issues. ## Language Generation Inputs -All generators use `src/MxGateway.Contracts/Protos` as the protobuf import +All generators use `src/ZB.MOM.WW.MxGateway.Contracts/Protos` as the protobuf import root. The checked-in descriptor is available when a language build prefers a descriptor input, but the `.proto` files remain canonical. @@ -94,7 +94,7 @@ Use these commands to regenerate language-specific client bindings: | Client | Command | |--------|---------| -| .NET | `dotnet build src/MxGateway.Contracts/MxGateway.Contracts.csproj` | +| .NET | `dotnet build src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj` | | Go | `Push-Location clients/go; ./generate-proto.ps1; Pop-Location` | | Rust | `Push-Location clients/rust; cargo check --workspace; Pop-Location` | | Python | `Push-Location clients/python; ./generate-proto.ps1; Pop-Location` | @@ -103,10 +103,10 @@ Use these commands to regenerate language-specific client bindings: .NET generation currently runs through the contracts project: ```powershell -dotnet build src/MxGateway.Contracts/MxGateway.Contracts.csproj +dotnet build src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj ``` -Future .NET client projects may either reference `MxGateway.Contracts` or +Future .NET client projects may either reference `ZB.MOM.WW.MxGateway.Contracts` or generate client-local files into `clients/dotnet/generated` with `Grpc.Tools`. Go clients should generate `mxaccess_gateway.proto` and diff --git a/docs/Contracts.md b/docs/Contracts.md index cef6690..0d19ee6 100644 --- a/docs/Contracts.md +++ b/docs/Contracts.md @@ -6,7 +6,7 @@ recreated by the contracts project build. ## Files -`src/MxGateway.Contracts/Protos/mxaccess_gateway.proto` defines the public +`src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto` defines the public `MxAccessGateway` gRPC service, command payloads, command replies, event DTOs, `MxValue`, `MxArray`, and `MxStatusProxy`. @@ -23,19 +23,61 @@ the corresponding MXAccess `AddItem`, `Advise`, `UnAdvise`, and `RemoveItem` calls sequentially on the session STA and preserves input order in the result list. -`src/MxGateway.Contracts/Protos/mxaccess_worker.proto` defines the named-pipe +The command model also includes bulk write/read command kinds: +`WriteBulk`, `Write2Bulk`, `WriteSecuredBulk`, `WriteSecured2Bulk`, and +`ReadBulk`. They are unary `Invoke` payloads on the same `MxAccessGateway` +surface (not separate gRPC methods) and exist so a caller can submit one list +of items per round trip while preserving MXAccess parity per entry. + +- `WriteBulkCommand` / `Write2BulkCommand` / `WriteSecuredBulkCommand` / + `WriteSecured2BulkCommand` each carry a `server_handle` and a `repeated` + list of entries (`WriteBulkEntry`, `Write2BulkEntry`, + `WriteSecuredBulkEntry`, `WriteSecured2BulkEntry`). Each entry mirrors the + single-item command shape — `item_handle` + `value` (+ `timestamp_value` on + the `*2` variants, + `current_user_id` / `verifier_user_id` on the secured + variants). All four replies use `BulkWriteReply`, which carries + `repeated BulkWriteResult`. A `BulkWriteResult` has `server_handle`, + `item_handle`, `was_successful`, `optional int32 hresult`, `repeated + MxStatusProxy statuses`, and `error_message`. Per-entry failures populate + `error_message` + `hresult` and never raise — callers iterate and inspect + each entry. The credential-sensitive redaction rules for `WriteSecured` / + `WriteSecured2` apply to every `value` inside `WriteSecuredBulkEntry` and + `WriteSecured2BulkEntry`. + +- `ReadBulkCommand` carries `server_handle`, `repeated string tag_addresses`, + and `uint32 timeout_ms` (0 means use the gateway-configured default). The + reply is `BulkReadReply` carrying `repeated BulkReadResult`. A + `BulkReadResult` has `server_handle`, `tag_address`, `item_handle`, + `was_successful`, `was_cached`, `value`, `quality`, `source_timestamp`, + `repeated MxStatusProxy statuses`, and `error_message`. MXAccess has no + synchronous `Read`, so `ReadBulk` is dual-mode per entry: when a tag is + already advised in the session the worker returns the cached + `OnDataChange` payload without touching the subscription + (`was_cached = true`); otherwise the worker takes a full + `AddItem` + `Advise` + wait-for-first-`OnDataChange` + `UnAdvise` + + `RemoveItem` snapshot lifecycle and returns the result + (`was_cached = false`). The asymmetry that `BulkReadResult` has no + `hresult` field is intentional — `ReadBulk` outcomes are timeout / cache + / lifecycle states rather than MXAccess COM return codes. + +See `gateway.md` for the full cached-vs-snapshot `ReadBulk` lifecycle and the +per-command scope requirements, and `docs/DesignDecisions.md` "Bulk Command +Family" for the rationale behind the per-entry result shape (independent +success tracking, input-order preservation, no partial-failure exceptions). + +`src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_worker.proto` defines the named-pipe worker IPC envelope and control messages. It imports `mxaccess_gateway.proto` so the worker and gateway use the same command, reply, event, value, and status shapes. -`src/MxGateway.Contracts/Protos/galaxy_repository.proto` defines the +`src/ZB.MOM.WW.MxGateway.Contracts/Protos/galaxy_repository.proto` defines the `GalaxyRepository` service used by clients to browse the Galaxy Repository (deployed object hierarchy and dynamic attributes). The service is metadata- only and does not share types with `mxaccess_gateway.proto`. See [Galaxy Repository Browse](./GalaxyRepository.md) for the RPC catalog and behavior. -Generated C# output is written to `src/MxGateway.Contracts/Generated/`. Do not +Generated C# output is written to `src/ZB.MOM.WW.MxGateway.Contracts/Generated/`. Do not hand-edit generated files. Client generation inputs are published through @@ -49,20 +91,20 @@ generation inputs, output directories, and golden protobuf JSON fixtures. Run the contracts build to regenerate C# protobuf and gRPC code: ```bash -dotnet build src/MxGateway.Contracts/MxGateway.Contracts.csproj +dotnet build src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj ``` Run the focused contract tests after changing either `.proto` file: ```bash -dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj --filter ProtobufContractRoundTripTests +dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter ProtobufContractRoundTripTests ``` The full solution build also regenerates the C# contracts before compiling gateway and test projects: ```bash -dotnet build src/MxGateway.sln +dotnet build src/ZB.MOM.WW.MxGateway.slnx ``` Regenerate the client descriptor after changing either `.proto` file: diff --git a/docs/CrossLanguageSmokeMatrix.md b/docs/CrossLanguageSmokeMatrix.md index 9dc8c3a..2675d0e 100644 --- a/docs/CrossLanguageSmokeMatrix.md +++ b/docs/CrossLanguageSmokeMatrix.md @@ -85,7 +85,7 @@ The explicit sequence remains the parity baseline for issue-level validation. Run the matrix shape tests after changing the smoke matrix: ```bash -dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj --filter FullyQualifiedName~CrossLanguageSmokeMatrixTests +dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter FullyQualifiedName~CrossLanguageSmokeMatrixTests ``` Live execution remains a separate opt-in step because it depends on a running diff --git a/docs/DesignDecisions.md b/docs/DesignDecisions.md index 1be2afa..85c9aa1 100644 --- a/docs/DesignDecisions.md +++ b/docs/DesignDecisions.md @@ -82,6 +82,18 @@ fan-out may be added later with explicit backpressure semantics. Rationale: one subscriber preserves simple event ordering and failure behavior while parity is being proven. +### Alarms — superseded for the alarm subsystem + +The single-subscriber rule above no longer applies to alarms. The gateway runs +an always-on central alarm monitor (`GatewayAlarmMonitor`) that owns one +gateway-managed worker session, caches the active-alarm set, and fans it out to +any number of clients through the session-less `StreamAlarms` RPC. Per-session +alarm auto-subscribe is removed; `AcknowledgeAlarm` is session-less and routes +through the monitor. Data-side `StreamEvents` remains one subscriber per +session. Rationale: alarm state is gateway-wide, not session-scoped — every +client wants the same current set plus updates, and forcing each to own a +worker would multiply AVEVA polling load for no benefit. + ## Authentication Decision: API key authentication for the public gateway. @@ -199,6 +211,57 @@ and failure behavior are easy to compare against direct MXAccess. Batch tag registration can be added later if measured setup latency requires it. +## Bulk Command Family + +Decision: the gateway exposes a fixed set of *bulk* command kinds — +`AddItemBulk`, `AdviseItemBulk`, `RemoveItemBulk`, `UnAdviseItemBulk`, +`SubscribeBulk`, `UnsubscribeBulk`, `WriteBulk`, `Write2Bulk`, +`WriteSecuredBulk`, `WriteSecured2Bulk`, `ReadBulk` — that carry a list of +entries in one round-trip and return one per-entry result. Each command kind +runs the corresponding single-item MXAccess COM call sequentially on the +worker STA; per-entry failures populate `was_successful = false` with the +underlying HRESULT and never throw. There is no transactional / fail-fast +semantic — bulk here means "one round-trip, per-entry results", not +"atomic". + +Rationale: MXAccess COM itself has no native bulk API for any of these +operations. Surfacing the per-entry result list keeps parity transparent — +the caller sees the same per-item HRESULT they would see calling MXAccess +N times directly — while the bulk shape collapses the gateway/IPC overhead +to one round-trip per batch and lets the worker keep the STA hot. + +`ReadBulk` is the only bulk command without a 1:1 MXAccess analogue. Two +choices were considered: + +1. **Cache-then-snapshot** (chosen): when a requested tag is already in the + session's item registry AND advised, the worker returns the last cached + `OnDataChange` value without touching the subscription + (`was_cached = true`). Otherwise it takes the full `AddItem + Advise + + wait-for-first-OnDataChange + UnAdvise + RemoveItem` lifecycle itself + (`was_cached = false`) and leaves the session exactly as it was before + the call. The cache lives on a per-session `MxAccessValueCache`, + populated by `MxAccessBaseEventSink` on every `OnDataChange` after the + event clears the outbound queue. + +2. **Always-snapshot**: take the AddItem-through-RemoveItem lifecycle for + every requested tag. Cleaner conceptually but pays the full lifecycle + cost on every call and would interfere with existing subscriptions if + MXAccess reuses item handles. + +The chosen behavior matches what callers actually want from "current +value" — a free read of an already-streaming tag, and a one-shot snapshot +otherwise — and never disturbs subscriptions the caller did not create. +The decision intentionally does NOT synthesize an `OnDataChange` event +from the snapshot path: the snapshot value reaches the caller through +`ReadBulk`'s reply payload only, not through the event stream. This +preserves the "Don't synthesize events" rule that scopes the rest of the +worker. + +`ReadBulk`'s wait loop pumps Windows messages on the worker STA +(`StaRuntime.PumpPendingMessages`) on every poll iteration so the inbound +MXAccess COM event can dispatch while the bulk executor still holds the +thread — without the pump the OnDataChange would never deliver. + ## Graceful Worker Shutdown Decision: best-effort cleanup before COM release. diff --git a/docs/Diagnostics.md b/docs/Diagnostics.md index f9e5589..8d966ab 100644 --- a/docs/Diagnostics.md +++ b/docs/Diagnostics.md @@ -1,6 +1,6 @@ # Gateway Diagnostics -The diagnostics subsystem provides structured logging, credential redaction, and request-scoped log enrichment for the gateway. It lives under `src/MxGateway.Server/Diagnostics/` and is wired into the ASP.NET Core pipeline so every gRPC and HTTP request carries the same correlation fields. +The diagnostics subsystem provides structured logging, credential redaction, and request-scoped log enrichment for the gateway. It lives under `src/ZB.MOM.WW.MxGateway.Server/Diagnostics/` and is wired into the ASP.NET Core pipeline so every gRPC and HTTP request carries the same correlation fields. ## Goals @@ -162,7 +162,7 @@ public static IApplicationBuilder UseGatewayRequestLoggingScope(this IApplicatio { ILogger logger = context.RequestServices .GetRequiredService() - .CreateLogger("MxGateway.Request"); + .CreateLogger("ZB.MOM.WW.MxGateway.Request"); using IDisposable? scope = logger.BeginGatewayScope(new GatewayLogScope( SessionId: ReadHeader(context, SessionIdHeaderName), @@ -188,7 +188,7 @@ The scope is keyed off four custom headers and the standard `authorization` head The numeric headers use `int.TryParse` and `ulong.TryParse`; missing or unparseable values become `null` and are dropped by `GatewayLogScope.ToDictionary`. This keeps the middleware tolerant of clients that do not yet emit every header, which matters because the earliest call in a session (`OpenSession`) has no `SessionId` to send. -The logger category is `MxGateway.Request`, which lets operators filter the request scope events independently from per-component categories. +The logger category is `ZB.MOM.WW.MxGateway.Request`, which lets operators filter the request scope events independently from per-component categories. ### Pipeline ordering @@ -213,7 +213,7 @@ The order matters: putting the logging scope first ensures that authentication f - `GatewayLogScope.ToDictionary` redacts `ClientIdentity` whenever a scope is materialized. - `DashboardRedactor.Redact` delegates to `RedactClientIdentity` for any value containing the `mxgw_` marker, then falls back to a marker-keyword check for fields like `password` or `token`. This keeps dashboard renders aligned with log redaction. -- `MxGateway.Tests/Diagnostics/GatewayLogRedactorTests.cs` covers each redaction branch, including the assertion that `WriteSecured` values stay redacted even when `valueLoggingEnabled` is true. +- `ZB.MOM.WW.MxGateway.Tests/Diagnostics/GatewayLogRedactorTests.cs` covers each redaction branch, including the assertion that `WriteSecured` values stay redacted even when `valueLoggingEnabled` is true. ## Related Documentation diff --git a/docs/GalaxyRepository.md b/docs/GalaxyRepository.md index b9957f1..8ef588b 100644 --- a/docs/GalaxyRepository.md +++ b/docs/GalaxyRepository.md @@ -2,7 +2,7 @@ The gateway exposes a read-only browse surface over the AVEVA System Platform Galaxy Repository (the SQL Server database named `ZB`). Clients use it to -enumerate the deployed object hierarchy and each object's dynamic attributes +enumerate the deployed object hierarchy and each object's attributes before subscribing to runtime values via the existing `MxAccessGateway` RPCs. This is a metadata layer: it never reads or writes runtime tag values, never @@ -19,20 +19,22 @@ ArchestrA IDE renders the deployment tree. Surfacing that data over gRPC lets remote clients build a navigable address space without any coupling to the COM layer or the host platform. -The query bodies are kept byte-for-byte identical to the equivalent OPC UA -server in the OtOpcUa project so the two consumers see the same row sets. +`HierarchySql` is the object-hierarchy query originally ported from the +equivalent OPC UA server in the OtOpcUa project. `AttributesSql` has since +diverged from OtOpcUa — see [Built-in vs configured attributes](#built-in-vs-configured-attributes) +— and is no longer kept in sync with it. ## RPC Surface The service is defined in -`src/MxGateway.Contracts/Protos/galaxy_repository.proto` under package +`src/ZB.MOM.WW.MxGateway.Contracts/Protos/galaxy_repository.proto` under package `galaxy_repository.v1`. | RPC | Purpose | |-----|---------| | `TestConnection` | Connectivity probe. Returns `{ ok: bool }` after a `SELECT 1`. Does not throw on SQL failure — returns `ok = false`. Always hits SQL directly so it remains a true health check. | | `GetLastDeployTime` | Returns the cached `galaxy.time_of_last_deploy`. Served from the shared hierarchy cache; refreshed in the background. | -| `DiscoverHierarchy` | Returns one page of the deployed hierarchy plus each returned object's dynamic attributes. **Served from cache** — see [Hierarchy Cache](#hierarchy-cache). | +| `DiscoverHierarchy` | Returns one page of the deployed hierarchy plus each returned object's attributes (configured and built-in — see [Built-in vs configured attributes](#built-in-vs-configured-attributes)). **Served from cache** — see [Hierarchy Cache](#hierarchy-cache). | | `WatchDeployEvents` | **Server-streaming.** The server emits the current state immediately on subscribe (so clients can bootstrap without waiting), then emits one event per detected deploy change. See [Deploy Notifications](#deploy-notifications). | `DiscoverHierarchy` is a paged unary RPC. The raw request accepts `page_size` @@ -53,7 +55,7 @@ reports the post-filter count. ## Hierarchy Cache The gateway holds a single shared `IGalaxyHierarchyCache` -(`src/MxGateway.Server/Galaxy/GalaxyHierarchyCache.cs`) — every +(`src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyCache.cs`) — every `DiscoverHierarchy` and `GetLastDeployTime` request reads from this cache rather than hitting SQL. Many clients can browse concurrently with at most one SQL query in flight. @@ -87,10 +89,40 @@ load to complete before returning. If the first load fails or times out, the client gets `Unavailable` with a short reason. Once any load completes (success or failure), this wait is skipped on subsequent calls. +### On-disk snapshot + +The gateway may lose connectivity to the Galaxy database — and the database is +often unreachable right when the gateway itself restarts. To keep browse +working across that gap, the cache persists its dataset to disk: + +- After every successful **heavy** refresh (a deploy change), the raw + hierarchy and attribute rowsets are written to + `MxGateway:Galaxy:SnapshotCachePath` + (default `C:\ProgramData\MxGateway\galaxy-snapshot.json`). The write is + atomic — a temp file plus rename — so a crash mid-write cannot corrupt the + snapshot. Cheap no-change ticks write nothing; the file is already current. +- On the **first** refresh after startup, before any SQL runs, the cache + reloads that file. The restored data is served with `Stale` status — + it is last-known data, not live — so clients can browse immediately even + when the Galaxy database is unreachable. +- The first live query then reconciles: if it observes the **same** + `time_of_last_deploy` the snapshot was saved at, the entry is promoted to + `Healthy` with no heavy re-query (the snapshot is provably current); if it + observes a newer deploy, the heavy queries run and replace the snapshot; if + the database is still unreachable, the entry stays `Stale`. + +`is_alarm` / `is_historized` filters, paging, and the dashboard summary all +work against a restored snapshot exactly as against a live pull — the restore +path runs the same materialization. Persistence is disabled by setting +`MxGateway:Galaxy:PersistSnapshot` to `false`; the snapshot file is then +neither written nor read, and a cold start with an unreachable database comes +up `Unavailable` as before. The on-disk file is a cache, not a system of +record: deleting it only forces the next cold start to wait for live SQL. + ## Deploy Notifications `WatchDeployEvents` is a server-streaming RPC backed by -`IGalaxyDeployNotifier` (`src/MxGateway.Server/Galaxy/GalaxyDeployNotifier.cs`). +`IGalaxyDeployNotifier` (`src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyDeployNotifier.cs`). The notifier maintains a private bounded channel per subscriber so a slow client cannot back-pressure other subscribers or the publisher. @@ -176,6 +208,43 @@ message DiscoverHierarchyReply { } ``` +### Built-in vs configured attributes + +Each `GalaxyObject` carries two kinds of attribute, both surfaced the same way +in the `attributes` list: + +- **Configured (dynamic) attributes** — attributes added in the ArchestrA IDE + attribute editor. Stored in the Galaxy `dynamic_attribute` table. +- **Built-in attributes** — attributes every object inherits from its + primitives: the object framework, the engine/platform primitives, and the + per-attribute extensions (Alarm, History, Boolean, …). Stored in + `attribute_definition` and reached through `primitive_instance`. + +Built-in attributes are why an `AppEngine` or `WinPlatform` object reports its +`Engine.*` and `Alarm*` attributes, and why an alarmed attribute such as +`TestAlarm001` reports its extension leaves `TestAlarm001.Acked`, +`TestAlarm001.AckMsg`, `TestAlarm001.ActiveAlarmState`, and so on. An earlier +version of the browse query returned only configured attributes, so those +objects came back empty or partial; including built-ins makes the browse +surface match what System Platform's own Object Viewer shows. Expect roughly +seven times as many attributes as configured-only — the dashboard attribute +count reflects this. + +Two rules govern the built-in rows: + +- **No category filter.** `attribute_definition` uses a different + `mx_attribute_category` numbering than `dynamic_attribute`, so only the + `_`-prefixed-name and `.Description` exclusions apply to built-ins. (The + configured-attribute category allow-list is unchanged.) +- **`is_historized` / `is_alarm` are always `false` for built-in rows.** Those + flags identify a configured attribute that *anchors* a history or alarm + extension (e.g. `TestAlarm001`), not the extension's machinery leaves + (`TestAlarm001.Acked`). `alarm_bearing_only` and `historized_only` therefore + still select the anchor attributes, not their built-in children. + +When a configured attribute and a built-in attribute resolve to the same +reference, the configured attribute wins. + ### Contained name vs tag name Galaxy objects carry two names. `tag_name` is globally unique and is what @@ -201,7 +270,7 @@ fields cannot express null. Use it to distinguish "no dimension reported" from ```text gRPC client(s) - -> GalaxyRepositoryGrpcService (src/MxGateway.Server/Grpc/) + -> GalaxyRepositoryGrpcService (src/ZB.MOM.WW.MxGateway.Server/Grpc/) DiscoverHierarchy, GetLastDeployTime -> IGalaxyHierarchyCache.Current WatchDeployEvents -> IGalaxyDeployNotifier TestConnection -> GalaxyRepository (direct SQL) @@ -218,29 +287,30 @@ GalaxyHierarchyRefreshService (BackgroundService) Component breakdown: -- `GalaxyRepository` (`src/MxGateway.Server/Galaxy/GalaxyRepository.cs`) holds - the SQL. Its constants `HierarchySql` and `AttributesSql` are copied verbatim - from the OtOpcUa project; do not edit them in isolation here. The two - queries walk template-derivation and package-derivation chains via - recursive CTEs and pick the most-derived attribute override per object. +- `GalaxyRepository` (`src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyRepository.cs`) holds + the SQL. Both `HierarchySql` and `AttributesSql` walk template-derivation and + package-derivation chains via recursive CTEs and pick the most-derived + override per object. `HierarchySql` still matches the OtOpcUa original; + `AttributesSql` does not — it additionally enumerates built-in primitive + attributes (see [Built-in vs configured attributes](#built-in-vs-configured-attributes)). - `GalaxyHierarchyCache` - (`src/MxGateway.Server/Galaxy/GalaxyHierarchyCache.cs`) holds the most + (`src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyCache.cs`) holds the most recent immutable `GalaxyHierarchyCacheEntry` (materialized objects + precomputed dashboard summary + counts + status). All gRPC clients share the same entry. - `GalaxyHierarchyRefreshService` - (`src/MxGateway.Server/Galaxy/GalaxyHierarchyRefreshService.cs`) is a + (`src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyRefreshService.cs`) is a hosted `BackgroundService` that drives `RefreshAsync` on the configured interval, with deploy-time gating to avoid unnecessary heavy queries. - `GalaxyDeployNotifier` - (`src/MxGateway.Server/Galaxy/GalaxyDeployNotifier.cs`) is a thin + (`src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyDeployNotifier.cs`) is a thin per-subscriber-channel fan-out for streaming clients. - `GalaxyProtoMapper` - (`src/MxGateway.Server/Grpc/GalaxyProtoMapper.cs`) converts row models to + (`src/ZB.MOM.WW.MxGateway.Server/Grpc/GalaxyProtoMapper.cs`) converts row models to proto messages. Used by the cache during refresh to materialize the reply once. - `GalaxyRepositoryGrpcService` - (`src/MxGateway.Server/Grpc/GalaxyRepositoryGrpcService.cs`) implements + (`src/ZB.MOM.WW.MxGateway.Server/Grpc/GalaxyRepositoryGrpcService.cs`) implements the four RPCs. ## Configuration @@ -251,6 +321,8 @@ Bound to `MxGateway:Galaxy` via `GalaxyRepositoryOptions`. |--------|---------|-------------| | `MxGateway:Galaxy:ConnectionString` | `Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;` | SQL Server connection string for the Galaxy Repository. Integrated Security against `localhost` is the dev default; production deployments should override this through the standard double-underscore environment variable form, e.g. `MxGateway__Galaxy__ConnectionString`. | | `MxGateway:Galaxy:CommandTimeoutSeconds` | `60` | Per-command SQL timeout. Applies to all three RPCs. | +| `MxGateway:Galaxy:PersistSnapshot` | `true` | Persists each successful browse dataset to disk and reloads it at startup. See [On-disk snapshot](#on-disk-snapshot). | +| `MxGateway:Galaxy:SnapshotCachePath` | `C:\ProgramData\MxGateway\galaxy-snapshot.json` | File path for the persisted browse snapshot. Ignored when `PersistSnapshot` is `false`. | The connection string is not treated as a secret in dev (`Integrated Security`), but production deployments that use SQL authentication should set @@ -306,7 +378,7 @@ that as a yellow or red status badge plus the truncated error. - Failures to reach the Galaxy database surface as `Unavailable`. Detailed SQL exceptions are logged at `Warning` and never returned to clients. - Integration tests live in - `src/MxGateway.IntegrationTests/Galaxy/GalaxyRepositoryLiveTests.cs`. Set + `src/ZB.MOM.WW.MxGateway.IntegrationTests/Galaxy/GalaxyRepositoryLiveTests.cs`. Set `MXGATEWAY_RUN_LIVE_GALAXY_TESTS=1` (and optionally `MXGATEWAY_LIVE_GALAXY_CONN`) to run them; otherwise they skip. diff --git a/docs/GatewayConfiguration.md b/docs/GatewayConfiguration.md index 7213e7a..76f9463 100644 --- a/docs/GatewayConfiguration.md +++ b/docs/GatewayConfiguration.md @@ -19,7 +19,7 @@ paths, timeouts, queue sizes, enum values, or protocol values are invalid. "RunMigrationsOnStartup": true }, "Worker": { - "ExecutablePath": "src\\MxGateway.Worker\\bin\\x86\\Release\\MxGateway.Worker.exe", + "ExecutablePath": "src\\ZB.MOM.WW.MxGateway.Worker\\bin\\x86\\Release\\ZB.MOM.WW.MxGateway.Worker.exe", "WorkingDirectory": null, "RequiredArchitecture": "X86", "StartupTimeoutSeconds": 30, @@ -60,7 +60,15 @@ paths, timeouts, queue sizes, enum values, or protocol values are invalid. "Galaxy": { "ConnectionString": "Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;", "CommandTimeoutSeconds": 60, - "DashboardRefreshIntervalSeconds": 30 + "DashboardRefreshIntervalSeconds": 30, + "PersistSnapshot": true, + "SnapshotCachePath": "C:\\ProgramData\\MxGateway\\galaxy-snapshot.json" + }, + "Alarms": { + "Enabled": false, + "SubscriptionExpression": "", + "DefaultArea": "", + "ReconcileIntervalSeconds": 30 } } } @@ -86,7 +94,7 @@ When `Mode` is `ApiKey`, `SqlitePath` and `PepperSecretName` must be present. | Option | Default | Description | |--------|---------|-------------| -| `MxGateway:Worker:ExecutablePath` | `src\MxGateway.Worker\bin\x86\Release\MxGateway.Worker.exe` | Path to the x86 worker executable launched for each gateway session. The path must be valid and point to a `.exe` file. | +| `MxGateway:Worker:ExecutablePath` | `src\ZB.MOM.WW.MxGateway.Worker\bin\x86\Release\ZB.MOM.WW.MxGateway.Worker.exe` | Path to the x86 worker executable launched for each gateway session. The path must be valid and point to a `.exe` file. | | `MxGateway:Worker:WorkingDirectory` | `null` | Optional working directory for the worker process. When set, it must be a valid filesystem path. | | `MxGateway:Worker:RequiredArchitecture` | `X86` | Required Portable Executable architecture for the worker. Supported values are `X86` and `X64`; MXAccess parity uses `X86`. | | `MxGateway:Worker:StartupTimeoutSeconds` | `30` | Total startup budget for process launch, startup probe, pipe connect, handshake, and worker readiness. | @@ -164,10 +172,24 @@ at startup. | `MxGateway:Galaxy:ConnectionString` | `Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;` | SQL Server connection string for the Galaxy Repository (`ZB`) used by the `GalaxyRepository` browse RPCs. Override in production via `MxGateway__Galaxy__ConnectionString`. | | `MxGateway:Galaxy:CommandTimeoutSeconds` | `60` | Per-command SQL timeout for all Galaxy browse RPCs. | | `MxGateway:Galaxy:DashboardRefreshIntervalSeconds` | `30` | Interval between background refreshes of the dashboard Galaxy summary cache. SQL is hit at most once per interval regardless of dashboard render rate. | +| `MxGateway:Galaxy:PersistSnapshot` | `true` | Persists the latest successful Galaxy browse dataset to disk. When `true`, the cache reloads that snapshot at startup so clients can still browse last-known data while the Galaxy database is unreachable. The restored data is served with `Stale` status until a live query confirms it. | +| `MxGateway:Galaxy:SnapshotCachePath` | `C:\ProgramData\MxGateway\galaxy-snapshot.json` | File path for the persisted Galaxy browse snapshot. Ignored when `PersistSnapshot` is `false`. The snapshot is written atomically (temp file plus rename). | See [Galaxy Repository Browse](./GalaxyRepository.md) for the RPC surface and behavior. +## Alarm Options + +| Option | Default | Description | +|--------|---------|-------------| +| `MxGateway:Alarms:Enabled` | `false` | Gates the gateway's always-on central alarm monitor. When `true`, the gateway opens one gateway-owned worker session dedicated to alarms, caches the active-alarm set, and fans it out to every client through the `StreamAlarms` RPC — no client opens its own session to see alarms. | +| `MxGateway:Alarms:SubscriptionExpression` | _(empty)_ | AVEVA alarm-subscription expression the monitor subscribes on startup, in canonical `\\\Galaxy!` form. The literal `Galaxy` provider is correct regardless of the Galaxy database name. When empty and `Enabled` is `true`, the gateway falls back to `\\\Galaxy!` if `DefaultArea` is set. | +| `MxGateway:Alarms:DefaultArea` | _(empty)_ | Area name used to compose a default subscription when `SubscriptionExpression` is empty. If both are empty while `Enabled` is `true`, the monitor faults with a configuration diagnostic. | +| `MxGateway:Alarms:ReconcileIntervalSeconds` | `30` | How often the monitor reconciles its in-process alarm cache against the worker's authoritative active-alarm snapshot, catching transitions the live poll-and-diff feed missed. Floored at 5 seconds. | + +The alarm monitor is independent of client sessions: `AcknowledgeAlarm` and +`StreamAlarms` are session-less RPCs served by the monitor. + ## Related Documentation - [Gateway Process Detailed Design](./GatewayProcessDesign.md) diff --git a/docs/GatewayDashboardDesign.md b/docs/GatewayDashboardDesign.md index d57f4ed..18f475c 100644 --- a/docs/GatewayDashboardDesign.md +++ b/docs/GatewayDashboardDesign.md @@ -34,7 +34,7 @@ SignalR circuit. Bootstrap is sufficient for a basic dashboard. ## Hosting Model -The dashboard is hosted by `MxGateway.Server` alongside the gRPC API. When +The dashboard is hosted by `ZB.MOM.WW.MxGateway.Server` alongside the gRPC API. When `MxGateway:Dashboard:Enabled` is `true`, `MapGatewayDashboard()` maps the configured `Dashboard:PathBase` to the Blazor Server app and maps the login, logout, and access-denied HTTP endpoints beside it. When dashboard hosting is @@ -49,6 +49,7 @@ Endpoint layout: /dashboard/workers /dashboard/events /dashboard/galaxy +/dashboard/apikeys /dashboard/settings /dashboard/_blazor ``` @@ -68,7 +69,7 @@ dashboard as the default web page. Otherwise leave gRPC/API hosting unaffected. ## High-Level Components ```text -MxGateway.Server +ZB.MOM.WW.MxGateway.Server Dashboard/ Components/ App.razor @@ -83,6 +84,7 @@ MxGateway.Server SessionDetailsPage.razor WorkersPage.razor EventsPage.razor + ApiKeysPage.razor SettingsPage.razor Shared/ MetricCard.razor @@ -91,6 +93,9 @@ MxGateway.Server DashboardSnapshotService.cs DashboardAuthorizationHandler.cs DashboardAuthenticator.cs + DashboardApiKeyAuthorization.cs + DashboardApiKeyManagementService.cs + DashboardApiKeySummary.cs DashboardSnapshot.cs DashboardSessionSummary.cs DashboardWorkerSummary.cs @@ -249,6 +254,99 @@ Show aggregate event diagnostics: Do not display full tag values by default. If value display is later added, make it opt-in and redacted. +### Browse page + +`/dashboard/browse` lets an operator explore the Galaxy tag hierarchy and watch +live values. The tree is built in-process by `DashboardBrowseTreeBuilder` from +`IGalaxyHierarchyCache.Current` — the same cache the Galaxy page reads — so a +render costs no gRPC call and no SQL round-trip. Each node shows its child +objects and, when expanded, its attributes with attribute name, data type +(including array dimension), and the alarm / historized flags. Galaxy SQL +carries no attribute description, so none is shown. A filter box switches the +tree to a flat list of matching attributes. + +Right-clicking an attribute (or double-clicking it) adds it to the subscription +panel. The panel shows each subscribed tag's live value, MXAccess data type, +quality and source timestamp, refreshed every two seconds. The subscription +panel is the explicit opt-in tag-value surface: it always shows values +regardless of `Dashboard:ShowTagValues`, which continues to govern only the +diagnostic session/worker views. + +### Alarms page + +`/dashboard/alarms` lists the alarms the gateway's central alarm monitor +currently holds as Active or ActiveAcked, refreshed every three seconds. It +defaults to showing unacknowledged `Active` alarms; filters add acknowledged +alarms and narrow by area, severity range, and a reference/source/description +text search. Cleared alarms are not retained — the gateway holds no +alarm-history store, so the page reflects only the live active set. The page is +read-only; it does not acknowledge alarms. If `MxGateway:Alarms:Enabled` is +false the central monitor never starts, and the page says so instead of showing +an empty list with no explanation. + +### Live data source + +Both the Browse subscription panel and the Alarms page read live MXAccess data +through `IDashboardLiveDataService` (`DashboardLiveDataService`). For tag data +it owns one shared gateway session for the whole dashboard, opened lazily on +first use via `ISessionManager` and re-opened transparently when it faults or +its lease expires. One session means one worker process backs every dashboard +circuit; all access is serialised so the worker sees one in-flight command at a +time. Tag reads go through `GatewaySession.SubscribeBulkAsync` / `ReadBulkAsync`. + +The Alarms page does **not** use the dashboard session: alarm data comes from +the gateway's always-on central monitor. `QueryAlarmsAsync` reads +`IGatewayAlarmService.CurrentAlarms` — the monitor's in-process cache — so the +dashboard sees the same active-alarm set as every `StreamAlarms` client, with +no per-dashboard alarm subscription. When `MxGateway:Alarms:Enabled` is false +the monitor never starts and the cache stays empty. + +### API keys page + +`/dashboard/apikeys` lists the gateway's API keys and, for authorized +operators, manages them. It reads key metadata through the same +`IApiKeyAdminStore` the `apikey` CLI uses, so the dashboard and the CLI act +on one source of truth. + +The table shows one row per key: + +- key id, +- status (`Active` or `Revoked`), +- display name, +- scopes, +- constraints (rendered as `unconstrained` when none are set), +- created timestamp, +- last-used timestamp. + +Key secrets are never listed. Only the peppered hash is stored, and the page +never reconstructs a key. See [Authorization](./Authorization.md#constraint-enforcement) +for what each constraint means and how it is enforced on the gRPC path. + +#### Management actions + +Create, Rotate, and Revoke controls render only when the signed-in user is +authorized. `DashboardApiKeyAuthorization.CanManage` requires an authenticated +principal that is a member of the LDAP `MxGateway:Ldap:RequiredGroup` — the +same group the dashboard login enforces. An anonymous localhost viewer can read +the table but sees no action controls. + +- **Create** opens a dialog for the key id, display name, scope checkboxes + (the `GatewayScopes` catalog), and the optional constraint fields: read and + write subtrees, read and write tag globs, browse subtrees, max write + classification, and the read-alarm-only / read-historized-only flags. +- **Rotate** issues a new secret for an existing key id and invalidates the + old one. +- **Revoke** marks a key revoked; a revoked key cannot be un-revoked. + +Create and Rotate return the assembled `mxgw__` token **once**, +in a one-time banner. It is never shown again, so the operator must copy it +immediately. This mirrors the `apikey create-key` / `rotate-key` CLI. + +Every management action appends an `api_key_audit` entry +(`dashboard-create-key`, `dashboard-rotate-key`, `dashboard-revoke-key`) with +the key id and the caller's remote address. Secrets and pepper values are never +logged. + ### Settings page Show read-only effective configuration: @@ -330,8 +428,8 @@ Suggested configuration: ## Styling The dashboard serves Bootstrap 5.3.3 assets from -`src/MxGateway.Server/wwwroot/lib/bootstrap/` and local layout/status styling -from `src/MxGateway.Server/wwwroot/css/dashboard.css`. +`src/ZB.MOM.WW.MxGateway.Server/wwwroot/lib/bootstrap/` and local layout/status styling +from `src/ZB.MOM.WW.MxGateway.Server/wwwroot/css/dashboard.css`. Recommended visual language: @@ -377,7 +475,7 @@ Integration tests should verify: The first dashboard slice implements: -1. Blazor Server hosting in `MxGateway.Server`. +1. Blazor Server hosting in `ZB.MOM.WW.MxGateway.Server`. 2. local Bootstrap static assets. 3. dashboard configuration binding. 4. dashboard auth using API key login and HTTP-only cookie. diff --git a/docs/GatewayProcessDesign.md b/docs/GatewayProcessDesign.md index d7ac230..c1332d1 100644 --- a/docs/GatewayProcessDesign.md +++ b/docs/GatewayProcessDesign.md @@ -59,7 +59,7 @@ Those belong to the worker. ## High-Level Components ```text -MxGateway.Server +ZB.MOM.WW.MxGateway.Server Program / Host Configuration Grpc @@ -677,7 +677,7 @@ development only. Dashboard authentication reuses the API-key verifier and scope model. The dashboard login endpoint accepts the key in a form post, checks `admin` scope when `Dashboard:RequireAdminScope` is enabled, and signs in with the -`MxGateway.Dashboard` cookie scheme. The cookie is HTTP-only, secure, strict +`ZB.MOM.WW.MxGateway.Dashboard` cookie scheme. The cookie is HTTP-only, secure, strict SameSite, and scoped with the `__Host-MxGatewayDashboard` name. Logout clears that cookie. Login and logout posts use anti-forgery validation, and dashboard API keys are not accepted in query strings. `Dashboard:AllowAnonymousLocalhost` @@ -703,15 +703,15 @@ gRPC admin API. It should initialize the auth database, create keys, list keys without secrets, revoke keys, rotate keys, and print raw secrets only once at creation. -`MxGateway.Server` exposes local API-key administration as an `apikey` +`ZB.MOM.WW.MxGateway.Server` exposes local API-key administration as an `apikey` subcommand before the web host starts: ```bash -MxGateway.Server apikey init-db --sqlite-path C:\ProgramData\MxGateway\gateway-auth.db -MxGateway.Server apikey create-key --key-id operator01 --display-name Operator --scopes session:open,events:read -MxGateway.Server apikey list-keys --json -MxGateway.Server apikey revoke-key --key-id operator01 -MxGateway.Server apikey rotate-key --key-id operator01 --json +ZB.MOM.WW.MxGateway.Server apikey init-db --sqlite-path C:\ProgramData\MxGateway\gateway-auth.db +ZB.MOM.WW.MxGateway.Server apikey create-key --key-id operator01 --display-name Operator --scopes session:open,events:read +ZB.MOM.WW.MxGateway.Server apikey list-keys --json +ZB.MOM.WW.MxGateway.Server apikey revoke-key --key-id operator01 +ZB.MOM.WW.MxGateway.Server apikey rotate-key --key-id operator01 --json ``` The subcommands accept `--sqlite-path`, `--pepper`, and `--json`. `--pepper` @@ -846,7 +846,7 @@ Suggested configuration shape: "RunMigrationsOnStartup": true }, "Worker": { - "ExecutablePath": "src/MxGateway.Worker/bin/x86/Release/MxGateway.Worker.exe", + "ExecutablePath": "src/ZB.MOM.WW.MxGateway.Worker/bin/x86/Release/ZB.MOM.WW.MxGateway.Worker.exe", "WorkingDirectory": null, "RequiredArchitecture": "X86", "StartupTimeoutSeconds": 30, @@ -887,7 +887,7 @@ Suggested configuration shape: Do not scatter connection or path constants through implementation code. -`MxGateway.Server` binds this section to `GatewayOptions` at startup and +`ZB.MOM.WW.MxGateway.Server` binds this section to `GatewayOptions` at startup and registers validation with `ValidateOnStart()`. Startup fails before the gateway begins serving traffic when required authentication settings are missing, timeouts or queue sizes are not positive, dashboard settings are malformed, or diff --git a/docs/GatewayTesting.md b/docs/GatewayTesting.md index f7c83ce..71312a6 100644 --- a/docs/GatewayTesting.md +++ b/docs/GatewayTesting.md @@ -7,13 +7,13 @@ provider state. ## Fake Worker Harness -`FakeWorkerHarness` in `src/MxGateway.Tests/Gateway/Workers/Fakes/` provides an +`FakeWorkerHarness` in `src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/Fakes/` provides an in-process worker side for named-pipe IPC tests. It uses the same `WorkerFrameReader`, `WorkerFrameWriter`, and `WorkerEnvelope` contract as the gateway so tests exercise real frame validation and worker-client state changes. Use the harness when a gateway or session test needs worker behavior without -starting `MxGateway.Worker.exe` or loading MXAccess COM. The harness scripts: +starting `ZB.MOM.WW.MxGateway.Worker.exe` or loading MXAccess COM. The harness scripts: - `WorkerHello` and `WorkerReady` startup, - command replies with matching correlation ids, @@ -37,43 +37,196 @@ event, and `CloseSession` without loading MXAccess COM. ## Live MXAccess Smoke -`WorkerLiveMxAccessSmokeTests` in `src/MxGateway.IntegrationTests/` composes the +`WorkerLiveMxAccessSmokeTests` in `src/ZB.MOM.WW.MxGateway.IntegrationTests/` composes the real gRPC service, `SessionManager`, `SessionWorkerClientFactory`, -`WorkerClient`, `WorkerProcessLauncher`, and `MxGateway.Worker.exe`. It is +`WorkerClient`, `WorkerProcessLauncher`, and `ZB.MOM.WW.MxGateway.Worker.exe`. It is skipped unless `MXGATEWAY_RUN_LIVE_MXACCESS_TESTS=1` is set because it creates the installed MXAccess COM object and depends on live provider state. The live smoke opens a gateway session, launches the x86 worker, runs -`Register`, `AddItem`, and `Advise`, waits a bounded time for one -`OnDataChange`, and closes the session in a `finally` block so the worker gets a -graceful shutdown request even when a command or event assertion fails. +`Register`, `AddItem`, and `Advise`, waits a bounded time for the first +`OnDataChange` event (skipping any earlier bootstrap/registration-state event), +and closes the session in a `finally` block so the worker gets a graceful +shutdown request even when a command or event assertion fails. Cleanup failures +in that `finally` block are logged rather than thrown, so a real assertion +failure is never masked by a shutdown timeout. + +`WorkerLiveMxAccessSmokeTests` additionally covers five MXAccess parity paths the +fake-worker tests cannot validate: + +- a `Write` round-trip against an advised item, asserting both that the reply is + `Ok` / `MxCommandKind.Write` *and* that the worker emits a matching + `OnWriteComplete` event for the targeted (server, item) handle pair — the + same round-trip proof used by `scripts/run-client-e2e-tests.ps1`, +- an `AddItem` against an invalid server handle, asserting the MXAccess failure + surfaces in the command reply without faulting the gateway transport, +- the `UnAdvise` → `RemoveItem` → `Unregister` teardown chain, asserting each + step replies `Ok` with the matching `MxCommandKind`, that no further + `OnDataChange` events arrive for the un-advised pair, and that a second + `RemoveItem` against the freed handle relays a non-`Ok` MXAccess failure, +- a `WriteSecured` round-trip after `AuthenticateUser`, asserting the reply + carries `MxCommandKind.WriteSecured` and the credential password never + appears in the diagnostic message (parity for both the secured-write + ordering rule and the "do not log secrets" contract), and +- an abnormal worker exit (the worker process is killed mid-session) where the + gateway must transition the session to `SessionState.Faulted` with a + non-empty fault description carrying a known worker-client classification + (pipe disconnected / worker faulted / end-of-stream / heartbeat expired). + +All six tests are gated by the same `MXGATEWAY_RUN_LIVE_MXACCESS_TESTS=1` +opt-in variable. Build the worker before running the smoke: ```bash -dotnet build src/MxGateway.Worker/MxGateway.Worker.csproj -p:Platform=x86 +dotnet build src/ZB.MOM.WW.MxGateway.Worker/ZB.MOM.WW.MxGateway.Worker.csproj -p:Platform=x86 ``` Run the smoke explicitly: ```bash $env:MXGATEWAY_RUN_LIVE_MXACCESS_TESTS = "1" -dotnet test src/MxGateway.IntegrationTests/MxGateway.IntegrationTests.csproj --filter FullyQualifiedName~WorkerLiveMxAccessSmokeTests +dotnet test src/ZB.MOM.WW.MxGateway.IntegrationTests/ZB.MOM.WW.MxGateway.IntegrationTests.csproj --filter FullyQualifiedName~WorkerLiveMxAccessSmokeTests ``` Optional live smoke variables: | Variable | Default | Description | |----------|---------|-------------| -| `MXGATEWAY_LIVE_MXACCESS_WORKER_EXE` | First existing `MxGateway.Worker.exe` under `src/MxGateway.Worker/bin/...` | Worker executable path. Set this when running against a packaged worker or a non-default build output. | +| `MXGATEWAY_LIVE_MXACCESS_WORKER_EXE` | First existing `ZB.MOM.WW.MxGateway.Worker.exe` under `src/ZB.MOM.WW.MxGateway.Worker/bin/...` | Worker executable path. Set this when running against a packaged worker or a non-default build output. | | `MXGATEWAY_LIVE_MXACCESS_ITEM` | `TestChildObject.TestInt` | MXAccess item reference used by `AddItem`. | -| `MXGATEWAY_LIVE_MXACCESS_CLIENT_NAME` | `MxGateway.IntegrationTests` | Client name passed to `Register`. | -| `MXGATEWAY_LIVE_MXACCESS_EVENT_TIMEOUT_SECONDS` | `15` | Maximum wait for the first `OnDataChange`. | +| `MXGATEWAY_LIVE_MXACCESS_CLIENT_NAME` | `ZB.MOM.WW.MxGateway.IntegrationTests` | Client name passed to `Register`. | +| `MXGATEWAY_LIVE_MXACCESS_EVENT_TIMEOUT_SECONDS` | `15` | Maximum wait for the first `OnDataChange` (also used for the `OnWriteComplete` round-trip and the abnormal-exit fault transition). | +| `MXGATEWAY_LIVE_MXACCESS_WRITE_SECURED_USER` | `admin` | ArchestrA user name passed to `AuthenticateUser` before the `WriteSecured` parity step. | +| `MXGATEWAY_LIVE_MXACCESS_WRITE_SECURED_PASSWORD` | `admin123` | Password paired with the user above. Never logged; the test asserts the value does not appear in the WriteSecured diagnostic message. | The test output includes session id, worker process id, command status, HRESULT/status diagnostics, event sequence and handles, close status, and worker stdout/stderr lines emitted during the run. +## Dev-rig Probes + +`src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/` partitions runtime probes from the regular +Worker.Tests regression suite. The folder is its own +`ZB.MOM.WW.MxGateway.Worker.Tests.Probes` namespace so a discovery filter (e.g. `dotnet +test --filter FullyQualifiedName~ZB.MOM.WW.MxGateway.Worker.Tests.Probes`) can target or +exclude them without enumerating individual class names. The probes are +`[Fact(Skip = "...")]` by default and exist to characterize live AVEVA +behavior on the dev rig, not to gate CI — flip `Skip = null` on the dev box +with installed MXAccess + a running Galaxy provider when running them: + +- `AlarmsLiveSmokeTests` — end-to-end smoke for the alarms-over-gateway + pipeline (`WnWrapAlarmConsumer` + `AlarmDispatcher` + + `MxAccessAlarmEventSink`) against `\\\Galaxy!DEV` with the dev rig's + 10-second flip script writing `TestMachine_001.TestAlarm001`. +- `AlarmClientWmProbeTests` — registers as an `AlarmClient` consumer on a real + hidden message-only window and logs every Win32 message that arrives during + a fixed pump window. Used to identify the `WM_APP` / + `RegisterWindowMessage` IDs alarm callbacks use. +- `WnWrapConsumerProbeTests` — instantiates AVEVA's standalone `wnwrapConsumer` + COM class, subscribes to the dev rig's `\\\Galaxy!DEV` provider, + and polls `GetXmlCurrentAlarms2`. The XML payload bypasses the + `FILETIME→DateTime` auto-marshaling that crashes + `aaAlarmManagedClient.AlarmClient.GetHighPriAlarm` on this rig. + +The probes share the Worker.Tests project (so they can use its `net48`/`x86` +configuration and the installed `ArchestrA.MxAccess` / `aaAlarmManagedClient` +references), but they are not part of the regression contract — a Worker.Tests +run with `Skip` left in place passes them as skipped. + +## Live Galaxy Repository + +`GalaxyRepositoryLiveTests` in `src/ZB.MOM.WW.MxGateway.IntegrationTests/Galaxy/` exercises +`GalaxyRepository` directly against the `ZB` Galaxy Repository SQL database. It is +skipped unless `MXGATEWAY_RUN_LIVE_GALAXY_TESTS=1` is set because it depends on a +reachable SQL Server instance and deployed Galaxy state — fake-worker tests cannot +cover the SQL browse RPCs. + +The suite covers `TestConnectionAsync`, `GetLastDeployTimeAsync`, +`GetHierarchyAsync`, and `GetAttributesAsync`. `GetHierarchyAsync` and +`GetAttributesAsync` assert a non-empty result, so the connected `ZB` database +must contain a deployed Galaxy, not just an empty schema. + +Run the Galaxy live tests explicitly: + +```bash +$env:MXGATEWAY_RUN_LIVE_GALAXY_TESTS = "1" +dotnet test src/ZB.MOM.WW.MxGateway.IntegrationTests/ZB.MOM.WW.MxGateway.IntegrationTests.csproj --filter FullyQualifiedName~GalaxyRepositoryLiveTests +``` + +Optional live Galaxy variables: + +| Variable | Default | Description | +|----------|---------|-------------| +| `MXGATEWAY_LIVE_GALAXY_CONN` | `Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;` | Galaxy Repository connection string. Set this when the `ZB` database is on a non-default instance or needs SQL authentication. | + +The default connection string targets `ZB` on `localhost` with Windows +authentication, which matches the Galaxy Repository conventions in CLAUDE.md. + +## Galaxy Filter Safety + +`GalaxyFilterInputSafetyTests` in `src/ZB.MOM.WW.MxGateway.Tests/Galaxy/` covers adversarial +input handling for the Galaxy Repository browse filter layer. It runs in the +unit-test project (no live SQL needed) and complements the live SQL coverage in +`GalaxyRepositoryLiveTests`. + +The test class re-frames the original "Galaxy SQL injection" concern (Tests-002 in +`code-reviews/Tests/findings.md`). `GalaxyRepository` issues only four *constant* +SQL statements (`HierarchySql`, `AttributesSql`, `SELECT 1`, +`SELECT time_of_last_deploy FROM galaxy`) — no `DiscoverHierarchyRequest` field +is ever concatenated into a SQL string, so there is no dynamic SQL surface and no +`LIKE`-escaping helper to test. All filters (`TagNameGlob`, `RootTagName`, +template-chain, category, contained-path) are applied **in memory** by +`GalaxyHierarchyProjector` / `GalaxyGlobMatcher` against the cached snapshot. + +The adversarial-input matrix (`'`, `' OR '1'='1`, `'; DROP TABLE gobject;--`, +`%`, `_`, `100%_off`, `[abc]`, `Pump'001`) pins the following invariants: + +- SQL metacharacters (`'`, `;`) and `LIKE`-wildcards (`%`, `_`) are treated as + opaque literals by `GalaxyGlobMatcher` — they never act as wildcards, never + spuriously match unrelated text. +- Only `*` and `?` are glob wildcards. +- `GalaxyGlobMatcher` applies a 100 ms regex timeout so a pathological glob + (e.g. 5 000 `a` characters plus a literal `!`) completes promptly rather than + catastrophically backtracking. +- `GalaxyHierarchyProjector` returns zero matches (rather than the whole + hierarchy) for an adversarial `TagNameGlob` or `TemplateChainContains`, and + surfaces `NotFound` for an adversarial `RootTagName`. +- The `DiscoverHierarchy` RPC end-to-end returns zero matches for adversarial + `TagNameGlob` rather than faulting. + +These invariants are the real security surface of the Galaxy browse path; the +SQL-injection framing does not apply to a constant-query layer. + +## Live LDAP + +`DashboardLdapLiveTests` in `src/ZB.MOM.WW.MxGateway.IntegrationTests/` exercises +`DashboardAuthenticator` against the live GLAuth directory. It is skipped unless +`MXGATEWAY_RUN_LIVE_LDAP_TESTS=1` is set because it binds against the GLAuth +service described in `glauth.md`. + +The suite builds the authenticator with a default `GatewayOptions`, so +`LdapOptions.RequiredGroup` keeps its `GwAdmin` default. `GwAdmin` is the +gateway-specific dashboard-admin role and is **not** part of the five baseline +GLAuth role groups — it must be provisioned before the LDAP live tests pass. +`AuthenticateAsync_AdminInGwAdminGroup_Succeeds` fails (rather than skips) when +GLAuth has only the baseline groups, so this is a hard prerequisite beyond "LDAP +is up." See the "Adding a gw-specific group" section of `glauth.md` for the +provisioning step that adds `GwAdmin` and grants it to `admin`. + +The suite covers both the success path and the `DashboardAuthenticator` failure +branches: `admin` in `GwAdmin` succeeds; `readonly` is denied for missing group; +`admin` with a wrong password is rejected by the candidate bind without leaking +the password into `FailureMessage`; an unknown username yields no candidate; and +an unreachable LDAP server is absorbed into a failed result rather than throwing. + +Run the LDAP live tests explicitly: + +```bash +$env:MXGATEWAY_RUN_LIVE_LDAP_TESTS = "1" +dotnet test src/ZB.MOM.WW.MxGateway.IntegrationTests/ZB.MOM.WW.MxGateway.IntegrationTests.csproj --filter FullyQualifiedName~DashboardLdapLiveTests +``` + ## Client E2E Scripts `scripts/discover-testmachine-tags.ps1` queries the ZB Galaxy Repository for the @@ -100,11 +253,75 @@ powershell -ExecutionPolicy Bypass -File scripts/discover-testmachine-tags.ps1 - ``` `scripts/run-client-e2e-tests.ps1` drives the .NET, Go, Rust, Python, and Java -client CLIs through a live gateway session. For each client it opens one -session, registers, verifies `SubscribeBulk` and `UnsubscribeBulk` on a bounded -tag subset, adds and advises every discovered test tag, reads a bounded event -stream, then closes the session in a `finally` path. The script writes a JSON -report under `artifacts/e2e/`. +client CLIs through a live gateway session. The gateway and worker are assumed +to be already running at `-Endpoint`; the script does not start or stop them. +For each client it runs these phases, then closes the session in a `finally` +path and writes a JSON report under `artifacts/e2e/`: + +1. **Session + register** — opens one session and registers. +2. **Bulk** — verifies `SubscribeBulk` / `UnsubscribeBulk` on a bounded tag + subset (skip with `-SkipBulk`). +3. **Add-item / advise** — adds and advises every discovered test tag. The + loop has no `StreamEvents` consumer attached, so advised tags accumulate + MXAccess change events in the worker event channel + (`MxGateway:Events:QueueCapacity`); left unbounded it overflows under + `FailFast` backpressure and faults the worker. Every `-DrainEveryTags` + advised tags (default 15) the loop connects a short-lived `StreamEvents` + drain so the gateway pumps that channel empty. `-DrainEveryTags 0` disables + the drain. +4. **Stream** — asserts a bounded event stream delivers at least one event + (skip with `-SkipStream`). +5. **Parity** — asserts MXAccess error paths are rejected rather than silently + succeeding: an invalid item handle and an unknown session id (skip with + `-SkipParity`). +6. **Auth rejection** — asserts `open-session` is rejected when the API key is + missing, and (when `-RejectScopeApiKeyEnv` names an insufficient-scope key) + when the key lacks the required scope. Skip with `-SkipAuth`. +7. **Write round-trip** — *opt-in (`-VerifyWrite`).* Runs right after + `register`: adds and advises a configurable writable attribute + (`-WriteAttribute`, default `TestChangingInt`), writes a per-client + sentinel value, then streams events and asserts an `OnWriteComplete` event + for that item is observed — proof the write round-tripped through the + gateway, worker, and MXAccess provider. The written value being echoed back + in an `OnDataChange` is recorded best-effort (`echoObserved`): a + provider-driven attribute such as `TestChangingInt` accepts the write but + immediately overwrites it, so no data-change carries the value back. The + Rust `stream-events` CLI emits full per-event JSON (`family`, `itemHandle`, + `value`) so all five clients apply the same checks. + + It is opt-in because it mutates live tag state. The phase fails fast if the + write command is rejected — e.g. against a gateway whose worker predates + write support (`MxAccessCommandExecutor` returning `InvalidRequest` for + `Write`/`Write2`/`WriteSecured`/`WriteSecured2`). +8. **Alarm feed + acknowledge** — *opt-in (`-VerifyAlarms`).* Runs after the + stream phase. Exercises the two session-less alarm subcommands against the + gateway's central alarm monitor: `stream-alarms` reads a bounded slice of + the feed (`-AlarmStreamMax`, default 1 — the feed's first message always + arrives immediately, whereas later ones depend on live transitions) and + asserts at least one `AlarmFeedMessage`; `acknowledge-alarm` acknowledges + `-AlarmReference` (default `Galaxy!TestArea.TestMachine_001.TestAlarm001`) + and asserts the RPC round-trips. The native ack outcome is not asserted — + it depends on whether that alarm is currently active. + + It is opt-in because it depends on the gateway's central alarm monitor + being enabled (`MxGateway:Alarms:Enabled`) and a live alarm provider. + +Each client CLI is driven through one long-lived `batch` process. Every CLI +exposes a `batch` subcommand: a process that reads one command line from stdin, +runs it through the normal subcommand dispatch, writes the JSON result, then a +line containing exactly `__MXGW_BATCH_EOR__`. The harness launches one such +process per client and pings the ~250 operations of the flow through it, so the +process — and, for the JVM, the runtime — cold-start is paid once per client +instead of once per operation. A command that fails inside the batch process +writes its `{"error":...}` envelope and the loop continues; the harness treats +that envelope as the operation failure (used by the parity and auth phases). + +Before the per-client phases run, the script builds the .NET CLI +(`dotnet build`) and installs the Java CLI (`gradle :mxgateway-cli:installDist`) +once, so the `batch` process launches straight from the compiled exe / the +installed launcher. The Go, Rust, and Python batch processes are launched via +`go run` / `cargo run` / `python -m`, which compile-or-start once when that +single per-client process starts. Build the gateway and worker, start the gateway, and provide a valid API key before running the client e2e script: @@ -121,40 +338,58 @@ powershell -ExecutionPolicy Bypass -File scripts/run-client-e2e-tests.ps1 -Clien powershell -ExecutionPolicy Bypass -File scripts/run-client-e2e-tests.ps1 -BulkTagCount 10 powershell -ExecutionPolicy Bypass -File scripts/run-client-e2e-tests.ps1 -SkipStream powershell -ExecutionPolicy Bypass -File scripts/run-client-e2e-tests.ps1 -SkipBulk +# Write round-trip (opt-in): point at a writable scalar attribute and its +# value type. +powershell -ExecutionPolicy Bypass -File scripts/run-client-e2e-tests.ps1 -VerifyWrite -WriteAttribute TestChangingInt -WriteType int32 +# Alarm feed + acknowledge (opt-in): needs MxGateway:Alarms:Enabled on the gateway. +powershell -ExecutionPolicy Bypass -File scripts/run-client-e2e-tests.ps1 -VerifyAlarms -AlarmReference "Galaxy!TestArea.TestMachine_001.TestAlarm001" +# Auth rejection: also assert an insufficient-scope key is denied. +powershell -ExecutionPolicy Bypass -File scripts/run-client-e2e-tests.ps1 -RejectScopeApiKeyEnv MXGATEWAY_READONLY_API_KEY +# Run all five clients concurrently as isolated child processes. +powershell -ExecutionPolicy Bypass -File scripts/run-client-e2e-tests.ps1 -Parallel +# Validate the flow offline (prints commands, contacts no gateway). +powershell -ExecutionPolicy Bypass -File scripts/run-client-e2e-tests.ps1 -DryRun powershell -ExecutionPolicy Bypass -File scripts/run-client-e2e-tests.ps1 -Endpoint localhost:5000 -ApiKeyEnv MXGATEWAY_API_KEY ``` +When `-VerifyWrite` is enabled, the write round-trip fails loudly if the write +command is rejected, if `-WriteAttribute` does not name a writable scalar +attribute, or if no `OnWriteComplete` event is observed for the written item +within `-WriteEchoMaxEvents` (default 200) streamed events. Raise +`-WriteEchoMaxEvents` if the gateway's per-session event backlog is large +enough to push `OnWriteComplete` past that bound. + ## Focused Commands Run the cross-language smoke matrix tests after changing the documented client smoke command list: ```bash -dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj --filter FullyQualifiedName~CrossLanguageSmokeMatrixTests +dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter FullyQualifiedName~CrossLanguageSmokeMatrixTests ``` Run the parity fixture matrix tests after changing the integration parity scenario list: ```bash -dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj --filter FullyQualifiedName~ParityFixtureMatrixTests +dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter FullyQualifiedName~ParityFixtureMatrixTests ``` Run the fake worker tests after changing gateway worker IPC, session startup, or event streaming behavior: ```bash -dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj --filter FullyQualifiedName~FakeWorkerHarnessTests -dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj --filter FullyQualifiedName~SessionWorkerClientFactoryFakeWorkerTests -dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj --filter FullyQualifiedName~GatewayEndToEndFakeWorkerSmokeTests -dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj --filter FullyQualifiedName~WorkerClientTests -dotnet test src/MxGateway.Worker.Tests/MxGateway.Worker.Tests.csproj -p:Platform=x86 --filter FullyQualifiedName~WorkerPipeSessionTests +dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter FullyQualifiedName~FakeWorkerHarnessTests +dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter FullyQualifiedName~SessionWorkerClientFactoryFakeWorkerTests +dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter FullyQualifiedName~GatewayEndToEndFakeWorkerSmokeTests +dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter FullyQualifiedName~WorkerClientTests +dotnet test src/ZB.MOM.WW.MxGateway.Worker.Tests/ZB.MOM.WW.MxGateway.Worker.Tests.csproj -p:Platform=x86 --filter FullyQualifiedName~WorkerPipeSessionTests ``` Run the gateway test project after shared gateway test infrastructure changes: ```bash -dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj +dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj ``` ## Related Documentation diff --git a/docs/Grpc.md b/docs/Grpc.md index 8cb1eef..f601c38 100644 --- a/docs/Grpc.md +++ b/docs/Grpc.md @@ -10,7 +10,7 @@ The layer is composed of four collaborators: | Type | Lifetime | Role | |------|----------|------| -| `MxAccessGatewayService` | scoped (gRPC) | Implements the four `MxAccessGateway` RPCs, performs exception mapping. | +| `MxAccessGatewayService` | scoped (gRPC) | Implements the six `MxAccessGateway` RPCs, performs exception mapping. | | `MxAccessGrpcRequestValidator` | singleton | Rejects malformed requests before any session work runs. | | `MxAccessGrpcMapper` | singleton | Converts public proto types to internal `WorkerCommand`/`WorkerEvent` types and back. | | `IEventStreamService` (`EventStreamService`) | singleton | Owns the event stream pipeline, including bounded queue and backpressure handling. | @@ -29,7 +29,7 @@ A second gRPC service, `GalaxyRepositoryGrpcService`, is mapped alongside it. It ## RPC Handlers -`MxAccessGatewayService` derives from the generated `MxAccessGateway.MxAccessGatewayBase` and implements every RPC declared in `mxaccess_gateway.proto`. The proto contract itself is documented in [Contracts](./Contracts.md); this section covers only what the server-side handler does on top of that contract. +`MxAccessGatewayService` derives from the generated `MxAccessGateway.MxAccessGatewayBase` and implements every RPC declared in `mxaccess_gateway.proto` — six in total: `OpenSession`, `CloseSession`, `Invoke`, `StreamEvents`, `AcknowledgeAlarm`, and `StreamAlarms`. The proto contract itself is documented in [Contracts](./Contracts.md); this section covers only what the server-side handler does on top of that contract. Public gRPC send and receive message sizes are configured from `MxGateway:Protocol:MaxGrpcMessageBytes` (default 16 MiB). Official clients use @@ -86,6 +86,14 @@ Carrying the enqueue timestamp into the worker layer is what lets queue-wait tim `StreamEvents` is a server-streaming RPC. The handler delegates the full pipeline to `IEventStreamService` and just forwards each `MxEvent` onto the response stream. Keeping the channel and producer/consumer machinery out of the handler means cancellation, exception mapping, and metric bookkeeping live in one place. +### `AcknowledgeAlarm` + +`AcknowledgeAlarm` is a unary, **session-less** RPC that acknowledges a single alarm. The handler validates `alarm_full_reference` inline (it does not run through `MxAccessGrpcRequestValidator`) and delegates to `IGatewayAlarmService.AcknowledgeAsync`. The always-on `GatewayAlarmMonitor` routes the ack over its own gateway-managed worker session — clients no longer open a session to acknowledge an alarm. A reference that parses as a canonical GUID forwards to `AcknowledgeAlarmCommand`; a `Provider!Group.Tag` reference forwards to `AcknowledgeAlarmByNameCommand`. + +### `StreamAlarms` + +`StreamAlarms` is a server-streaming, **session-less** RPC that attaches to the gateway's central alarm feed. The handler delegates to `IGatewayAlarmService.StreamAsync`. The stream opens with one `AlarmFeedMessage` carrying an `active_alarm` per currently-active alarm (the ConditionRefresh snapshot), then a single `snapshot_complete`, then a `transition` for every subsequent raise / acknowledge / clear. It is served by the always-on `GatewayAlarmMonitor`, which owns a single gateway-managed worker session and fans out to every attached client — clients no longer open a session of their own. `alarm_filter_prefix`, when set, scopes the stream to a sub-tree. + ## Validation Rules `MxAccessGrpcRequestValidator` rejects requests with `StatusCode.InvalidArgument` before any session work happens. The rules are intentionally narrow — anything that requires session state (for example, "session does not exist") is left for `ISessionManager` so the validator can stay synchronous and side-effect free. @@ -96,6 +104,8 @@ Carrying the enqueue timestamp into the worker layer is what lets queue-wait tim | `CloseSession` | `session_id` must be non-empty. | `InvalidArgument` | | `StreamEvents` | `session_id` must be non-empty. | `InvalidArgument` | | `Invoke` | `session_id` non-empty, `command` present, `kind` not `Unspecified`, payload oneof must match `kind`. | `InvalidArgument` | +| `AcknowledgeAlarm` | `alarm_full_reference` must be non-empty. Validated inline in the handler, not by `MxAccessGrpcRequestValidator`. | `InvalidArgument` | +| `StreamAlarms` | No required fields — `alarm_filter_prefix` is optional. | — | The payload-vs-kind check matters because the `MxCommand.payload` oneof is non-discriminated on the wire — a misaligned client could send `kind = Write` with a `Register` payload and silently confuse the worker. The validator turns that into a clear client error: diff --git a/docs/ImplementationPlanClients.md b/docs/ImplementationPlanClients.md index 51a1c34..a9d6cff 100644 --- a/docs/ImplementationPlanClients.md +++ b/docs/ImplementationPlanClients.md @@ -64,9 +64,9 @@ Labels: `area:client-dotnet`, `type:infra`, `priority:p0` Deliverables: -- `clients/dotnet/MxGateway.Client`, -- `clients/dotnet/MxGateway.Client.Cli`, -- `clients/dotnet/MxGateway.Client.Tests`, +- `clients/dotnet/ZB.MOM.WW.MxGateway.Client`, +- `clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli`, +- `clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests`, - optional integration test project, - generated protobuf setup. diff --git a/docs/ImplementationPlanGateway.md b/docs/ImplementationPlanGateway.md index 69e70c9..8874b67 100644 --- a/docs/ImplementationPlanGateway.md +++ b/docs/ImplementationPlanGateway.md @@ -22,19 +22,19 @@ Labels: `area:gateway`, `type:infra`, `priority:p0` Deliverables: -- create `src/MxGateway.sln`, -- create `src/MxGateway.Contracts`, -- create `src/MxGateway.Server`, -- create `src/MxGateway.Tests`, -- create `src/MxGateway.IntegrationTests`, -- target `MxGateway.Server` to `net10.0`, +- create `src/ZB.MOM.WW.MxGateway.slnx`, +- create `src/ZB.MOM.WW.MxGateway.Contracts`, +- create `src/ZB.MOM.WW.MxGateway.Server`, +- create `src/ZB.MOM.WW.MxGateway.Tests`, +- create `src/ZB.MOM.WW.MxGateway.IntegrationTests`, +- target `ZB.MOM.WW.MxGateway.Server` to `net10.0`, - add shared C# build settings in `Directory.Build.props`, - add baseline tests. Acceptance criteria: -- `dotnet build src/MxGateway.sln` succeeds, -- `dotnet test src/MxGateway.sln` succeeds, +- `dotnet build src/ZB.MOM.WW.MxGateway.slnx` succeeds, +- `dotnet test src/ZB.MOM.WW.MxGateway.slnx` succeeds, - gateway project does not reference MXAccess COM. ### Issue: Define Protobuf Contracts @@ -43,8 +43,8 @@ Labels: `area:contracts`, `type:feature`, `priority:p0` Deliverables: -- `src/MxGateway.Contracts/Protos/mxaccess_gateway.proto`, -- `src/MxGateway.Contracts/Protos/mxaccess_worker.proto`, +- `src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto`, +- `src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_worker.proto`, - `MxAccessGateway` service with `OpenSession`, `CloseSession`, `Invoke`, and `StreamEvents`, - `WorkerEnvelope` and worker IPC messages, diff --git a/docs/ImplementationPlanMxAccessWorker.md b/docs/ImplementationPlanMxAccessWorker.md index c97e504..34c9813 100644 --- a/docs/ImplementationPlanMxAccessWorker.md +++ b/docs/ImplementationPlanMxAccessWorker.md @@ -23,12 +23,12 @@ Labels: `area:worker`, `type:infra`, `priority:p0` Deliverables: -- create `src/MxGateway.Worker`, +- create `src/ZB.MOM.WW.MxGateway.Worker`, - target `.NET Framework 4.8`, - platform target `x86`, - reference generated worker contracts, - reference `ArchestrA.MXAccess.dll`, -- create `src/MxGateway.Worker.Tests`, +- create `src/ZB.MOM.WW.MxGateway.Worker.Tests`, - document MSBuild command from `docs/ToolchainLinks.md`. Acceptance criteria: diff --git a/docs/Metrics.md b/docs/Metrics.md index 81659f7..2ab0e6c 100644 --- a/docs/Metrics.md +++ b/docs/Metrics.md @@ -4,7 +4,7 @@ The metrics subsystem exposes counters, histograms, and observable gauges that d ## Overview -`GatewayMetrics` is a singleton (registered in `GatewayApplication.cs`) that owns a single `Meter` named `MxGateway.Server` and a set of synchronised counters, histograms, and observable gauges. Subsystems call typed mutator methods (`SessionOpened`, `CommandFailed`, `EventReceived`, etc.) rather than touching the `Meter` directly, which keeps the OpenTelemetry instrument names and tag conventions in one place. A `lock (_syncRoot)` block guards the scalar fields used by `GetSnapshot`, while per-event maps use `ConcurrentDictionary` so the hot event path avoids the lock. +`GatewayMetrics` is a singleton (registered in `GatewayApplication.cs`) that owns a single `Meter` named `ZB.MOM.WW.MxGateway.Server` and a set of synchronised counters, histograms, and observable gauges. Subsystems call typed mutator methods (`SessionOpened`, `CommandFailed`, `EventReceived`, etc.) rather than touching the `Meter` directly, which keeps the OpenTelemetry instrument names and tag conventions in one place. A `lock (_syncRoot)` block guards the scalar fields used by `GetSnapshot`, while per-event maps use `ConcurrentDictionary` so the hot event path avoids the lock. ## Meter and OpenTelemetry Compatibility @@ -13,7 +13,7 @@ The meter name is exposed as a constant so that hosting code can register it wit ```csharp public sealed class GatewayMetrics : IDisposable { - public const string MeterName = "MxGateway.Server"; + public const string MeterName = "ZB.MOM.WW.MxGateway.Server"; public GatewayMetrics() { diff --git a/docs/MxAccessWorkerInstanceDesign.md b/docs/MxAccessWorkerInstanceDesign.md index 99d88df..d490f59 100644 --- a/docs/MxAccessWorkerInstanceDesign.md +++ b/docs/MxAccessWorkerInstanceDesign.md @@ -33,23 +33,23 @@ project targets .NET Framework 4.8, but the SDK resolver comes from the .NET SDK installation: ```powershell -dotnet msbuild src\MxGateway.Worker\MxGateway.Worker.csproj /restore /p:Configuration=Debug /p:Platform=x86 +dotnet msbuild src\ZB.MOM.WW.MxGateway.Worker\ZB.MOM.WW.MxGateway.Worker.csproj /restore /p:Configuration=Debug /p:Platform=x86 ``` `docs/ToolchainLinks.md` records the Visual Studio MSBuild executable for classic .NET Framework and COM interop builds: ```powershell -& "C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\MSBuild\Current\Bin\MSBuild.exe" src\MxGateway.Worker\MxGateway.Worker.csproj /p:Configuration=Debug /p:Platform=x86 +& "C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\MSBuild\Current\Bin\MSBuild.exe" src\ZB.MOM.WW.MxGateway.Worker\ZB.MOM.WW.MxGateway.Worker.csproj /p:Configuration=Debug /p:Platform=x86 ``` Run the worker tests with the same platform target: ```powershell -dotnet test src\MxGateway.Worker.Tests\MxGateway.Worker.Tests.csproj -p:Platform=x86 +dotnet test src\ZB.MOM.WW.MxGateway.Worker.Tests\ZB.MOM.WW.MxGateway.Worker.Tests.csproj -p:Platform=x86 ``` -The only MXAccess interop reference belongs in `MxGateway.Worker`. Gateway and +The only MXAccess interop reference belongs in `ZB.MOM.WW.MxGateway.Worker`. Gateway and test projects may reference the worker project for metadata and scaffold tests, but they must not reference `ArchestrA.MXAccess.dll` directly. @@ -132,7 +132,7 @@ credential, or API key values before the message is written. ## Internal Components ```text -MxGateway.Worker +ZB.MOM.WW.MxGateway.Worker Program Bootstrap WorkerOptions @@ -251,7 +251,7 @@ The loop should update a heartbeat timestamp after: - processing an MXAccess event. `StaRuntime` implements this runtime boundary in the worker. It starts one -background thread named `MxGateway.Worker.STA`, sets it to `ApartmentState.STA`, +background thread named `ZB.MOM.WW.MxGateway.Worker.STA`, sets it to `ApartmentState.STA`, initializes COM through `StaComApartmentInitializer`, and runs `StaMessagePump`. Commands are scheduled through `InvokeAsync`; the command queue signals an `AutoResetEvent` so `MsgWaitForMultipleObjectsEx` can wake the @@ -655,12 +655,39 @@ the event queue implementation owns those counters. The STA watchdog currently emits a `WorkerFault` with `WorkerFaultCategory.StaHung` when `LastStaActivityUtc` is older than -`WorkerPipeSessionOptions.HeartbeatGrace`. The fault includes the current -command correlation id when a command is active. Command duration and high event -queue depth remain observable through heartbeat fields until dedicated -thresholds own those warnings. The worker reports stale STA activity, but the -gateway owns the final kill decision through its existing heartbeat and worker -lifecycle policy. +`WorkerPipeSessionOptions.HeartbeatGrace` **and no command is in flight**. +`StaRuntime.ProcessQueuedCommands` calls `MarkActivity()` only immediately +before and after each work item, so a synchronously long-running STA command +(for example a `ReadBulk` waiting `timeout_ms` for the first `OnDataChange`) +legitimately freezes `LastStaActivityUtc` for the duration of the wait while +the worker is healthy. The watchdog is therefore suppressed while the +heartbeat snapshot's `CurrentCommandCorrelationId` is non-empty: the worker is +busy executing a command, not hung, and the heartbeat already surfaces the +in-flight correlation id so the gateway can apply its own per-command timeout +if it considers the command too slow. The fault still fires on a truly hung +STA — no command in flight and no activity for longer than `HeartbeatGrace` — +which is the only case the watchdog can usefully distinguish from a slow +command. Command duration and high event queue depth remain observable through +heartbeat fields until dedicated thresholds own those warnings. The worker +reports stale STA activity, but the gateway owns the final kill decision +through its existing heartbeat and worker lifecycle policy. + +The in-flight-command suppression itself is bounded by +`WorkerPipeSessionOptions.HeartbeatStuckCeiling` (default 75 seconds = 5 × +`HeartbeatGrace`). The motivating case for the suppression is a legitimately +slow synchronous command — but a genuinely stuck COM call (for example +against a dead MXAccess provider whose cross-apartment marshaler is +permanently blocked, or a write completion that never fires) leaves +`CurrentCommandCorrelationId` non-empty indefinitely. Without an upper bound +the worker-side `StaHung` watchdog would be permanently defeated for that +session and only the gateway's per-command timeout would catch the hang — +losing the worker-originated diagnostic (`StaHung` fault category, the +stale-by interval) from the gateway audit trail. Once `LastStaActivityUtc` +has been stale for longer than `HeartbeatStuckCeiling`, the watchdog fires +`StaHung` regardless of whether a command is in flight, on the assumption +that no legitimate STA command should run that long without periodically +refreshing activity. Deployments that legitimately run very long bulk +operations should raise the ceiling rather than disable it. ## Shutdown @@ -807,7 +834,7 @@ tests. `AddItem` uses `TestChildObject.TestInt` by default and accepts an override through `MXGATEWAY_LIVE_MXACCESS_ITEM`; `AddItem2` uses the captured parity fixture shape `AddItem2("TestInt", "TestChildObject")`. -`WorkerLiveMxAccessSmokeTests` in `src/MxGateway.IntegrationTests/` uses the +`WorkerLiveMxAccessSmokeTests` in `src/ZB.MOM.WW.MxGateway.IntegrationTests/` uses the same opt-in variable for the gateway-to-worker live smoke. It launches the x86 worker through `WorkerProcessLauncher`, opens a gateway session, runs `Register`, `AddItem`, and `Advise`, waits for one `OnDataChange`, and closes diff --git a/docs/ParityFixtureMatrix.md b/docs/ParityFixtureMatrix.md index 08bb1e6..db31003 100644 --- a/docs/ParityFixtureMatrix.md +++ b/docs/ParityFixtureMatrix.md @@ -88,7 +88,7 @@ into a transport failure when the worker captured HRESULT or status details. Run the parity fixture matrix tests after changing the matrix: ```bash -dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj --filter FullyQualifiedName~ParityFixtureMatrixTests +dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter FullyQualifiedName~ParityFixtureMatrixTests ``` Live MXAccess execution remains opt-in. The matrix defines which scenarios to diff --git a/docs/Sessions.md b/docs/Sessions.md index bb4696e..fb502ce 100644 --- a/docs/Sessions.md +++ b/docs/Sessions.md @@ -16,7 +16,7 @@ All four interfaces (`ISessionManager`, `ISessionRegistry`, `ISessionWorkerClien The session id is an opaque string in the form `session-{guid:N}` and the per-session pipe name is `mxaccess-gateway-{ProcessId}-{SessionId}`. Encoding the gateway PID into the pipe name avoids collisions when an old gateway process leaks pipes that the OS has not yet reclaimed. -`SessionState` itself is the protobuf-generated enum from `MxGateway.Contracts.Proto`, so it is shared between the gateway and clients on the wire. +`SessionState` itself is the protobuf-generated enum from `ZB.MOM.WW.MxGateway.Contracts.Proto`, so it is shared between the gateway and clients on the wire. ```csharp public void TransitionTo(SessionState nextState) @@ -33,12 +33,19 @@ public void TransitionTo(SessionState nextState) return; } + if (_state is SessionState.Closing + && nextState is not SessionState.Closed + && nextState is not SessionState.Faulted) + { + return; + } + _state = nextState; } } ``` -`Closed` is terminal and `Faulted` only allows a transition to `Closed`. This guards against late callbacks (worker exit, heartbeat timeout) re-animating a session that is already torn down. +`Closed` is terminal, `Faulted` only allows a transition to `Closed`, and `Closing` only allows a transition to `Closed` or `Faulted`. This guards against late callbacks (worker exit, heartbeat timeout) re-animating a session that is already tearing down or torn down — once `CloseAsync` has set `Closing` under `_syncRoot`, no `TransitionTo(Ready)` from another thread can walk the session back to `Ready`. Both close-related writes (`Closing` and `Closed`) go through `_syncRoot` exactly like every other state write; `_closeLock` only serializes concurrent close attempts. ### SessionManager (ISessionManager) @@ -184,7 +191,7 @@ Sessions open with `MxGateway:Sessions:DefaultLeaseSeconds` (default 1800) added ### Close -`GatewaySession.CloseAsync` is serialized by a per-session `SemaphoreSlim` (`_closeLock`). It transitions to `Closing`, asks the worker client to shut down within `ShutdownTimeout`, and on success transitions to `Closed`. If `WorkerClient.ShutdownAsync` throws, the session falls back to `IWorkerClient.Kill` (forced close): +`GatewaySession.CloseAsync` is serialized by a per-session `SemaphoreSlim` (`_closeLock`) so only one close runs at a time, but every read/write of `_state` still passes through `_syncRoot` (via `TryBeginClose` and `MarkClosed`). The close path therefore obeys the same lock discipline as `TransitionTo` / `MarkFaulted`: it transitions to `Closing`, asks the worker client to shut down within `ShutdownTimeout`, and on success transitions to `Closed`. `DisposeAsync` waits on `_closeLock` once before disposing the semaphore so an in-flight close's `Release()` cannot race against the dispose. If `WorkerClient.ShutdownAsync` throws, the session falls back to `IWorkerClient.Kill` (forced close): ```csharp if (_workerClient is not null) diff --git a/docs/WorkerBootstrap.md b/docs/WorkerBootstrap.md index 001cf37..aa2ffc6 100644 --- a/docs/WorkerBootstrap.md +++ b/docs/WorkerBootstrap.md @@ -1,13 +1,13 @@ # Worker Bootstrap -The bootstrap layer parses the command-line arguments and environment variables passed to the `MxGateway.Worker` process, validates them against the gateway contract, and produces either a populated `WorkerOptions` instance or a structured failure that maps to a `WorkerExitCode`. +The bootstrap layer parses the command-line arguments and environment variables passed to the `ZB.MOM.WW.MxGateway.Worker` process, validates them against the gateway contract, and produces either a populated `WorkerOptions` instance or a structured failure that maps to a `WorkerExitCode`. ## Overview The worker process is a short-lived child of the gateway. The gateway side of this contract lives in [WorkerProcessLauncher](./WorkerProcessLauncher.md). On the worker side, `Program.cs` is a single line that delegates to `WorkerApplication.Run(args)`: ```csharp -using MxGateway.Worker; +using ZB.MOM.WW.MxGateway.Worker; return WorkerApplication.Run(args); ``` diff --git a/docs/WorkerConversion.md b/docs/WorkerConversion.md index 696cc53..18691fe 100644 --- a/docs/WorkerConversion.md +++ b/docs/WorkerConversion.md @@ -1,10 +1,10 @@ # Worker Conversion Layer -The conversion layer in `MxGateway.Worker.Conversion` projects COM `VARIANT` payloads, `HRESULT` codes, and `MXSTATUS_PROXY` records into the protobuf wire types in `MxGateway.Contracts.Proto`. The design is parity-first: every projection preserves enough raw metadata that the original COM observation can be reconstructed downstream. +The conversion layer in `ZB.MOM.WW.MxGateway.Worker.Conversion` projects COM `VARIANT` payloads, `HRESULT` codes, and `MXSTATUS_PROXY` records into the protobuf wire types in `ZB.MOM.WW.MxGateway.Contracts.Proto`. The design is parity-first: every projection preserves enough raw metadata that the original COM observation can be reconstructed downstream. ## Overview -`gateway.md` (sections "Value Model" and "Status Model") requires that the wire format use a value union capable of representing COM `VARIANT` values and arrays, that lossy conversions retain both the typed projection and raw diagnostic metadata, and that `MXSTATUS_PROXY` arrays never collapse to a single success flag. The types in `src/MxGateway.Worker/Conversion/` are the worker-side enforcement of those rules. +`gateway.md` (sections "Value Model" and "Status Model") requires that the wire format use a value union capable of representing COM `VARIANT` values and arrays, that lossy conversions retain both the typed projection and raw diagnostic metadata, and that `MXSTATUS_PROXY` arrays never collapse to a single success flag. The types in `src/ZB.MOM.WW.MxGateway.Worker/Conversion/` are the worker-side enforcement of those rules. The layer is split into three concerns: diff --git a/docs/WorkerFrameProtocol.md b/docs/WorkerFrameProtocol.md index 88de84a..8a43fbe 100644 --- a/docs/WorkerFrameProtocol.md +++ b/docs/WorkerFrameProtocol.md @@ -35,17 +35,22 @@ oversized frames, protocol version mismatches, and session mismatches. ## Verification +The frame protocol lives in `ZB.MOM.WW.MxGateway.Worker.Ipc` (`WorkerFrameReader`, +`WorkerFrameWriter`, `WorkerFrameProtocolOptions`) and is covered by +`src/ZB.MOM.WW.MxGateway.Worker.Tests/Ipc/WorkerFrameProtocolTests.cs`. The worker is an +x86 process, so build and test it with `-p:Platform=x86`. + Run the focused tests after changing the frame protocol: -```bash -dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj --filter WorkerFrameProtocolTests +```powershell +dotnet test src/ZB.MOM.WW.MxGateway.Worker.Tests/ZB.MOM.WW.MxGateway.Worker.Tests.csproj -p:Platform=x86 --filter WorkerFrameProtocolTests ``` -Run the gateway build because the frame protocol is part of -`MxGateway.Server`: +Run the x86 worker build because the frame protocol is part of +`ZB.MOM.WW.MxGateway.Worker`: -```bash -dotnet build src/MxGateway.Server/MxGateway.Server.csproj +```powershell +dotnet build src/ZB.MOM.WW.MxGateway.Worker/ZB.MOM.WW.MxGateway.Worker.csproj -p:Platform=x86 ``` ## Related Documentation diff --git a/docs/WorkerProcessLauncher.md b/docs/WorkerProcessLauncher.md index 9f41bd6..235031d 100644 --- a/docs/WorkerProcessLauncher.md +++ b/docs/WorkerProcessLauncher.md @@ -60,13 +60,13 @@ optional pipe reservation, records a worker kill metric, and reports a Run the focused launcher tests after changing process launch behavior: ```bash -dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj --filter WorkerProcessLauncherTests +dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter WorkerProcessLauncherTests ``` -Run the gateway build because the launcher is part of `MxGateway.Server`: +Run the gateway build because the launcher is part of `ZB.MOM.WW.MxGateway.Server`: ```bash -dotnet build src/MxGateway.Server/MxGateway.Server.csproj +dotnet build src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj ``` ## Related Documentation diff --git a/docs/WorkerSta.md b/docs/WorkerSta.md index 5065728..8c04661 100644 --- a/docs/WorkerSta.md +++ b/docs/WorkerSta.md @@ -4,7 +4,7 @@ The worker STA runtime owns the dedicated single-threaded apartment thread that ## Why an STA Is Required -The installed MXAccess interop assembly declares an `Apartment` threading model (see `gateway.md` under "STA Worker Thread Model"). COM objects with that model must be created and called on a thread initialized as a single-threaded apartment, and any callbacks the object raises (event sink calls) are delivered through the thread's Windows message queue. A plain blocking queue is not sufficient: the STA loop must pump Windows messages so that the COM marshaler can deliver event invocations on the same thread that holds the object. Because of that constraint, every MXAccess operation in the worker is funneled through the types in `src/MxGateway.Worker/Sta/`. +The installed MXAccess interop assembly declares an `Apartment` threading model (see `gateway.md` under "STA Worker Thread Model"). COM objects with that model must be created and called on a thread initialized as a single-threaded apartment, and any callbacks the object raises (event sink calls) are delivered through the thread's Windows message queue. A plain blocking queue is not sufficient: the STA loop must pump Windows messages so that the COM marshaler can deliver event invocations on the same thread that holds the object. Because of that constraint, every MXAccess operation in the worker is funneled through the types in `src/ZB.MOM.WW.MxGateway.Worker/Sta/`. ## Types @@ -20,13 +20,13 @@ The installed MXAccess interop assembly declares an `Apartment` threading model ## STA Thread Initialization -`StaRuntime`'s constructor configures a background `Thread` named `MxGateway.Worker.STA` and forces it into `ApartmentState.STA` before the thread starts. `Start()` releases the thread and then blocks on `startedEvent` so callers observe a fully-initialized apartment (or a captured `startupException`) before the first `InvokeAsync` call: +`StaRuntime`'s constructor configures a background `Thread` named `ZB.MOM.WW.MxGateway.Worker.STA` and forces it into `ApartmentState.STA` before the thread starts. `Start()` releases the thread and then blocks on `startedEvent` so callers observe a fully-initialized apartment (or a captured `startupException`) before the first `InvokeAsync` call: ```csharp staThread = new Thread(ThreadMain) { IsBackground = true, - Name = "MxGateway.Worker.STA" + Name = "ZB.MOM.WW.MxGateway.Worker.STA" }; staThread.SetApartmentState(ApartmentState.STA); ``` diff --git a/gateway.md b/gateway.md index 931e0ee..23ae0dc 100644 --- a/gateway.md +++ b/gateway.md @@ -104,8 +104,8 @@ Responsibilities: The gateway must never instantiate or call MXAccess directly. -The gateway observability foundation lives in `MxGateway.Server.Diagnostics` -and `MxGateway.Server.Metrics`. Structured logging scopes carry session, +The gateway observability foundation lives in `ZB.MOM.WW.MxGateway.Server.Diagnostics` +and `ZB.MOM.WW.MxGateway.Server.Metrics`. Structured logging scopes carry session, worker, correlation, command, and client identity fields with redaction applied before values enter log state. `GatewayMetrics` exposes counters, gauges, and histograms through .NET `Meter` and a snapshot API that dashboard services can @@ -113,13 +113,31 @@ project without binding to a metrics exporter. `DashboardSnapshotService` projects sessions, workers, metrics, faults, and effective configuration into immutable DTOs for read-only dashboard rendering. The Blazor Server dashboard renders those snapshots at `/dashboard`, -`/dashboard/sessions`, `/dashboard/workers`, `/dashboard/events`, and -`/dashboard/settings`. Components subscribe to +`/dashboard/sessions`, `/dashboard/workers`, `/dashboard/events`, +`/dashboard/galaxy`, and `/dashboard/settings`. Components subscribe to `IDashboardSnapshotService.WatchSnapshotsAsync()` and update on the configured snapshot interval without mutating session or worker state. The dashboard uses local Bootstrap CSS and JavaScript plus a small local stylesheet; it does not use a Blazor UI component library. +`/dashboard/browse` walks the `IGalaxyHierarchyCache` tree and reads subscribed +tag values live through `IDashboardLiveDataService`, which owns one shared, +lazily-opened gateway session for the whole dashboard. `/dashboard/alarms` +reads the central alarm monitor's in-process cache directly. See +`docs/GatewayDashboardDesign.md`. + +The gateway runs an always-on central alarm monitor (`GatewayAlarmMonitor`): +one gateway-owned worker session subscribes the configured AVEVA alarm +provider, caches the active-alarm set (reconciled periodically against the +worker's snapshot), and fans it out to every client through the session-less +`StreamAlarms` RPC — the stream opens with the current active-alarm snapshot, +then streams live transitions. `AcknowledgeAlarm` is session-less and routes +through the monitor. Clients never open a worker session to see alarms, and +alarm monitoring is independent of client lifecycle; the monitor re-opens its +session if the worker faults. Gated by `MxGateway:Alarms:Enabled` — see +`docs/DesignDecisions.md` for why this reverses the v1 single-subscriber rule +for the alarm subsystem. + Dashboard routes use the same API-key verifier as gRPC. `/dashboard/login` accepts the API key in a form body, validates the configured `admin` scope, and issues an HTTP-only secure cookie for subsequent dashboard requests. @@ -283,6 +301,44 @@ Core commands: - `AuthenticateUser` - `ArchestrAUserToId` +Bulk variants (single gRPC round-trip carries the full list, the worker +runs the per-item MXAccess calls sequentially on its STA, and the reply +returns one result per requested entry — per-entry failures populate +`was_successful = false` + `error_message` and never throw): + +- `AddItemBulk` — `repeated string tag_addresses` → `BulkSubscribeReply`. +- `AdviseItemBulk` — `repeated int32 item_handles` → `BulkSubscribeReply`. +- `RemoveItemBulk` — `repeated int32 item_handles` → `BulkSubscribeReply`. +- `UnAdviseItemBulk` — `repeated int32 item_handles` → `BulkSubscribeReply`. +- `SubscribeBulk` — `repeated string tag_addresses` (AddItem + Advise per + entry, with cleanup on Advise failure) → `BulkSubscribeReply`. +- `UnsubscribeBulk` — `repeated int32 item_handles` (UnAdvise + RemoveItem + per entry, with independent error tracking) → `BulkSubscribeReply`. +- `WriteBulk` — `repeated WriteBulkEntry` (each `{item_handle, value, user_id}`) + → `BulkWriteReply` (`repeated BulkWriteResult`). Required scope: `invoke:write`. +- `Write2Bulk` — `repeated Write2BulkEntry` (each adds `timestamp_value`) → + `BulkWriteReply`. Required scope: `invoke:write`. +- `WriteSecuredBulk` — `repeated WriteSecuredBulkEntry` (each + `{item_handle, current_user_id, verifier_user_id, value}`) → `BulkWriteReply`. + Required scope: `invoke:secure`. Same redaction rules as single-item + `WriteSecured`: per-entry `value` must never reach logs unless an explicit + redacted value-logging path is enabled. +- `WriteSecured2Bulk` — `repeated WriteSecured2BulkEntry` (each adds + `timestamp_value`) → `BulkWriteReply`. Required scope: `invoke:secure`. +- `ReadBulk` — `repeated string tag_addresses` + `uint32 timeout_ms` → + `BulkReadReply` (`repeated BulkReadResult`). MXAccess COM has no + synchronous `Read`; the worker satisfies this command by returning the + last cached `OnDataChange` payload when the requested tag is already + advised (`was_cached = true`, no subscription side-effects), or by + taking a full `AddItem` + `Advise` + wait-for-first-OnDataChange + + `UnAdvise` + `RemoveItem` snapshot lifecycle when no live subscription + exists (`was_cached = false`). Per-tag timeouts surface as + `was_successful = false` rather than throwing. The cache lives on the + worker's `MxAccessValueCache`, populated by `MxAccessBaseEventSink` on + every `OnDataChange` after the event clears the outbound queue. + Required scope: `invoke:read`. `timeout_ms == 0` uses the worker's + default (1000 ms). + Optional diagnostics: - `Ping` @@ -579,8 +635,11 @@ Policy: - command exceptions return structured command fault with HRESULT if known, - stale sessions are closed by lease timeout, - stuck workers are killed by process id, -- gateway restart should not attempt to reattach old workers unless explicitly - designed; first version should terminate orphaned workers on startup. +- gateway restart does not reattach old workers; `OrphanWorkerCleanupHostedService` + runs `OrphanWorkerTerminator` once on startup — before the server accepts + sessions — to kill leftover `ZB.MOM.WW.MxGateway.Worker.exe` processes (matched by the + configured worker executable path, or by image name when the x64 gateway cannot + introspect the x86 worker's module) left behind by a previous unclean run. Because each client owns one worker, a crash or leak affects only that session. @@ -667,36 +726,36 @@ Optimizations after parity: Suggested additions: ```text -src/MxGateway.Contracts/ +src/ZB.MOM.WW.MxGateway.Contracts/ Protos/ mxaccess_gateway.proto mxaccess_worker.proto Generated/ -src/MxGateway.Server/ +src/ZB.MOM.WW.MxGateway.Server/ Program.cs Sessions/ Workers/ Grpc/ Metrics/ -src/MxGateway.Worker/ +src/ZB.MOM.WW.MxGateway.Worker/ Program.cs Ipc/ Sta/ MxAccess/ Conversion/ -src/MxGateway.Tests/ +src/ZB.MOM.WW.MxGateway.Tests/ contract tests gateway session tests fake worker tests -src/MxGateway.Worker.Tests/ +src/ZB.MOM.WW.MxGateway.Worker.Tests/ value/status conversion tests STA queue tests -src/MxGateway.IntegrationTests/ +src/ZB.MOM.WW.MxGateway.IntegrationTests/ optional live MXAccess tests ``` diff --git a/scripts/publish-client-proto-inputs.ps1 b/scripts/publish-client-proto-inputs.ps1 index ab2814f..737ad7c 100644 --- a/scripts/publish-client-proto-inputs.ps1 +++ b/scripts/publish-client-proto-inputs.ps1 @@ -7,7 +7,7 @@ Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" $repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") -$protoRoot = Join-Path $repoRoot "src/MxGateway.Contracts/Protos" +$protoRoot = Join-Path $repoRoot "src/ZB.MOM.WW.MxGateway.Contracts/Protos" $manifestPath = Join-Path $repoRoot "clients/proto/proto-inputs.json" $descriptorPath = Join-Path $repoRoot "clients/proto/descriptors/mxaccessgw-client-v1.protoset" diff --git a/scripts/run-client-e2e-tests.ps1 b/scripts/run-client-e2e-tests.ps1 index a5f2865..ed17a8f 100644 --- a/scripts/run-client-e2e-tests.ps1 +++ b/scripts/run-client-e2e-tests.ps1 @@ -1,3 +1,24 @@ +<# +.SYNOPSIS +Cross-language client e2e matrix for the MXAccess Gateway. + +.DESCRIPTION +Drives the .NET, Go, Rust, Python, and Java client CLIs against a running +gateway + worker. For each language the script exercises session open/close, +register, bulk subscribe/unsubscribe, per-tag add-item/advise, event +streaming, a write round-trip with value assertion, error-path (parity) +checks, and API-key auth rejection. With -VerifyAlarms it also exercises the +session-less stream-alarms and acknowledge-alarm subcommands against the +gateway's central alarm monitor. + +Each client CLI is driven through one long-lived `batch` process: the harness +writes one command line to its stdin and reads the JSON result back, so the +~250 operations per client pay the process (and JVM/runtime) cold-start once +instead of once per operation. + +The gateway and worker are assumed to be already running at -Endpoint; the +script does not start or stop them. +#> [CmdletBinding()] param( [string[]]$Clients = @("dotnet", "go", "rust", "python", "java"), @@ -17,10 +38,57 @@ param( [string]$Database = "ZB", [int]$EventLimit = 5, [int]$BulkTagCount = 6, + # The per-tag advise loop advises every discovered tag with no StreamEvents + # consumer attached, so MXAccess change events accumulate in the worker + # event channel (MxGateway:Events:QueueCapacity). Left unbounded the channel + # overflows under FailFast backpressure and faults the worker — slow, + # process-per-call clients (the Java CLI) hit this before the loop ends. + # Every DrainEveryTags advised tags the loop connects a short-lived + # StreamEvents drain so the gateway pumps that channel empty. 0 disables it. + [int]$DrainEveryTags = 15, [switch]$SkipStream, [switch]$SkipBulk, + # Skip the bulk read+write coverage that runs alongside the existing + # subscribe-bulk phase. The read-bulk phase confirms cached-path + # semantics against tags left advised by subscribe-bulk (was_cached + # = true); the write-bulk phase runs when -VerifyWrite is set and + # exercises the BulkWriteResult shape against the writable tag. + [switch]$SkipReadWriteBulk, + # Write round-trip. Opt-in because it mutates live tag state: it writes a + # sentinel value to -WriteAttribute and asserts an OnWriteComplete event + # confirms the write reached the MXAccess provider. + [switch]$VerifyWrite, + [string]$WriteAttribute = "TestChangingInt", + [string]$WriteType = "int32", + [int]$WriteValueBase = 424200, + [int]$WriteEchoMaxEvents = 200, + # Alarm feed + acknowledge coverage. Opt-in because it depends on the + # gateway's central alarm monitor being enabled (MxGateway:Alarms:Enabled) + # and a live alarm provider: stream-alarms reads the monitor's snapshot and + # acknowledge-alarm acknowledges -AlarmReference. Both RPCs are session-less + # — they exercise the gateway's always-on monitor, not a client session. + [switch]$VerifyAlarms, + [string]$AlarmReference = "Galaxy!TestArea.TestMachine_001.TestAlarm001", + # Messages to read from the central alarm feed. 1 is enough to confirm the + # subcommand round-trips: the feed's first message (an active-alarm + # snapshot, or snapshot-complete when no alarms are active) always arrives + # immediately, whereas later messages depend on live alarm transitions. + [int]$AlarmStreamMax = 1, + # Error-path (parity) checks. + [switch]$SkipParity, + # API-key auth rejection checks. + [switch]$SkipAuth, + # Optional env var holding an API key whose scopes are insufficient for + # open-session; when supplied the auth phase also asserts that key is + # rejected (PermissionDenied) on top of the always-on missing-key check. + [string]$RejectScopeApiKeyEnv, + # Run each language client concurrently as an isolated child process. + [switch]$Parallel, [switch]$DryRun, - [string]$ReportPath + [string]$ReportPath, + # Internal: set by -Parallel on each spawned child so it always writes its + # report (even under -DryRun) for the parent to merge. Not for direct use. + [switch]$EmitReport ) Set-StrictMode -Version Latest @@ -56,6 +124,18 @@ if ($BulkTagCount -lt 1) { throw "BulkTagCount must be greater than zero." } +if ($DrainEveryTags -lt 0) { + throw "DrainEveryTags cannot be negative." +} + +if ($WriteEchoMaxEvents -lt 1) { + throw "WriteEchoMaxEvents must be greater than zero." +} + +if ($AlarmStreamMax -lt 1) { + throw "AlarmStreamMax must be greater than zero." +} + foreach ($client in $Clients) { if ($validClients -notcontains $client) { throw "Unsupported client '$client'. Supported clients: $($validClients -join ', ')." @@ -116,7 +196,10 @@ function Invoke-NativeCommand { [string]$FilePath, [string[]]$Arguments, [string]$WorkingDirectory, - [hashtable]$Environment = @{} + [hashtable]$Environment = @{}, + # When set, a non-zero exit code is returned to the caller instead of + # throwing. Used by the parity and auth phases, which expect failure. + [switch]$AllowFailure ) $process = [System.Diagnostics.Process]::new() @@ -155,7 +238,7 @@ function Invoke-NativeCommand { stderr = $stderr } - if ($result.exitCode -ne 0) { + if ($result.exitCode -ne 0 -and -not $AllowFailure) { throw "Command failed with exit code $($result.exitCode): $commandLine`n$stderr`n$stdout" } @@ -243,6 +326,44 @@ function Get-StreamEventCount { } } +# Returns the per-event objects from a stream-events reply so the write +# round-trip can inspect their values. Mirrors Get-StreamEventCount: .NET, +# Rust, and Python aggregate under `events`; Go and Java emit one event +# object per line (Read-JsonObject collapses NDJSON into an array). +function Get-StreamEvents { + param( + [string]$Client, + [object]$Json + ) + + switch ($Client) { + "dotnet" { return @($Json.events) } + "go" { return @($Json) } + "rust" { return @($Json.events) } + "python" { return @($Json.events) } + "java" { return @($Json) } + } +} + +# Counts the messages in a stream-alarms reply. The CLIs shape the aggregate +# JSON differently: .NET nests them under `alarms`, Rust under `messages` with +# a `messageCount`, Python under `messages`; Go and Java emit one AlarmFeedMessage +# object per line (Read-JsonObject collapses NDJSON into a bare array). +function Get-AlarmMessageCount { + param( + [string]$Client, + [object]$Json + ) + + switch ($Client) { + "dotnet" { return @($Json.alarms).Count } + "go" { return @($Json).Count } + "rust" { return [int]$Json.messageCount } + "python" { return @($Json.messages).Count } + "java" { return @($Json).Count } + } +} + function Get-PropertyValue { param( [object]$Object, @@ -263,6 +384,71 @@ function Get-PropertyValue { return $null } +# Extracts the item handle from a streamed event, tolerating camelCase +# (protobuf-JSON) and snake_case (some MessageToDict shapes). +function Get-EventItemHandle { + param([object]$Event) + + $handle = Get-PropertyValue -Object $Event -Names @("itemHandle", "item_handle") + if ($null -eq $handle) { + return $null + } + + return [int]$handle +} + +# Extracts the event family as a string. All five CLIs render it as the +# protobuf enum name (e.g. MX_EVENT_FAMILY_ON_WRITE_COMPLETE). +function Get-EventFamily { + param([object]$Event) + + return [string](Get-PropertyValue -Object $Event -Names @("family")) +} + +# Extracts the scalar payload from a streamed event's MxValue as a string. +# The MxValue oneof renders to one protobuf-JSON `*Value` key; all five +# CLIs (after the Rust stream-events extension) emit the same key names. +function Get-EventScalar { + param([object]$Event) + + $value = Get-PropertyValue -Object $Event -Names @("value") + if ($null -eq $value) { + return $null + } + + foreach ($key in @("boolValue", "int32Value", "int64Value", "floatValue", "doubleValue", "stringValue")) { + $property = $value.PSObject.Properties[$key] + if ($null -ne $property -and $null -ne $property.Value) { + return [string]$property.Value + } + } + + return $null +} + +# Compares a written value against an observed event scalar. Numeric values +# are compared numerically (so 42 matches 42.0); everything else compares as +# a trimmed, case-insensitive string. +function Test-ValueEquals { + param( + [string]$Expected, + [string]$Observed + ) + + if ([string]::IsNullOrWhiteSpace($Observed)) { + return $false + } + + $expectedNumber = 0.0 + $observedNumber = 0.0 + if ([double]::TryParse($Expected, [ref]$expectedNumber) -and + [double]::TryParse($Observed, [ref]$observedNumber)) { + return $expectedNumber -eq $observedNumber + } + + return [string]::Equals($Expected.Trim(), $Observed.Trim(), [StringComparison]::OrdinalIgnoreCase) +} + function Get-BulkResults { param( [string]$Client, @@ -274,7 +460,18 @@ function Get-BulkResults { return @(Get-PropertyValue -Object $Json -Names @("results")) } - $replyName = if ($Operation -eq "subscribe-bulk") { "subscribeBulk" } else { "unsubscribeBulk" } + # .NET emits the full MxCommandReply via protobuf JSON, with results + # nested under a per-command field name. + $replyName = switch ($Operation) { + "subscribe-bulk" { "subscribeBulk" } + "unsubscribe-bulk" { "unsubscribeBulk" } + "read-bulk" { "readBulk" } + "write-bulk" { "writeBulk" } + "write2-bulk" { "write2Bulk" } + "write-secured-bulk" { "writeSecuredBulk" } + "write-secured2-bulk" { "writeSecured2Bulk" } + default { $Operation } + } $reply = Get-PropertyValue -Object $Json -Names @($replyName) return @(Get-PropertyValue -Object $reply -Names @("results")) } @@ -311,24 +508,73 @@ function Assert-BulkResults { } } +# Builds the dotnet and Java client CLIs once up front and records the path to +# each compiled artifact, so the long-lived `batch` process is launched from +# the compiled exe / installed launcher without paying a `dotnet build` or +# `gradle` step at flow time. The Go, Rust, and Python batch processes are +# launched via `go run` / `cargo run` / `python -m`, which compile-or-start +# once when that single per-client process starts. +function Initialize-ClientBuilds { + if ($Clients -contains "dotnet") { + $cliProject = Join-Path $repoRoot "clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/ZB.MOM.WW.MxGateway.Client.Cli.csproj" + $script:dotnetCliExe = Join-Path $repoRoot ` + "clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/bin/Debug/net10.0/ZB.MOM.WW.MxGateway.Client.Cli.exe" + if (-not $DryRun) { + Write-Host "Building the .NET client CLI once: $cliProject" + Invoke-NativeCommand -FilePath "dotnet" ` + -Arguments @("build", $cliProject, "-c", "Debug", "--nologo", "-v", "quiet") ` + -WorkingDirectory $repoRoot | Out-Null + if (-not (Test-Path $script:dotnetCliExe)) { + throw "The .NET client CLI build did not produce '$script:dotnetCliExe'." + } + } + } + + if ($Clients -contains "java") { + $script:javaCliBat = Join-Path $repoRoot ` + "clients/java/mxgateway-cli/build/install/mxgateway-cli/bin/mxgateway-cli.bat" + if (-not $DryRun) { + $gradleCommand = Get-Command "gradle.bat", "gradle.cmd", "gradle.exe", "gradle" ` + -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($null -eq $gradleCommand) { + throw "The 'gradle' command was not found on PATH; the Java client e2e flow requires Gradle." + } + Write-Host "Installing the Java client CLI once via :mxgateway-cli:installDist" + Invoke-NativeCommand -FilePath "cmd.exe" ` + -Arguments @("/c", $gradleCommand.Source, "--quiet", ":mxgateway-cli:installDist") ` + -WorkingDirectory (Join-Path $repoRoot "clients/java") | Out-Null + if (-not (Test-Path $script:javaCliBat)) { + throw "The Java client CLI install did not produce '$script:javaCliBat'." + } + } + } +} + function Get-ClientCommand { param( [string]$Client, [string]$Operation, - [hashtable]$Values + [hashtable]$Values, + # The environment variable the client reads the API key from. Defaults + # to the run-wide -ApiKeyEnv; the auth phase overrides it to drive a + # missing-key or insufficient-scope rejection. + [string]$ApiKeyEnvName = $ApiKeyEnv ) $httpEndpoint = ConvertTo-HttpEndpoint -Value $Endpoint $hostEndpoint = ConvertTo-HostEndpoint -Value $Endpoint $clientName = "mxgw-$Client-e2e" + $streamMaxEvents = if ($Values.ContainsKey("maxEvents")) { [int]$Values.maxEvents } else { $EventLimit } + # Python's stream-events call ends on a wall-clock timeout; give it enough + # headroom to drain a large write-echo budget. + $pythonStreamTimeout = [Math]::Max(15, [int][Math]::Ceiling($streamMaxEvents / 4.0)) switch ($Client) { "dotnet" { $arguments = @( - "run", "--project", "clients/dotnet/MxGateway.Client.Cli", "--", $Operation, "--endpoint", $httpEndpoint, - "--api-key-env", $ApiKeyEnv, + "--api-key-env", $ApiKeyEnvName, "--timeout", "60s", "--json" ) @@ -344,18 +590,34 @@ function Get-ClientCommand { $arguments += @("--session-id", $Values.sessionId, "--server-handle", "$($Values.serverHandle)", "--items", $Values.items) } elseif ($Operation -eq "unsubscribe-bulk") { $arguments += @("--session-id", $Values.sessionId, "--server-handle", "$($Values.serverHandle)", "--item-handles", $Values.itemHandles) + } elseif ($Operation -eq "read-bulk") { + $arguments += @("--session-id", $Values.sessionId, "--server-handle", "$($Values.serverHandle)", "--items", $Values.items) + if ($Values.ContainsKey("timeoutMs")) { $arguments += @("--timeout-ms", "$($Values.timeoutMs)") } + } elseif ($Operation -eq "write-bulk") { + $arguments += @("--session-id", $Values.sessionId, "--server-handle", "$($Values.serverHandle)", + "--item-handles", $Values.itemHandles, "--type", $Values.valueType, "--values", $Values.values) + if ($Values.ContainsKey("userId")) { $arguments += @("--user-id", "$($Values.userId)") } + } elseif ($Operation -eq "write") { + $arguments += @("--session-id", $Values.sessionId, "--server-handle", "$($Values.serverHandle)", "--item-handle", "$($Values.itemHandle)", "--type", $Values.valueType, "--value", $Values.value) } elseif ($Operation -eq "stream-events") { - $arguments += @("--session-id", $Values.sessionId, "--max-events", "$EventLimit") + $arguments += @("--session-id", $Values.sessionId, "--max-events", "$streamMaxEvents") + } elseif ($Operation -eq "stream-alarms") { + $arguments += @("--max-events", "$streamMaxEvents") + if ($Values.ContainsKey("filterPrefix")) { $arguments += @("--filter-prefix", $Values.filterPrefix) } + } elseif ($Operation -eq "acknowledge-alarm") { + $arguments += @("--reference", $Values.alarmReference) + if ($Values.ContainsKey("comment")) { $arguments += @("--comment", $Values.comment) } + if ($Values.ContainsKey("operator")) { $arguments += @("--operator", $Values.operator) } } elseif ($Operation -eq "close-session") { $arguments += @("--session-id", $Values.sessionId) } - return [pscustomobject]@{ file = "dotnet"; args = $arguments; cwd = $repoRoot; env = @{} } + return [pscustomobject]@{ file = $script:dotnetCliExe; args = $arguments; cwd = $repoRoot; env = @{} } } "go" { $arguments = @( "run", "./cmd/mxgw-go", $Operation, "-endpoint", $hostEndpoint, - "-api-key-env", $ApiKeyEnv, + "-api-key-env", $ApiKeyEnvName, "-plaintext", "-json" ) @@ -371,8 +633,24 @@ function Get-ClientCommand { $arguments += @("-session-id", $Values.sessionId, "-server-handle", "$($Values.serverHandle)", "-items", $Values.items) } elseif ($Operation -eq "unsubscribe-bulk") { $arguments += @("-session-id", $Values.sessionId, "-server-handle", "$($Values.serverHandle)", "-item-handles", $Values.itemHandles) + } elseif ($Operation -eq "read-bulk") { + $arguments += @("-session-id", $Values.sessionId, "-server-handle", "$($Values.serverHandle)", "-items", $Values.items) + if ($Values.ContainsKey("timeoutMs")) { $arguments += @("-timeout-ms", "$($Values.timeoutMs)") } + } elseif ($Operation -eq "write-bulk") { + $arguments += @("-session-id", $Values.sessionId, "-server-handle", "$($Values.serverHandle)", + "-item-handles", $Values.itemHandles, "-type", $Values.valueType, "-values", $Values.values) + if ($Values.ContainsKey("userId")) { $arguments += @("-user-id", "$($Values.userId)") } + } elseif ($Operation -eq "write") { + $arguments += @("-session-id", $Values.sessionId, "-server-handle", "$($Values.serverHandle)", "-item-handle", "$($Values.itemHandle)", "-type", $Values.valueType, "-value", $Values.value) } elseif ($Operation -eq "stream-events") { - $arguments += @("-session-id", $Values.sessionId, "-limit", "$EventLimit") + $arguments += @("-session-id", $Values.sessionId, "-limit", "$streamMaxEvents") + } elseif ($Operation -eq "stream-alarms") { + $arguments += @("-limit", "$streamMaxEvents") + if ($Values.ContainsKey("filterPrefix")) { $arguments += @("-filter-prefix", $Values.filterPrefix) } + } elseif ($Operation -eq "acknowledge-alarm") { + $arguments += @("-reference", $Values.alarmReference) + if ($Values.ContainsKey("comment")) { $arguments += @("-comment", $Values.comment) } + if ($Values.ContainsKey("operator")) { $arguments += @("-operator", $Values.operator) } } elseif ($Operation -eq "close-session") { $arguments += @("-session-id", $Values.sessionId) } @@ -382,7 +660,7 @@ function Get-ClientCommand { $arguments = @( "run", "-p", "mxgw-cli", "--", $Operation, "--endpoint", $httpEndpoint, - "--api-key-env", $ApiKeyEnv, + "--api-key-env", $ApiKeyEnvName, "--json" ) if ($Operation -eq "open-session") { @@ -397,8 +675,26 @@ function Get-ClientCommand { $arguments += @("--session-id", $Values.sessionId, "--server-handle", "$($Values.serverHandle)", "--items", $Values.items) } elseif ($Operation -eq "unsubscribe-bulk") { $arguments += @("--session-id", $Values.sessionId, "--server-handle", "$($Values.serverHandle)", "--item-handles", $Values.itemHandles) + } elseif ($Operation -eq "read-bulk") { + $arguments += @("--session-id", $Values.sessionId, "--server-handle", "$($Values.serverHandle)", "--items", $Values.items) + if ($Values.ContainsKey("timeoutMs")) { $arguments += @("--timeout-ms", "$($Values.timeoutMs)") } + } elseif ($Operation -eq "write-bulk") { + # Rust uses --value-type for the type flag. + $arguments += @("--session-id", $Values.sessionId, "--server-handle", "$($Values.serverHandle)", + "--item-handles", $Values.itemHandles, "--value-type", $Values.valueType, "--values", $Values.values) + if ($Values.ContainsKey("userId")) { $arguments += @("--user-id", "$($Values.userId)") } + } elseif ($Operation -eq "write") { + # Rust names the type flag --value-type, unlike the other CLIs. + $arguments += @("--session-id", $Values.sessionId, "--server-handle", "$($Values.serverHandle)", "--item-handle", "$($Values.itemHandle)", "--value-type", $Values.valueType, "--value", $Values.value) } elseif ($Operation -eq "stream-events") { - $arguments += @("--session-id", $Values.sessionId, "--max-events", "$EventLimit") + $arguments += @("--session-id", $Values.sessionId, "--max-events", "$streamMaxEvents") + } elseif ($Operation -eq "stream-alarms") { + $arguments += @("--max-events", "$streamMaxEvents") + if ($Values.ContainsKey("filterPrefix")) { $arguments += @("--filter-prefix", $Values.filterPrefix) } + } elseif ($Operation -eq "acknowledge-alarm") { + $arguments += @("--reference", $Values.alarmReference) + if ($Values.ContainsKey("comment")) { $arguments += @("--comment", $Values.comment) } + if ($Values.ContainsKey("operator")) { $arguments += @("--operator", $Values.operator) } } elseif ($Operation -eq "close-session") { $arguments += @("--session-id", $Values.sessionId) } @@ -408,7 +704,7 @@ function Get-ClientCommand { $arguments = @( "-m", "mxgateway_cli", $Operation, "--endpoint", $hostEndpoint, - "--api-key-env", $ApiKeyEnv, + "--api-key-env", $ApiKeyEnvName, "--plaintext", "--json" ) @@ -424,8 +720,24 @@ function Get-ClientCommand { $arguments += @("--session-id", $Values.sessionId, "--server-handle", "$($Values.serverHandle)", "--items", $Values.items) } elseif ($Operation -eq "unsubscribe-bulk") { $arguments += @("--session-id", $Values.sessionId, "--server-handle", "$($Values.serverHandle)", "--item-handles", $Values.itemHandles) + } elseif ($Operation -eq "read-bulk") { + $arguments += @("--session-id", $Values.sessionId, "--server-handle", "$($Values.serverHandle)", "--items", $Values.items) + if ($Values.ContainsKey("timeoutMs")) { $arguments += @("--timeout-ms", "$($Values.timeoutMs)") } + } elseif ($Operation -eq "write-bulk") { + $arguments += @("--session-id", $Values.sessionId, "--server-handle", "$($Values.serverHandle)", + "--item-handles", $Values.itemHandles, "--type", $Values.valueType, "--values", $Values.values) + if ($Values.ContainsKey("userId")) { $arguments += @("--user-id", "$($Values.userId)") } + } elseif ($Operation -eq "write") { + $arguments += @("--session-id", $Values.sessionId, "--server-handle", "$($Values.serverHandle)", "--item-handle", "$($Values.itemHandle)", "--type", $Values.valueType, "--value", $Values.value) } elseif ($Operation -eq "stream-events") { - $arguments += @("--session-id", $Values.sessionId, "--max-events", "$EventLimit", "--timeout", "15") + $arguments += @("--session-id", $Values.sessionId, "--max-events", "$streamMaxEvents", "--timeout", "$pythonStreamTimeout") + } elseif ($Operation -eq "stream-alarms") { + $arguments += @("--max-messages", "$streamMaxEvents", "--timeout", "$pythonStreamTimeout") + if ($Values.ContainsKey("filterPrefix")) { $arguments += @("--filter-prefix", $Values.filterPrefix) } + } elseif ($Operation -eq "acknowledge-alarm") { + $arguments += @("--reference", $Values.alarmReference) + if ($Values.ContainsKey("comment")) { $arguments += @("--comment", $Values.comment) } + if ($Values.ContainsKey("operator")) { $arguments += @("--operator", $Values.operator) } } elseif ($Operation -eq "close-session") { $arguments += @("--session-id", $Values.sessionId) } @@ -438,7 +750,7 @@ function Get-ClientCommand { $cliArgs = @( $Operation, "--endpoint", $hostEndpoint, - "--api-key-env", $ApiKeyEnv, + "--api-key-env", $ApiKeyEnvName, "--plaintext", "--json" ) @@ -454,54 +766,882 @@ function Get-ClientCommand { $cliArgs += @("--session-id", $Values.sessionId, "--server-handle", "$($Values.serverHandle)", "--items", $Values.items) } elseif ($Operation -eq "unsubscribe-bulk") { $cliArgs += @("--session-id", $Values.sessionId, "--server-handle", "$($Values.serverHandle)", "--item-handles", $Values.itemHandles) + } elseif ($Operation -eq "read-bulk") { + $cliArgs += @("--session-id", $Values.sessionId, "--server-handle", "$($Values.serverHandle)", "--items", $Values.items) + if ($Values.ContainsKey("timeoutMs")) { $cliArgs += @("--timeout-ms", "$($Values.timeoutMs)") } + } elseif ($Operation -eq "write-bulk") { + $cliArgs += @("--session-id", $Values.sessionId, "--server-handle", "$($Values.serverHandle)", + "--item-handles", $Values.itemHandles, "--type", $Values.valueType, "--values", $Values.values) + if ($Values.ContainsKey("userId")) { $cliArgs += @("--user-id", "$($Values.userId)") } + } elseif ($Operation -eq "write") { + $cliArgs += @("--session-id", $Values.sessionId, "--server-handle", "$($Values.serverHandle)", "--item-handle", "$($Values.itemHandle)", "--type", $Values.valueType, "--value", $Values.value) } elseif ($Operation -eq "stream-events") { - $cliArgs += @("--session-id", $Values.sessionId, "--limit", "$EventLimit") + $cliArgs += @("--session-id", $Values.sessionId, "--limit", "$streamMaxEvents") + } elseif ($Operation -eq "stream-alarms") { + $cliArgs += @("--limit", "$streamMaxEvents") + if ($Values.ContainsKey("filterPrefix")) { $cliArgs += @("--filter-prefix", $Values.filterPrefix) } + } elseif ($Operation -eq "acknowledge-alarm") { + $cliArgs += @("--reference", $Values.alarmReference) + if ($Values.ContainsKey("comment")) { $cliArgs += @("--comment", $Values.comment) } + if ($Values.ContainsKey("operator")) { $cliArgs += @("--operator", $Values.operator) } } elseif ($Operation -eq "close-session") { $cliArgs += @("--session-id", $Values.sessionId) } - $arguments = @("--quiet", ":mxgateway-cli:run", "--args=$($cliArgs -join ' ')") - return [pscustomobject]@{ file = "gradle"; args = $arguments; cwd = (Join-Path $repoRoot "clients/java"); env = @{} } + # The Java CLI is installed once up front (gradle + # :mxgateway-cli:installDist) so each call runs the generated + # launcher script directly instead of paying Gradle configuration + # plus a JVM cold-start per invocation. .NET's Process.Start + # (UseShellExecute=false) cannot launch a .bat directly, so the + # launcher runs through cmd.exe. + return [pscustomobject]@{ + file = "cmd.exe" + args = @("/c", $script:javaCliBat) + $cliArgs + cwd = (Join-Path $repoRoot "clients/java") + env = @{} + } } } } +# Synthesizes a dry-run JSON reply for an operation so the flow can be +# validated without a live gateway. +function Get-DryRunReply { + param( + [string]$Client, + [string]$Operation, + [hashtable]$Values + ) + + switch ($Operation) { + "open-session" { return [pscustomobject]@{ sessionId = "dry-run-session-$Client"; reply = [pscustomobject]@{ sessionId = "dry-run-session-$Client" } } } + "register" { return [pscustomobject]@{ serverHandle = 1; register = [pscustomobject]@{ serverHandle = 1 }; reply = [pscustomobject]@{ register = [pscustomobject]@{ serverHandle = 1 } } } } + "add-item" { return [pscustomobject]@{ itemHandle = 1; addItem = [pscustomobject]@{ itemHandle = 1 }; reply = [pscustomobject]@{ addItem = [pscustomobject]@{ itemHandle = 1 } } } } + "write" { return [pscustomobject]@{ ok = $true; operation = "write"; reply = [pscustomobject]@{} } } + "subscribe-bulk" { + $results = @($Values.items -split "," | ForEach-Object -Begin { $index = 1 } -Process { + [pscustomobject]@{ itemHandle = $index++; tagAddress = $_; wasSuccessful = $true } + }) + return [pscustomobject]@{ subscribeBulk = [pscustomobject]@{ results = $results }; results = $results } + } + "unsubscribe-bulk" { + $results = @($Values.itemHandles -split "," | ForEach-Object { + [pscustomobject]@{ itemHandle = [int]$_; wasSuccessful = $true } + }) + return [pscustomobject]@{ unsubscribeBulk = [pscustomobject]@{ results = $results }; results = $results } + } + "read-bulk" { + $results = @($Values.items -split "," | ForEach-Object -Begin { $index = 1 } -Process { + [pscustomobject]@{ + itemHandle = $index++ + tagAddress = $_ + wasSuccessful = $true + wasCached = $true + } + }) + return [pscustomobject]@{ readBulk = [pscustomobject]@{ results = $results }; results = $results } + } + "write-bulk" { + $results = @($Values.itemHandles -split "," | ForEach-Object { + [pscustomobject]@{ itemHandle = [int]$_; wasSuccessful = $true } + }) + return [pscustomobject]@{ writeBulk = [pscustomobject]@{ results = $results }; results = $results } + } + "stream-events" { + # Synthesize an OnDataChange (carrying the written value) and an + # OnWriteComplete so the write round-trip assertion passes under + # -DryRun. The reply is shaped per client: Go and Java emit one + # event object per line (Read-JsonObject collapses NDJSON to a + # bare array), the others aggregate the events under `events`. + $itemHandle = if ($Values.ContainsKey("echoItemHandle")) { [int]$Values.echoItemHandle } else { 1 } + $echoValue = if ($Values.ContainsKey("echoValue")) { $Values.echoValue } else { 1 } + $dataEvent = [pscustomobject]@{ + workerSequence = 1 + family = "MX_EVENT_FAMILY_ON_DATA_CHANGE" + itemHandle = $itemHandle + value = [pscustomobject]@{ int32Value = $echoValue } + } + $writeCompleteEvent = [pscustomobject]@{ + workerSequence = 2 + family = "MX_EVENT_FAMILY_ON_WRITE_COMPLETE" + itemHandle = $itemHandle + } + $events = @($dataEvent, $writeCompleteEvent) + switch ($Client) { + "go" { return ,$events } + "java" { return ,$events } + "rust" { return [pscustomobject]@{ eventCount = $events.Count; events = $events } } + default { return [pscustomobject]@{ events = $events } } + } + } + "stream-alarms" { + # Synthesize an active-alarm snapshot followed by the + # snapshot-complete sentinel. The reply is shaped per client: + # Go and Java emit one message object per line (Read-JsonObject + # collapses NDJSON to a bare array), Rust aggregates under + # `messages` with a `messageCount`, Python under `messages`, and + # .NET under `alarms`. + $activeAlarm = [pscustomobject]@{ + activeAlarm = [pscustomobject]@{ + alarmFullReference = "Galaxy!TestArea.TestMachine_001.TestAlarm001" + currentState = "ALARM_CONDITION_STATE_ACTIVE" + severity = 500 + } + } + $snapshotComplete = [pscustomobject]@{ snapshotComplete = $true } + $messages = @($activeAlarm, $snapshotComplete) + switch ($Client) { + "go" { return ,$messages } + "java" { return ,$messages } + "rust" { return [pscustomobject]@{ messageCount = $messages.Count; messages = $messages } } + "dotnet" { return [pscustomobject]@{ alarms = $messages } } + default { return [pscustomobject]@{ messages = $messages } } + } + } + "acknowledge-alarm" { + return [pscustomobject]@{ + rawReply = [pscustomobject]@{ hresult = 0; diagnosticMessage = "dry-run ack" } + reply = [pscustomobject]@{ hresult = 0 } + } + } + default { return [pscustomobject]@{ ok = $true; reply = [pscustomobject]@{} } } + } +} + +# --- Batch-mode client process --------------------------------------------- +# The e2e flow issues ~250 operations per client. Spawning one CLI process per +# operation pays a process — and, for the JVM, a runtime — cold-start every +# time. Instead each client CLI exposes a `batch` subcommand: a single +# long-lived process that reads one command line from stdin, runs it, writes +# the JSON result, then a line containing exactly $batchTerminator. The harness +# drives that one process per client, so startup is paid once. +$script:batchTerminator = "__MXGW_BATCH_EOR__" +$script:currentBatchClient = $null + +# A redirected child's StandardInput writer is created with Console.InputEncoding, +# which is UTF-8 *with a BOM* on this host. The writer then prepends that BOM to +# the first bytes it sends, and the CLIs parse it into their first argument. +# Switching the console input encoding to a BOM-less encoding before any batch +# process starts makes that writer BOM-free. The e2e command lines are ASCII. +try { + [Console]::InputEncoding = [System.Text.Encoding]::ASCII +} catch { + Write-Warning "Could not set a BOM-less console input encoding: $($_.Exception.Message)" +} + +# Derives the `batch`-process launch spec from Get-ClientCommand: the launch +# prefix is whatever precedes the operation token (e.g. `run -p mxgw-cli --`), +# with the operation itself replaced by `batch`. +function Get-BatchLaunchSpec { + param([string]$Client) + + $command = Get-ClientCommand -Client $Client -Operation "open-session" -Values @{} -ApiKeyEnvName $ApiKeyEnv + $argList = [object[]]$command.args + $operationIndex = [Array]::IndexOf($argList, "open-session") + if ($operationIndex -lt 0) { + throw "Cannot locate the operation token in the '$Client' command line." + } + $prefix = if ($operationIndex -gt 0) { @($argList[0..($operationIndex - 1)]) } else { @() } + return [pscustomobject]@{ + file = $command.file + args = @($prefix + "batch") + cwd = $command.cwd + env = $command.env + } +} + +# Returns just the operation arguments (operation token + flags) for a client +# command, stripping the launch prefix — this is the line written to the batch +# process for one operation. +function Get-ClientOperationArgs { + param( + [string]$Client, + [string]$Operation, + [hashtable]$Values, + [string]$ApiKeyEnvName = $ApiKeyEnv + ) + + $command = Get-ClientCommand -Client $Client -Operation $Operation -Values $Values -ApiKeyEnvName $ApiKeyEnvName + $argList = [object[]]$command.args + $operationIndex = [Array]::IndexOf($argList, $Operation) + if ($operationIndex -le 0) { + return @($argList) + } + return @($argList[$operationIndex..($argList.Count - 1)]) +} + +# True when a parsed command reply is the CLI's failure envelope rather than a +# normal result. All five CLIs emit a top-level `error` field on failure. +function Test-OperationFailed { + param([object]$Json) + + if ($null -eq $Json) { + return $true + } + $errorValue = Get-PropertyValue -Object $Json -Names @("error") + return -not [string]::IsNullOrEmpty([string]$errorValue) +} + +# Starts the long-lived `batch` process for a client and returns a handle +# carrying the process and its redirected stdin/stdout streams. +function Start-BatchClient { + param([string]$Client) + + $spec = Get-BatchLaunchSpec -Client $Client + $startInfo = [System.Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = $spec.file + $startInfo.Arguments = ($spec.args | ForEach-Object { ConvertTo-NativeArgument -Value $_ }) -join " " + $startInfo.WorkingDirectory = $spec.cwd + $startInfo.RedirectStandardInput = $true + $startInfo.RedirectStandardOutput = $true + # stderr is left attached to the console: the CLIs only log diagnostics + # there, and not redirecting it removes any risk of the child blocking on a + # full stderr pipe while the harness reads stdout. + $startInfo.RedirectStandardError = $false + $startInfo.UseShellExecute = $false + foreach ($entry in $spec.env.GetEnumerator()) { + $startInfo.Environment[$entry.Key] = [string]$entry.Value + } + + $process = [System.Diagnostics.Process]::new() + $process.StartInfo = $startInfo + [void]$process.Start() + return [pscustomobject]@{ client = $Client; process = $process; input = $process.StandardInput } +} + +# Sends one operation to a batch process and returns its raw JSON output text +# (everything written before the terminator line). +function Invoke-BatchOperation { + param( + [pscustomobject]$BatchClient, + [string]$Client, + [string]$Operation, + [hashtable]$Values, + [string]$ApiKeyEnvName + ) + + $operationArgs = Get-ClientOperationArgs -Client $Client -Operation $Operation ` + -Values $Values -ApiKeyEnvName $ApiKeyEnvName + $process = $BatchClient.process + $BatchClient.input.WriteLine(($operationArgs -join " ")) + $BatchClient.input.Flush() + + $builder = [System.Text.StringBuilder]::new() + while ($true) { + $line = $process.StandardOutput.ReadLine() + if ($null -eq $line) { + throw ("Batch client '$Client' closed its output before terminating operation " + + "'$Operation' (process exited: $($process.HasExited)).") + } + if ($line -eq $script:batchTerminator) { + break + } + [void]$builder.AppendLine($line) + } + return $builder.ToString() +} + +# Signals end-of-input to a batch process and waits for it to exit. +function Stop-BatchClient { + param([pscustomobject]$BatchClient) + + if ($null -eq $BatchClient) { + return + } + $process = $BatchClient.process + try { + if (-not $process.HasExited) { + $BatchClient.input.Close() + if (-not $process.WaitForExit(15000)) { + $process.Kill($true) + } + } + } catch { + try { $process.Kill($true) } catch { } + } finally { + $process.Dispose() + } +} + function Invoke-ClientOperation { param( [string]$Client, [string]$Operation, - [hashtable]$Values = @{} + [hashtable]$Values = @{}, + [string]$ApiKeyEnvName = $ApiKeyEnv ) - $command = Get-ClientCommand -Client $Client -Operation $Operation -Values $Values - $result = Invoke-NativeCommand ` - -FilePath $command.file ` - -Arguments $command.args ` - -WorkingDirectory $command.cwd ` - -Environment $command.env if ($DryRun) { - switch ($Operation) { - "open-session" { return [pscustomobject]@{ sessionId = "dry-run-session-$Client"; reply = [pscustomobject]@{ sessionId = "dry-run-session-$Client" } } } - "register" { return [pscustomobject]@{ serverHandle = 1; register = [pscustomobject]@{ serverHandle = 1 }; reply = [pscustomobject]@{ register = [pscustomobject]@{ serverHandle = 1 } } } } - "add-item" { return [pscustomobject]@{ itemHandle = 1; addItem = [pscustomobject]@{ itemHandle = 1 }; reply = [pscustomobject]@{ addItem = [pscustomobject]@{ itemHandle = 1 } } } } - "subscribe-bulk" { - $results = @($Values.items -split "," | ForEach-Object -Begin { $index = 1 } -Process { - [pscustomobject]@{ itemHandle = $index++; tagAddress = $_; wasSuccessful = $true } + $operationArgs = Get-ClientOperationArgs -Client $Client -Operation $Operation ` + -Values $Values -ApiKeyEnvName $ApiKeyEnvName + Write-Host "[dry-run] (batch:$Client) $($operationArgs -join ' ')" + return Get-DryRunReply -Client $Client -Operation $Operation -Values $Values + } + + $stdout = Invoke-BatchOperation -BatchClient $script:currentBatchClient -Client $Client ` + -Operation $Operation -Values $Values -ApiKeyEnvName $ApiKeyEnvName + $json = Read-JsonObject -Text $stdout + if (Test-OperationFailed -Json $json) { + $errorValue = Get-PropertyValue -Object $json -Names @("error") + throw "$Client $Operation failed: $errorValue" + } + return $json +} + +# Runs a client operation that is expected to fail. Returns a record whose +# `failed` flag is true when the CLI reported its failure envelope. Under +# -DryRun a synthetic failure is returned so the parity and auth phases can be +# exercised offline. +function Invoke-ClientOperationExpectingFailure { + param( + [string]$Client, + [string]$Operation, + [hashtable]$Values = @{}, + [string]$ApiKeyEnvName = $ApiKeyEnv + ) + + if ($DryRun) { + $operationArgs = Get-ClientOperationArgs -Client $Client -Operation $Operation ` + -Values $Values -ApiKeyEnvName $ApiKeyEnvName + Write-Host "[dry-run] (batch:$Client) $($operationArgs -join ' ')" + return [pscustomobject]@{ failed = $true; json = $null } + } + + $stdout = Invoke-BatchOperation -BatchClient $script:currentBatchClient -Client $Client ` + -Operation $Operation -Values $Values -ApiKeyEnvName $ApiKeyEnvName + $json = Read-JsonObject -Text $stdout + return [pscustomobject]@{ failed = (Test-OperationFailed -Json $json); json = $json } +} + +# Connects a short-lived StreamEvents consumer so the gateway empties the worker +# event channel. The per-tag advise loop advises every discovered tag with no +# consumer attached; without periodic draining the worker event channel +# (MxGateway:Events:QueueCapacity) overflows under FailFast backpressure and +# faults the worker. +# +# A small bounded read is enough: the gateway's per-stream producer +# (EventStreamService.ProduceEventsAsync) races ahead of the CLI and pulls the +# entire worker event channel into its own buffer the instant a subscriber +# attaches, so the channel is emptied long before the CLI finishes reading +# these events. Run via the expecting-failure path so the drain's exit code is +# ignored — its purpose is the side effect (emptying the channel), not output. +function Invoke-EventDrain { + param( + [string]$Client, + [string]$SessionId + ) + + Invoke-ClientOperationExpectingFailure -Client $Client -Operation "stream-events" -Values @{ + sessionId = $SessionId + maxEvents = 200 + } | Out-Null +} + +# Runs the full e2e flow for a single language client and returns the result +# record. Discovered tags are passed in so the (slow) SQL discovery runs once. +function Invoke-ClientFlow { + param( + [string]$Client, + [object[]]$Tags + ) + + Write-Host "Running $Client client e2e flow against $($Tags.Count) discovered tags." + $sessionId = $null + $serverHandle = $null + $clientResult = [ordered]@{ + language = $Client + sessionId = $null + serverHandle = $null + bulk = $null + addedItems = @() + eventCount = 0 + write = $null + alarms = $null + parity = @() + auth = @() + closed = $false + error = $null + } + + try { + if (-not $DryRun) { + $script:currentBatchClient = Start-BatchClient -Client $Client + } + + $openJson = Invoke-ClientOperation -Client $Client -Operation "open-session" + $sessionId = Get-OpenSessionId -Client $Client -Json $openJson + if ([string]::IsNullOrWhiteSpace($sessionId)) { + throw "The $Client open-session command did not return a session id." + } + $clientResult.sessionId = $sessionId + + $registerJson = Invoke-ClientOperation -Client $Client -Operation "register" -Values @{ + sessionId = $sessionId + } + $serverHandle = Get-ServerHandle -Client $Client -Json $registerJson + $clientResult.serverHandle = $serverHandle + + # --- Write round-trip + value assertion --------------------------- + # Runs right after register, before the bulk and add-item phases, so + # only a small backlog of events precedes the write. The gateway + # replays the per-session event buffer from the start, so the + # post-write OnWriteComplete must be reachable within the bounded + # -WriteEchoMaxEvents window. + if ($VerifyWrite) { + $writeTag = @($Tags | Where-Object { + $_.attributeName -eq $WriteAttribute + }) | Select-Object -First 1 + + if ($null -eq $writeTag) { + Write-Warning "$Client write phase skipped: no discovered tag has attribute '$WriteAttribute'." + } else { + $writeAddJson = Invoke-ClientOperation -Client $Client -Operation "add-item" -Values @{ + sessionId = $sessionId + serverHandle = $serverHandle + item = $writeTag.fullTagReference + } + $writeItemHandle = Get-ItemHandle -Client $Client -Json $writeAddJson + Invoke-ClientOperation -Client $Client -Operation "advise" -Values @{ + sessionId = $sessionId + serverHandle = $serverHandle + itemHandle = $writeItemHandle + } | Out-Null + + $sentinelValue = "$($WriteValueBase + $script:clientFlowIndex)" + Invoke-ClientOperation -Client $Client -Operation "write" -Values @{ + sessionId = $sessionId + serverHandle = $serverHandle + itemHandle = $writeItemHandle + valueType = $WriteType + value = $sentinelValue + } | Out-Null + + $writeStreamJson = Invoke-ClientOperation -Client $Client -Operation "stream-events" -Values @{ + sessionId = $sessionId + maxEvents = $WriteEchoMaxEvents + echoItemHandle = $writeItemHandle + echoValue = $sentinelValue + } + $writeEvents = @(Get-StreamEvents -Client $Client -Json $writeStreamJson) + $writeItemEvents = @($writeEvents | Where-Object { + (Get-EventItemHandle -Event $_) -eq $writeItemHandle }) - return [pscustomobject]@{ subscribeBulk = [pscustomobject]@{ results = $results }; results = $results } + + # The reliable write round-trip signal: MXAccess fires + # OnWriteComplete once the write reaches the provider. The + # value echo is best-effort — a provider-driven attribute + # (e.g. a simulated counter) accepts the write but does not + # hold the value, so no OnDataChange carries it back. + $writeCompleteEvent = $writeItemEvents | Where-Object { + (Get-EventFamily -Event $_) -match "WRITE_COMPLETE" + } | Select-Object -First 1 + $echoEvent = $writeItemEvents | Where-Object { + Test-ValueEquals -Expected $sentinelValue -Observed (Get-EventScalar -Event $_) + } | Select-Object -First 1 + + if ($null -eq $writeCompleteEvent) { + throw ("$Client write round-trip failed: wrote $WriteType=$sentinelValue to " + + "'$($writeTag.fullTagReference)' (item handle $writeItemHandle) but no " + + "OnWriteComplete event was observed in $($writeEvents.Count) streamed event(s). " + + "Increase -WriteEchoMaxEvents, or drop -VerifyWrite.") + } + + $clientResult.write = [ordered]@{ + attributeName = $WriteAttribute + fullTagReference = $writeTag.fullTagReference + itemHandle = $writeItemHandle + valueType = $WriteType + value = $sentinelValue + writeCompleteObserved = $true + echoObserved = ($null -ne $echoEvent) + } + + # WriteBulk smoke: single-entry batch against the same writable + # tag. Exercises the BulkWriteResult wire format end-to-end + # without complicating the OnWriteComplete echo assertion that + # the single-item write phase already verified above. Pinned + # to a different sentinel value so a subsequent read-bulk + # against the same tag would see the bulk write's effect. + if (-not $SkipReadWriteBulk) { + $bulkSentinel = $sentinelValue + 1 + $writeBulkJson = Invoke-ClientOperation -Client $Client -Operation "write-bulk" -Values @{ + sessionId = $sessionId + serverHandle = $serverHandle + itemHandles = "$writeItemHandle" + valueType = $WriteType + values = "$bulkSentinel" + userId = 0 + } + $writeBulkResults = @(Get-BulkResults -Client $Client -Operation "write-bulk" -Json $writeBulkJson) + Assert-BulkResults -Client $Client -Operation "write-bulk" -Results $writeBulkResults -ExpectedCount 1 + $clientResult.write.writeBulkValue = $bulkSentinel + $clientResult.write.writeBulkResultCount = $writeBulkResults.Count + } } - "unsubscribe-bulk" { - $results = @($Values.itemHandles -split "," | ForEach-Object { - [pscustomobject]@{ itemHandle = [int]$_; wasSuccessful = $true } - }) - return [pscustomobject]@{ unsubscribeBulk = [pscustomobject]@{ results = $results }; results = $results } + } + + if (-not $SkipBulk) { + $bulkTags = @($Tags | Select-Object -First ([Math]::Min($BulkTagCount, $Tags.Count))) + $bulkItems = ($bulkTags | ForEach-Object { $_.fullTagReference }) -join "," + $subscribeBulkJson = Invoke-ClientOperation -Client $Client -Operation "subscribe-bulk" -Values @{ + sessionId = $sessionId + serverHandle = $serverHandle + items = $bulkItems } - "stream-events" { return [pscustomobject]@{ eventCount = 1; events = @([pscustomobject]@{ workerSequence = 1 }) } } - default { return [pscustomobject]@{ ok = $true; reply = [pscustomobject]@{} } } + $subscribeResults = @(Get-BulkResults -Client $Client -Operation "subscribe-bulk" -Json $subscribeBulkJson) + Assert-BulkResults -Client $Client -Operation "subscribe-bulk" -Results $subscribeResults -ExpectedCount $bulkTags.Count + $bulkItemHandles = @(Get-BulkItemHandles -Results $subscribeResults) + if ($bulkItemHandles.Count -ne $bulkTags.Count) { + throw "$Client subscribe-bulk returned $($bulkItemHandles.Count) usable item handle(s); expected $($bulkTags.Count)." + } + + # ReadBulk over the already-advised tags: every result must come + # from the per-session value cache (was_cached = true). Confirms + # the gateway/worker/cache wiring serves cached values for tags + # the caller did not create the subscription for. + $readBulkSummary = $null + if (-not $SkipReadWriteBulk) { + $readBulkJson = Invoke-ClientOperation -Client $Client -Operation "read-bulk" -Values @{ + sessionId = $sessionId + serverHandle = $serverHandle + items = $bulkItems + timeoutMs = 1500 + } + $readResults = @(Get-BulkResults -Client $Client -Operation "read-bulk" -Json $readBulkJson) + Assert-BulkResults -Client $Client -Operation "read-bulk" -Results $readResults -ExpectedCount $bulkTags.Count + $cachedCount = @($readResults | Where-Object { + [bool](Get-PropertyValue -Object $_ -Names @("wasCached", "was_cached")) + }).Count + # Allow up to one snapshot fallback per batch: a freshly + # advised tag may not have an OnDataChange cached yet if it + # hasn't pushed an update in the small window between + # subscribe-bulk and read-bulk. Anything beyond that means + # the cached-path optimization is broken. + $maxSnapshotFallbacks = 1 + if ($cachedCount -lt ($readResults.Count - $maxSnapshotFallbacks)) { + throw ("$Client read-bulk only returned $cachedCount cached result(s) " + + "out of $($readResults.Count); the cache-then-snapshot fork must " + + "serve cached values for already-advised tags.") + } + $readBulkSummary = [ordered]@{ + tagCount = $readResults.Count + cachedCount = $cachedCount + } + } + + $unsubscribeBulkJson = Invoke-ClientOperation -Client $Client -Operation "unsubscribe-bulk" -Values @{ + sessionId = $sessionId + serverHandle = $serverHandle + itemHandles = $bulkItemHandles -join "," + } + $unsubscribeResults = @(Get-BulkResults -Client $Client -Operation "unsubscribe-bulk" -Json $unsubscribeBulkJson) + Assert-BulkResults -Client $Client -Operation "unsubscribe-bulk" -Results $unsubscribeResults -ExpectedCount $bulkItemHandles.Count + + $clientResult.bulk = [ordered]@{ + tagCount = $bulkTags.Count + subscribedCount = $subscribeResults.Count + unsubscribedCount = $unsubscribeResults.Count + itemHandles = $bulkItemHandles + readBulk = $readBulkSummary + } + } + + $advisedSinceDrain = 0 + foreach ($tag in $Tags) { + $addJson = Invoke-ClientOperation -Client $Client -Operation "add-item" -Values @{ + sessionId = $sessionId + serverHandle = $serverHandle + item = $tag.fullTagReference + } + $itemHandle = Get-ItemHandle -Client $Client -Json $addJson + Invoke-ClientOperation -Client $Client -Operation "advise" -Values @{ + sessionId = $sessionId + serverHandle = $serverHandle + itemHandle = $itemHandle + } | Out-Null + + $clientResult.addedItems += [ordered]@{ + tagName = $tag.tagName + attributeName = $tag.attributeName + fullTagReference = $tag.fullTagReference + itemHandle = $itemHandle + protectedWriteRequired = $tag.attributeName -eq "ProtectedValue" + } + + # Drain the worker event channel every DrainEveryTags advised tags + # so this unbounded advise loop cannot overflow it and fault the + # worker before the loop completes. + $advisedSinceDrain++ + if ($DrainEveryTags -gt 0 -and $advisedSinceDrain -ge $DrainEveryTags) { + Invoke-EventDrain -Client $Client -SessionId $sessionId + $advisedSinceDrain = 0 + } + } + + # --- Event streaming ---------------------------------------------- + if (-not $SkipStream) { + $streamJson = Invoke-ClientOperation -Client $Client -Operation "stream-events" -Values @{ + sessionId = $sessionId + } + $clientResult.eventCount = Get-StreamEventCount -Client $Client -Json $streamJson + if ($clientResult.eventCount -lt 1) { + throw "The $Client stream-events command returned no events." + } + } + + # --- Alarm feed + acknowledge ------------------------------------- + # Session-less RPCs against the gateway's always-on central alarm + # monitor. Opt-in (-VerifyAlarms) because it needs the monitor enabled + # (MxGateway:Alarms:Enabled) and a live alarm provider. + if ($VerifyAlarms) { + $alarmStreamJson = Invoke-ClientOperation -Client $Client -Operation "stream-alarms" -Values @{ + maxEvents = $AlarmStreamMax + } + $alarmMessageCount = Get-AlarmMessageCount -Client $Client -Json $alarmStreamJson + if ($alarmMessageCount -lt 1) { + throw "The $Client stream-alarms command returned no alarm-feed messages." + } + + # The acknowledge round-trips against the central monitor; the + # native ack outcome depends on whether the referenced alarm is + # currently active, so only the RPC's success is asserted here. + Invoke-ClientOperation -Client $Client -Operation "acknowledge-alarm" -Values @{ + alarmReference = $AlarmReference + comment = "e2e-matrix" + operator = "mxgw-e2e" + } | Out-Null + + $clientResult.alarms = [ordered]@{ + streamMessageCount = $alarmMessageCount + acknowledgeReference = $AlarmReference + acknowledged = $true + } + } + + # --- Error-path (parity) checks ----------------------------------- + # MXAccess parity: an invalid item handle and an unknown session must + # both be rejected rather than silently succeeding. + if (-not $SkipParity) { + $parityChecks = @( + [ordered]@{ + check = "invalid-item-handle" + operation = "advise" + values = @{ sessionId = $sessionId; serverHandle = $serverHandle; itemHandle = 2147483647 } + }, + [ordered]@{ + check = "unknown-session" + operation = "register" + values = @{ sessionId = [guid]::NewGuid().ToString() } + } + ) + + foreach ($parityCheck in $parityChecks) { + $parityResult = Invoke-ClientOperationExpectingFailure ` + -Client $Client -Operation $parityCheck.operation -Values $parityCheck.values + $passed = [bool]$parityResult.failed + $clientResult.parity += [ordered]@{ + check = $parityCheck.check + operation = $parityCheck.operation + passed = $passed + } + if (-not $passed) { + throw "$Client parity check '$($parityCheck.check)' expected $($parityCheck.operation) to fail but it exited 0." + } + } + } + + # --- API-key auth rejection --------------------------------------- + # Runs after a working session is established, so a non-zero exit is + # an auth rejection rather than the gateway being unreachable. + if (-not $SkipAuth) { + $authChecks = @( + [ordered]@{ check = "missing-api-key"; apiKeyEnv = $script:missingKeyEnvName } + ) + if (-not [string]::IsNullOrWhiteSpace($RejectScopeApiKeyEnv)) { + $authChecks += [ordered]@{ check = "insufficient-scope"; apiKeyEnv = $RejectScopeApiKeyEnv } + } + + foreach ($authCheck in $authChecks) { + $authResult = Invoke-ClientOperationExpectingFailure ` + -Client $Client -Operation "open-session" -ApiKeyEnvName $authCheck.apiKeyEnv + $passed = [bool]$authResult.failed + $clientResult.auth += [ordered]@{ + check = $authCheck.check + passed = $passed + } + if (-not $passed) { + throw "$Client auth check '$($authCheck.check)' expected open-session to be rejected but it exited 0." + } + } + } + } catch { + $clientResult.error = $_.Exception.Message + Write-Warning "$Client e2e flow failed: $($clientResult.error)" + } finally { + if (-not [string]::IsNullOrWhiteSpace($sessionId)) { + try { + Invoke-ClientOperation -Client $Client -Operation "close-session" -Values @{ + sessionId = $sessionId + } | Out-Null + $clientResult.closed = $true + } catch { + $clientResult.error = "$($clientResult.error) close-session failed: $($_.Exception.Message)" + } + } + + if ($null -ne $script:currentBatchClient) { + Stop-BatchClient -BatchClient $script:currentBatchClient + $script:currentBatchClient = $null } } - return Read-JsonObject -Text $result.stdout + + return $clientResult } +# Forwards every run parameter to a single-client child invocation used by +# -Parallel. -Parallel itself is intentionally omitted so the child runs the +# serial path. +function Get-ChildArgumentList { + param( + [string]$Client, + [string]$ChildReportPath + ) + + $childArgs = @( + "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $PSCommandPath, + "-Clients", $Client, + "-MachineStart", "$MachineStart", + "-MachineEnd", "$MachineEnd", + "-Attributes", ($Attributes -join ","), + "-Endpoint", $Endpoint, + "-ApiKeyEnv", $ApiKeyEnv, + "-SqlServer", $SqlServer, + "-Database", $Database, + "-EventLimit", "$EventLimit", + "-BulkTagCount", "$BulkTagCount", + "-DrainEveryTags", "$DrainEveryTags", + "-WriteAttribute", $WriteAttribute, + "-WriteType", $WriteType, + "-WriteValueBase", "$WriteValueBase", + "-WriteEchoMaxEvents", "$WriteEchoMaxEvents", + "-AlarmReference", $AlarmReference, + "-AlarmStreamMax", "$AlarmStreamMax", + "-ReportPath", $ChildReportPath, + "-EmitReport" + ) + if (-not [string]::IsNullOrWhiteSpace($RejectScopeApiKeyEnv)) { + $childArgs += @("-RejectScopeApiKeyEnv", $RejectScopeApiKeyEnv) + } + if ($SkipStream) { $childArgs += "-SkipStream" } + if ($SkipBulk) { $childArgs += "-SkipBulk" } + if ($VerifyWrite) { $childArgs += "-VerifyWrite" } + if ($VerifyAlarms) { $childArgs += "-VerifyAlarms" } + if ($SkipParity) { $childArgs += "-SkipParity" } + if ($SkipAuth) { $childArgs += "-SkipAuth" } + if ($DryRun) { $childArgs += "-DryRun" } + return $childArgs +} + +# An env var name that is guaranteed not to be set in this process, used to +# drive the missing-API-key auth rejection. +$script:missingKeyEnvName = "MXGW_E2E_MISSING_KEY_" + ([guid]::NewGuid().ToString("N")) + +# --- Parallel mode: fan out one isolated child process per client ---------- +if ($Parallel -and $Clients.Count -gt 1) { + Write-Host "Running $($Clients.Count) client e2e flows in parallel." + $childRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("mxgw-e2e-" + ([guid]::NewGuid().ToString("N"))) + New-Item -ItemType Directory -Path $childRoot -Force | Out-Null + + $children = @() + foreach ($client in $Clients) { + $childReport = Join-Path $childRoot "$client.json" + $childLog = Join-Path $childRoot "$client.log" + $childArgs = Get-ChildArgumentList -Client $client -ChildReportPath $childReport + $process = Start-Process -FilePath "pwsh" -ArgumentList $childArgs ` + -RedirectStandardOutput $childLog -RedirectStandardError "$childLog.err" ` + -NoNewWindow -PassThru + $children += [pscustomobject]@{ + client = $client + process = $process + report = $childReport + log = $childLog + } + } + + foreach ($child in $children) { + $child.process.WaitForExit() + } + + $mergedClients = @() + $discoveredTags = @() + $hadFailure = $false + foreach ($child in $children) { + foreach ($logPath in @($child.log, "$($child.log).err")) { + if ((Test-Path $logPath) -and -not [string]::IsNullOrWhiteSpace((Get-Content -Raw -Path $logPath))) { + Write-Host "----- $($child.client) -----" + Get-Content -Path $logPath | ForEach-Object { Write-Host $_ } + } + } + + if ($child.process.ExitCode -ne 0) { + $hadFailure = $true + } + + if (Test-Path $child.report) { + $childRun = Get-Content -Raw -Path $child.report | ConvertFrom-Json + $mergedClients += @($childRun.clients) + if ($discoveredTags.Count -eq 0) { + $discoveredTags = @($childRun.discoveredTags) + } + } else { + $hadFailure = $true + $mergedClients += [pscustomobject]@{ + language = $child.client + error = "Child process exited $($child.process.ExitCode) without writing a report." + } + } + } + + $run = [ordered]@{ + schemaVersion = 1 + endpoint = $Endpoint + apiKeyEnv = $ApiKeyEnv + machineStart = $MachineStart + machineEnd = $MachineEnd + attributes = $Attributes + eventLimit = $EventLimit + bulkTagCount = $BulkTagCount + drainEveryTags = $DrainEveryTags + skipStream = [bool]$SkipStream + skipBulk = [bool]$SkipBulk + verifyWrite = [bool]$VerifyWrite + verifyAlarms = [bool]$VerifyAlarms + skipParity = [bool]$SkipParity + skipAuth = [bool]$SkipAuth + writeAttribute = $WriteAttribute + parallel = $true + discoveredTags = $discoveredTags + clients = $mergedClients + completedAt = (Get-Date).ToUniversalTime().ToString("O") + success = -not $hadFailure + } + + $reportDirectory = Split-Path -Parent $ReportPath + if (-not [string]::IsNullOrWhiteSpace($reportDirectory)) { + New-Item -ItemType Directory -Path $reportDirectory -Force | Out-Null + } + $run | ConvertTo-Json -Depth 8 | Set-Content -Path $ReportPath -Encoding UTF8 + Write-Host "Wrote merged e2e report to $ReportPath" + Remove-Item -Path $childRoot -Recurse -Force -ErrorAction SilentlyContinue + + if ($hadFailure) { + exit 1 + } + return +} + +# --- Serial mode ----------------------------------------------------------- +Initialize-ClientBuilds + $discoveryJson = & $discoveryScript ` -MachineStart $MachineStart ` -MachineEnd $MachineEnd ` @@ -532,131 +1672,36 @@ $run = [ordered]@{ attributes = $Attributes eventLimit = $EventLimit bulkTagCount = $BulkTagCount + drainEveryTags = $DrainEveryTags skipStream = [bool]$SkipStream skipBulk = [bool]$SkipBulk + verifyWrite = [bool]$VerifyWrite + verifyAlarms = [bool]$VerifyAlarms + skipParity = [bool]$SkipParity + skipAuth = [bool]$SkipAuth + writeAttribute = $WriteAttribute + parallel = $false startedAt = (Get-Date).ToUniversalTime().ToString("O") discoveredTags = $tags clients = @() } $hadFailure = $false +$script:clientFlowIndex = 0 foreach ($client in $Clients) { - Write-Host "Running $client client e2e flow against $($tags.Count) discovered tags." - $sessionId = $null - $serverHandle = $null - $clientResult = [ordered]@{ - language = $client - sessionId = $null - serverHandle = $null - bulk = $null - addedItems = @() - eventCount = 0 - closed = $false - error = $null - } - - try { - $openJson = Invoke-ClientOperation -Client $client -Operation "open-session" - $sessionId = Get-OpenSessionId -Client $client -Json $openJson - if ([string]::IsNullOrWhiteSpace($sessionId)) { - throw "The $client open-session command did not return a session id." - } - $clientResult.sessionId = $sessionId - - $registerJson = Invoke-ClientOperation -Client $client -Operation "register" -Values @{ - sessionId = $sessionId - } - $serverHandle = Get-ServerHandle -Client $client -Json $registerJson - $clientResult.serverHandle = $serverHandle - - if (-not $SkipBulk) { - $bulkTags = @($tags | Select-Object -First ([Math]::Min($BulkTagCount, $tags.Count))) - $bulkItems = ($bulkTags | ForEach-Object { $_.fullTagReference }) -join "," - $subscribeBulkJson = Invoke-ClientOperation -Client $client -Operation "subscribe-bulk" -Values @{ - sessionId = $sessionId - serverHandle = $serverHandle - items = $bulkItems - } - $subscribeResults = @(Get-BulkResults -Client $client -Operation "subscribe-bulk" -Json $subscribeBulkJson) - Assert-BulkResults -Client $client -Operation "subscribe-bulk" -Results $subscribeResults -ExpectedCount $bulkTags.Count - $bulkItemHandles = @(Get-BulkItemHandles -Results $subscribeResults) - if ($bulkItemHandles.Count -ne $bulkTags.Count) { - throw "$client subscribe-bulk returned $($bulkItemHandles.Count) usable item handle(s); expected $($bulkTags.Count)." - } - - $unsubscribeBulkJson = Invoke-ClientOperation -Client $client -Operation "unsubscribe-bulk" -Values @{ - sessionId = $sessionId - serverHandle = $serverHandle - itemHandles = $bulkItemHandles -join "," - } - $unsubscribeResults = @(Get-BulkResults -Client $client -Operation "unsubscribe-bulk" -Json $unsubscribeBulkJson) - Assert-BulkResults -Client $client -Operation "unsubscribe-bulk" -Results $unsubscribeResults -ExpectedCount $bulkItemHandles.Count - - $clientResult.bulk = [ordered]@{ - tagCount = $bulkTags.Count - subscribedCount = $subscribeResults.Count - unsubscribedCount = $unsubscribeResults.Count - itemHandles = $bulkItemHandles - } - } - - foreach ($tag in $tags) { - $addJson = Invoke-ClientOperation -Client $client -Operation "add-item" -Values @{ - sessionId = $sessionId - serverHandle = $serverHandle - item = $tag.fullTagReference - } - $itemHandle = Get-ItemHandle -Client $client -Json $addJson - Invoke-ClientOperation -Client $client -Operation "advise" -Values @{ - sessionId = $sessionId - serverHandle = $serverHandle - itemHandle = $itemHandle - } | Out-Null - - $clientResult.addedItems += [ordered]@{ - tagName = $tag.tagName - attributeName = $tag.attributeName - fullTagReference = $tag.fullTagReference - itemHandle = $itemHandle - protectedWriteRequired = $tag.attributeName -eq "ProtectedValue" - } - } - - if (-not $SkipStream) { - $streamJson = Invoke-ClientOperation -Client $client -Operation "stream-events" -Values @{ - sessionId = $sessionId - } - $clientResult.eventCount = Get-StreamEventCount -Client $client -Json $streamJson - if ($clientResult.eventCount -lt 1) { - throw "The $client stream-events command returned no events." - } - } - } catch { + $clientResult = Invoke-ClientFlow -Client $client -Tags $tags + if (-not [string]::IsNullOrWhiteSpace($clientResult.error)) { $hadFailure = $true - $clientResult.error = $_.Exception.Message - Write-Warning "$client e2e flow failed: $($clientResult.error)" - } finally { - if (-not [string]::IsNullOrWhiteSpace($sessionId)) { - try { - Invoke-ClientOperation -Client $client -Operation "close-session" -Values @{ - sessionId = $sessionId - } | Out-Null - $clientResult.closed = $true - } catch { - $hadFailure = $true - $clientResult.error = "$($clientResult.error) close-session failed: $($_.Exception.Message)" - } - } } - $run.clients += $clientResult + $script:clientFlowIndex++ } $run.completedAt = (Get-Date).ToUniversalTime().ToString("O") $run.success = -not $hadFailure -if (-not $DryRun) { +if (-not $DryRun -or $EmitReport) { $reportDirectory = Split-Path -Parent $ReportPath if (-not [string]::IsNullOrWhiteSpace($reportDirectory)) { New-Item -ItemType Directory -Path $reportDirectory -Force | Out-Null diff --git a/scripts/validate-client-behavior-fixtures.ps1 b/scripts/validate-client-behavior-fixtures.ps1 index 6f1ea3b..27cdc34 100644 --- a/scripts/validate-client-behavior-fixtures.ps1 +++ b/scripts/validate-client-behavior-fixtures.ps1 @@ -7,7 +7,7 @@ Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" $repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") -$testProject = Join-Path $repoRoot "src/MxGateway.Tests/MxGateway.Tests.csproj" +$testProject = Join-Path $repoRoot "src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj" $arguments = @( "test", $testProject, diff --git a/src/MxGateway.Contracts/GatewayContractInfo.cs b/src/MxGateway.Contracts/GatewayContractInfo.cs deleted file mode 100644 index 633623d..0000000 --- a/src/MxGateway.Contracts/GatewayContractInfo.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace MxGateway.Contracts; - -/// -/// Exposes version metadata shared by gateway components before generated -/// protobuf contracts are introduced. -/// -public static class GatewayContractInfo -{ - public const uint GatewayProtocolVersion = 3; - - public const uint WorkerProtocolVersion = 1; - - public const string DefaultBackendName = "mxaccess-worker"; -} diff --git a/src/MxGateway.Contracts/Generated/MxaccessGatewayGrpc.cs b/src/MxGateway.Contracts/Generated/MxaccessGatewayGrpc.cs deleted file mode 100644 index 55606d7..0000000 --- a/src/MxGateway.Contracts/Generated/MxaccessGatewayGrpc.cs +++ /dev/null @@ -1,338 +0,0 @@ -// -// Generated by the protocol buffer compiler. DO NOT EDIT! -// source: mxaccess_gateway.proto -// -#pragma warning disable 0414, 1591, 8981, 0612 -#region Designer generated code - -using grpc = global::Grpc.Core; - -namespace MxGateway.Contracts.Proto { - /// - /// Public client API for MXAccess sessions hosted by the gateway. - /// - public static partial class MxAccessGateway - { - static readonly string __ServiceName = "mxaccess_gateway.v1.MxAccessGateway"; - - [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - static void __Helper_SerializeMessage(global::Google.Protobuf.IMessage message, grpc::SerializationContext context) - { - #if !GRPC_DISABLE_PROTOBUF_BUFFER_SERIALIZATION - if (message is global::Google.Protobuf.IBufferMessage) - { - context.SetPayloadLength(message.CalculateSize()); - global::Google.Protobuf.MessageExtensions.WriteTo(message, context.GetBufferWriter()); - context.Complete(); - return; - } - #endif - context.Complete(global::Google.Protobuf.MessageExtensions.ToByteArray(message)); - } - - [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - static class __Helper_MessageCache - { - public static readonly bool IsBufferMessage = global::System.Reflection.IntrospectionExtensions.GetTypeInfo(typeof(global::Google.Protobuf.IBufferMessage)).IsAssignableFrom(typeof(T)); - } - - [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - static T __Helper_DeserializeMessage(grpc::DeserializationContext context, global::Google.Protobuf.MessageParser parser) where T : global::Google.Protobuf.IMessage - { - #if !GRPC_DISABLE_PROTOBUF_BUFFER_SERIALIZATION - if (__Helper_MessageCache.IsBufferMessage) - { - return parser.ParseFrom(context.PayloadAsReadOnlySequence()); - } - #endif - return parser.ParseFrom(context.PayloadAsNewBuffer()); - } - - [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - static readonly grpc::Marshaller __Marshaller_mxaccess_gateway_v1_OpenSessionRequest = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::MxGateway.Contracts.Proto.OpenSessionRequest.Parser)); - [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - static readonly grpc::Marshaller __Marshaller_mxaccess_gateway_v1_OpenSessionReply = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::MxGateway.Contracts.Proto.OpenSessionReply.Parser)); - [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - static readonly grpc::Marshaller __Marshaller_mxaccess_gateway_v1_CloseSessionRequest = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::MxGateway.Contracts.Proto.CloseSessionRequest.Parser)); - [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - static readonly grpc::Marshaller __Marshaller_mxaccess_gateway_v1_CloseSessionReply = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::MxGateway.Contracts.Proto.CloseSessionReply.Parser)); - [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - static readonly grpc::Marshaller __Marshaller_mxaccess_gateway_v1_MxCommandRequest = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::MxGateway.Contracts.Proto.MxCommandRequest.Parser)); - [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - static readonly grpc::Marshaller __Marshaller_mxaccess_gateway_v1_MxCommandReply = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::MxGateway.Contracts.Proto.MxCommandReply.Parser)); - [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - static readonly grpc::Marshaller __Marshaller_mxaccess_gateway_v1_StreamEventsRequest = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::MxGateway.Contracts.Proto.StreamEventsRequest.Parser)); - [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - static readonly grpc::Marshaller __Marshaller_mxaccess_gateway_v1_MxEvent = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::MxGateway.Contracts.Proto.MxEvent.Parser)); - [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - static readonly grpc::Marshaller __Marshaller_mxaccess_gateway_v1_AcknowledgeAlarmRequest = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::MxGateway.Contracts.Proto.AcknowledgeAlarmRequest.Parser)); - [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - static readonly grpc::Marshaller __Marshaller_mxaccess_gateway_v1_AcknowledgeAlarmReply = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::MxGateway.Contracts.Proto.AcknowledgeAlarmReply.Parser)); - [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - static readonly grpc::Marshaller __Marshaller_mxaccess_gateway_v1_QueryActiveAlarmsRequest = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::MxGateway.Contracts.Proto.QueryActiveAlarmsRequest.Parser)); - [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - static readonly grpc::Marshaller __Marshaller_mxaccess_gateway_v1_ActiveAlarmSnapshot = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::MxGateway.Contracts.Proto.ActiveAlarmSnapshot.Parser)); - - [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - static readonly grpc::Method __Method_OpenSession = new grpc::Method( - grpc::MethodType.Unary, - __ServiceName, - "OpenSession", - __Marshaller_mxaccess_gateway_v1_OpenSessionRequest, - __Marshaller_mxaccess_gateway_v1_OpenSessionReply); - - [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - static readonly grpc::Method __Method_CloseSession = new grpc::Method( - grpc::MethodType.Unary, - __ServiceName, - "CloseSession", - __Marshaller_mxaccess_gateway_v1_CloseSessionRequest, - __Marshaller_mxaccess_gateway_v1_CloseSessionReply); - - [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - static readonly grpc::Method __Method_Invoke = new grpc::Method( - grpc::MethodType.Unary, - __ServiceName, - "Invoke", - __Marshaller_mxaccess_gateway_v1_MxCommandRequest, - __Marshaller_mxaccess_gateway_v1_MxCommandReply); - - [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - static readonly grpc::Method __Method_StreamEvents = new grpc::Method( - grpc::MethodType.ServerStreaming, - __ServiceName, - "StreamEvents", - __Marshaller_mxaccess_gateway_v1_StreamEventsRequest, - __Marshaller_mxaccess_gateway_v1_MxEvent); - - [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - static readonly grpc::Method __Method_AcknowledgeAlarm = new grpc::Method( - grpc::MethodType.Unary, - __ServiceName, - "AcknowledgeAlarm", - __Marshaller_mxaccess_gateway_v1_AcknowledgeAlarmRequest, - __Marshaller_mxaccess_gateway_v1_AcknowledgeAlarmReply); - - [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - static readonly grpc::Method __Method_QueryActiveAlarms = new grpc::Method( - grpc::MethodType.ServerStreaming, - __ServiceName, - "QueryActiveAlarms", - __Marshaller_mxaccess_gateway_v1_QueryActiveAlarmsRequest, - __Marshaller_mxaccess_gateway_v1_ActiveAlarmSnapshot); - - /// Service descriptor - public static global::Google.Protobuf.Reflection.ServiceDescriptor Descriptor - { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.Services[0]; } - } - - /// Base class for server-side implementations of MxAccessGateway - [grpc::BindServiceMethod(typeof(MxAccessGateway), "BindService")] - public abstract partial class MxAccessGatewayBase - { - [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - public virtual global::System.Threading.Tasks.Task OpenSession(global::MxGateway.Contracts.Proto.OpenSessionRequest request, grpc::ServerCallContext context) - { - throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, "")); - } - - [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - public virtual global::System.Threading.Tasks.Task CloseSession(global::MxGateway.Contracts.Proto.CloseSessionRequest request, grpc::ServerCallContext context) - { - throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, "")); - } - - [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - public virtual global::System.Threading.Tasks.Task Invoke(global::MxGateway.Contracts.Proto.MxCommandRequest request, grpc::ServerCallContext context) - { - throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, "")); - } - - [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - public virtual global::System.Threading.Tasks.Task StreamEvents(global::MxGateway.Contracts.Proto.StreamEventsRequest request, grpc::IServerStreamWriter responseStream, grpc::ServerCallContext context) - { - throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, "")); - } - - [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - public virtual global::System.Threading.Tasks.Task AcknowledgeAlarm(global::MxGateway.Contracts.Proto.AcknowledgeAlarmRequest request, grpc::ServerCallContext context) - { - throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, "")); - } - - [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - public virtual global::System.Threading.Tasks.Task QueryActiveAlarms(global::MxGateway.Contracts.Proto.QueryActiveAlarmsRequest request, grpc::IServerStreamWriter responseStream, grpc::ServerCallContext context) - { - throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, "")); - } - - } - - /// Client for MxAccessGateway - public partial class MxAccessGatewayClient : grpc::ClientBase - { - /// Creates a new client for MxAccessGateway - /// The channel to use to make remote calls. - [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - public MxAccessGatewayClient(grpc::ChannelBase channel) : base(channel) - { - } - /// Creates a new client for MxAccessGateway that uses a custom CallInvoker. - /// The callInvoker to use to make remote calls. - [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - public MxAccessGatewayClient(grpc::CallInvoker callInvoker) : base(callInvoker) - { - } - /// Protected parameterless constructor to allow creation of test doubles. - [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - protected MxAccessGatewayClient() : base() - { - } - /// Protected constructor to allow creation of configured clients. - /// The client configuration. - [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - protected MxAccessGatewayClient(ClientBaseConfiguration configuration) : base(configuration) - { - } - - [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - public virtual global::MxGateway.Contracts.Proto.OpenSessionReply OpenSession(global::MxGateway.Contracts.Proto.OpenSessionRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) - { - return OpenSession(request, new grpc::CallOptions(headers, deadline, cancellationToken)); - } - [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - public virtual global::MxGateway.Contracts.Proto.OpenSessionReply OpenSession(global::MxGateway.Contracts.Proto.OpenSessionRequest request, grpc::CallOptions options) - { - return CallInvoker.BlockingUnaryCall(__Method_OpenSession, null, options, request); - } - [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - public virtual grpc::AsyncUnaryCall OpenSessionAsync(global::MxGateway.Contracts.Proto.OpenSessionRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) - { - return OpenSessionAsync(request, new grpc::CallOptions(headers, deadline, cancellationToken)); - } - [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - public virtual grpc::AsyncUnaryCall OpenSessionAsync(global::MxGateway.Contracts.Proto.OpenSessionRequest request, grpc::CallOptions options) - { - return CallInvoker.AsyncUnaryCall(__Method_OpenSession, null, options, request); - } - [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - public virtual global::MxGateway.Contracts.Proto.CloseSessionReply CloseSession(global::MxGateway.Contracts.Proto.CloseSessionRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) - { - return CloseSession(request, new grpc::CallOptions(headers, deadline, cancellationToken)); - } - [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - public virtual global::MxGateway.Contracts.Proto.CloseSessionReply CloseSession(global::MxGateway.Contracts.Proto.CloseSessionRequest request, grpc::CallOptions options) - { - return CallInvoker.BlockingUnaryCall(__Method_CloseSession, null, options, request); - } - [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - public virtual grpc::AsyncUnaryCall CloseSessionAsync(global::MxGateway.Contracts.Proto.CloseSessionRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) - { - return CloseSessionAsync(request, new grpc::CallOptions(headers, deadline, cancellationToken)); - } - [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - public virtual grpc::AsyncUnaryCall CloseSessionAsync(global::MxGateway.Contracts.Proto.CloseSessionRequest request, grpc::CallOptions options) - { - return CallInvoker.AsyncUnaryCall(__Method_CloseSession, null, options, request); - } - [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - public virtual global::MxGateway.Contracts.Proto.MxCommandReply Invoke(global::MxGateway.Contracts.Proto.MxCommandRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) - { - return Invoke(request, new grpc::CallOptions(headers, deadline, cancellationToken)); - } - [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - public virtual global::MxGateway.Contracts.Proto.MxCommandReply Invoke(global::MxGateway.Contracts.Proto.MxCommandRequest request, grpc::CallOptions options) - { - return CallInvoker.BlockingUnaryCall(__Method_Invoke, null, options, request); - } - [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - public virtual grpc::AsyncUnaryCall InvokeAsync(global::MxGateway.Contracts.Proto.MxCommandRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) - { - return InvokeAsync(request, new grpc::CallOptions(headers, deadline, cancellationToken)); - } - [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - public virtual grpc::AsyncUnaryCall InvokeAsync(global::MxGateway.Contracts.Proto.MxCommandRequest request, grpc::CallOptions options) - { - return CallInvoker.AsyncUnaryCall(__Method_Invoke, null, options, request); - } - [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - public virtual grpc::AsyncServerStreamingCall StreamEvents(global::MxGateway.Contracts.Proto.StreamEventsRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) - { - return StreamEvents(request, new grpc::CallOptions(headers, deadline, cancellationToken)); - } - [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - public virtual grpc::AsyncServerStreamingCall StreamEvents(global::MxGateway.Contracts.Proto.StreamEventsRequest request, grpc::CallOptions options) - { - return CallInvoker.AsyncServerStreamingCall(__Method_StreamEvents, null, options, request); - } - [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - public virtual global::MxGateway.Contracts.Proto.AcknowledgeAlarmReply AcknowledgeAlarm(global::MxGateway.Contracts.Proto.AcknowledgeAlarmRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) - { - return AcknowledgeAlarm(request, new grpc::CallOptions(headers, deadline, cancellationToken)); - } - [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - public virtual global::MxGateway.Contracts.Proto.AcknowledgeAlarmReply AcknowledgeAlarm(global::MxGateway.Contracts.Proto.AcknowledgeAlarmRequest request, grpc::CallOptions options) - { - return CallInvoker.BlockingUnaryCall(__Method_AcknowledgeAlarm, null, options, request); - } - [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - public virtual grpc::AsyncUnaryCall AcknowledgeAlarmAsync(global::MxGateway.Contracts.Proto.AcknowledgeAlarmRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) - { - return AcknowledgeAlarmAsync(request, new grpc::CallOptions(headers, deadline, cancellationToken)); - } - [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - public virtual grpc::AsyncUnaryCall AcknowledgeAlarmAsync(global::MxGateway.Contracts.Proto.AcknowledgeAlarmRequest request, grpc::CallOptions options) - { - return CallInvoker.AsyncUnaryCall(__Method_AcknowledgeAlarm, null, options, request); - } - [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - public virtual grpc::AsyncServerStreamingCall QueryActiveAlarms(global::MxGateway.Contracts.Proto.QueryActiveAlarmsRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) - { - return QueryActiveAlarms(request, new grpc::CallOptions(headers, deadline, cancellationToken)); - } - [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - public virtual grpc::AsyncServerStreamingCall QueryActiveAlarms(global::MxGateway.Contracts.Proto.QueryActiveAlarmsRequest request, grpc::CallOptions options) - { - return CallInvoker.AsyncServerStreamingCall(__Method_QueryActiveAlarms, null, options, request); - } - /// Creates a new instance of client from given ClientBaseConfiguration. - [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - protected override MxAccessGatewayClient NewInstance(ClientBaseConfiguration configuration) - { - return new MxAccessGatewayClient(configuration); - } - } - - /// Creates service definition that can be registered with a server - /// An object implementing the server-side handling logic. - [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - public static grpc::ServerServiceDefinition BindService(MxAccessGatewayBase serviceImpl) - { - return grpc::ServerServiceDefinition.CreateBuilder() - .AddMethod(__Method_OpenSession, serviceImpl.OpenSession) - .AddMethod(__Method_CloseSession, serviceImpl.CloseSession) - .AddMethod(__Method_Invoke, serviceImpl.Invoke) - .AddMethod(__Method_StreamEvents, serviceImpl.StreamEvents) - .AddMethod(__Method_AcknowledgeAlarm, serviceImpl.AcknowledgeAlarm) - .AddMethod(__Method_QueryActiveAlarms, serviceImpl.QueryActiveAlarms).Build(); - } - - /// Register service method with a service binder with or without implementation. Useful when customizing the service binding logic. - /// Note: this method is part of an experimental API that can change or be removed without any prior notice. - /// Service methods will be bound by calling AddMethod on this object. - /// An object implementing the server-side handling logic. - [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - public static void BindService(grpc::ServiceBinderBase serviceBinder, MxAccessGatewayBase serviceImpl) - { - serviceBinder.AddMethod(__Method_OpenSession, serviceImpl == null ? null : new grpc::UnaryServerMethod(serviceImpl.OpenSession)); - serviceBinder.AddMethod(__Method_CloseSession, serviceImpl == null ? null : new grpc::UnaryServerMethod(serviceImpl.CloseSession)); - serviceBinder.AddMethod(__Method_Invoke, serviceImpl == null ? null : new grpc::UnaryServerMethod(serviceImpl.Invoke)); - serviceBinder.AddMethod(__Method_StreamEvents, serviceImpl == null ? null : new grpc::ServerStreamingServerMethod(serviceImpl.StreamEvents)); - serviceBinder.AddMethod(__Method_AcknowledgeAlarm, serviceImpl == null ? null : new grpc::UnaryServerMethod(serviceImpl.AcknowledgeAlarm)); - serviceBinder.AddMethod(__Method_QueryActiveAlarms, serviceImpl == null ? null : new grpc::ServerStreamingServerMethod(serviceImpl.QueryActiveAlarms)); - } - - } -} -#endregion diff --git a/src/MxGateway.IntegrationTests/WorkerLiveMxAccessSmokeTests.cs b/src/MxGateway.IntegrationTests/WorkerLiveMxAccessSmokeTests.cs deleted file mode 100644 index a20fa94..0000000 --- a/src/MxGateway.IntegrationTests/WorkerLiveMxAccessSmokeTests.cs +++ /dev/null @@ -1,626 +0,0 @@ -using System.Collections.Concurrent; -using System.Diagnostics; -using Google.Protobuf.WellKnownTypes; -using Grpc.Core; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using MxGateway.Contracts; -using MxGateway.Contracts.Proto; -using MxGateway.Server.Configuration; -using MxGateway.Server.Grpc; -using MxGateway.Server.Metrics; -using MxGateway.Server.Security.Authentication; -using MxGateway.Server.Security.Authorization; -using MxGateway.Server.Sessions; -using MxGateway.Server.Workers; -using Xunit.Abstractions; - -namespace MxGateway.IntegrationTests; - -public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output) -{ - private static readonly TimeSpan CommandTimeout = TimeSpan.FromSeconds(15); - private static readonly TimeSpan StreamShutdownTimeout = TimeSpan.FromSeconds(10); - - /// - /// Verifies that a gateway session can register, add item, advise, and stream events from live MXAccess. - /// - [LiveMxAccessFact] - [Trait("Category", "LiveMxAccess")] - public async Task GatewaySession_WithLiveWorker_RegistersAdvisesStreamsDataAndCloses() - { - string workerExecutablePath = IntegrationTestEnvironment.ResolveLiveMxAccessWorkerExecutablePath(); - Assert.True( - File.Exists(workerExecutablePath), - $"Live MXAccess worker executable was not found at {workerExecutablePath}. Build the worker or set {IntegrationTestEnvironment.LiveMxAccessWorkerExecutableVariableName}."); - - TestWorkerProcessFactory processFactory = new(output); - await using GatewayServiceFixture fixture = new(workerExecutablePath, processFactory, output); - - string? sessionId = null; - RecordingServerStreamWriter? eventWriter = null; - Task? streamTask = null; - - try - { - OpenSessionReply openReply = await fixture.Service.OpenSession( - new OpenSessionRequest - { - ClientSessionName = "live-mxaccess-smoke", - ClientCorrelationId = "live-open", - CommandTimeout = Duration.FromTimeSpan(CommandTimeout), - }, - new TestServerCallContext()).ConfigureAwait(false); - - sessionId = openReply.SessionId; - output.WriteLine($"OpenSession status={openReply.ProtocolStatus.Code} session={sessionId} worker_pid={openReply.WorkerProcessId}"); - Assert.Equal(ProtocolStatusCode.Ok, openReply.ProtocolStatus.Code); - Assert.True(openReply.WorkerProcessId > 0); - - eventWriter = new RecordingServerStreamWriter(); - streamTask = fixture.Service.StreamEvents( - new StreamEventsRequest { SessionId = sessionId }, - eventWriter, - new TestServerCallContext()); - - MxCommandReply registerReply = await fixture.Service.Invoke( - CreateRegisterRequest(sessionId), - new TestServerCallContext()).ConfigureAwait(false); - LogReply("Register", registerReply); - Assert.Equal(ProtocolStatusCode.Ok, registerReply.ProtocolStatus.Code); - Assert.True(registerReply.Register.ServerHandle > 0); - - MxCommandReply addItemReply = await fixture.Service.Invoke( - CreateAddItemRequest(sessionId, registerReply.Register.ServerHandle), - new TestServerCallContext()).ConfigureAwait(false); - LogReply("AddItem", addItemReply); - Assert.Equal(ProtocolStatusCode.Ok, addItemReply.ProtocolStatus.Code); - Assert.True(addItemReply.AddItem.ItemHandle > 0); - - MxCommandReply adviseReply = await fixture.Service.Invoke( - CreateAdviseRequest( - sessionId, - registerReply.Register.ServerHandle, - addItemReply.AddItem.ItemHandle), - new TestServerCallContext()).ConfigureAwait(false); - LogReply("Advise", adviseReply); - Assert.Equal(ProtocolStatusCode.Ok, adviseReply.ProtocolStatus.Code); - - MxEvent dataChange = await eventWriter - .WaitForFirstMessageAsync(IntegrationTestEnvironment.LiveMxAccessEventTimeout) - .ConfigureAwait(false); - LogEvent(dataChange); - - Assert.Equal(MxEventFamily.OnDataChange, dataChange.Family); - Assert.Equal(sessionId, dataChange.SessionId); - Assert.Equal(registerReply.Register.ServerHandle, dataChange.ServerHandle); - Assert.Equal(addItemReply.AddItem.ItemHandle, dataChange.ItemHandle); - } - finally - { - try - { - if (!string.IsNullOrWhiteSpace(sessionId)) - { - await CloseSessionAsync(fixture, sessionId).ConfigureAwait(false); - } - - if (streamTask is not null) - { - await streamTask.WaitAsync(StreamShutdownTimeout).ConfigureAwait(false); - } - } - finally - { - await processFactory.WaitForProcessesAsync(StreamShutdownTimeout).ConfigureAwait(false); - } - } - } - - private static MxCommandRequest CreateRegisterRequest(string sessionId) - { - return new MxCommandRequest - { - SessionId = sessionId, - ClientCorrelationId = "live-register", - Command = new MxCommand - { - Kind = MxCommandKind.Register, - Register = new RegisterCommand - { - ClientName = IntegrationTestEnvironment.LiveMxAccessClientName, - }, - }, - }; - } - - private static MxCommandRequest CreateAddItemRequest( - string sessionId, - int serverHandle) - { - return new MxCommandRequest - { - SessionId = sessionId, - ClientCorrelationId = "live-add-item", - Command = new MxCommand - { - Kind = MxCommandKind.AddItem, - AddItem = new AddItemCommand - { - ServerHandle = serverHandle, - ItemDefinition = IntegrationTestEnvironment.LiveMxAccessItem, - }, - }, - }; - } - - private static MxCommandRequest CreateAdviseRequest( - string sessionId, - int serverHandle, - int itemHandle) - { - return new MxCommandRequest - { - SessionId = sessionId, - ClientCorrelationId = "live-advise", - Command = new MxCommand - { - Kind = MxCommandKind.Advise, - Advise = new AdviseCommand - { - ServerHandle = serverHandle, - ItemHandle = itemHandle, - }, - }, - }; - } - - private async Task CloseSessionAsync( - GatewayServiceFixture fixture, - string sessionId) - { - CloseSessionReply closeReply = await fixture.Service.CloseSession( - new CloseSessionRequest - { - SessionId = sessionId, - ClientCorrelationId = "live-close", - }, - new TestServerCallContext()).ConfigureAwait(false); - - output.WriteLine($"CloseSession status={closeReply.ProtocolStatus.Code} final_state={closeReply.FinalState}"); - } - - private void LogReply( - string method, - MxCommandReply reply) - { - output.WriteLine( - $"{method} status={reply.ProtocolStatus.Code} hresult={reply.Hresult} diagnostic={reply.DiagnosticMessage}"); - - foreach (MxStatusProxy status in reply.Statuses) - { - output.WriteLine( - $"{method} mxstatus success={status.Success} category={status.Category} detail={status.Detail} text={status.DiagnosticText}"); - } - } - - private void LogEvent(MxEvent dataChange) - { - output.WriteLine( - $"Event family={dataChange.Family} worker_sequence={dataChange.WorkerSequence} server_handle={dataChange.ServerHandle} item_handle={dataChange.ItemHandle} quality={dataChange.Quality}"); - output.WriteLine( - $"Event value_type={dataChange.Value?.DataType} raw_status={dataChange.RawStatus}"); - } - - /// - /// Test fixture that assembles the gateway service with a worker process factory for live MXAccess testing. - /// - private sealed class GatewayServiceFixture : IAsyncDisposable - { - private readonly GatewayMetrics _metrics = new(); - private readonly SessionRegistry _registry = new(); - private readonly ILoggerFactory _loggerFactory; - - /// - /// Initializes the fixture with worker executable path, factory, and test output helper. - /// - /// Path to the worker process executable. - /// Factory for creating worker processes. - /// Test output helper for logging. - public GatewayServiceFixture( - string workerExecutablePath, - IWorkerProcessFactory processFactory, - ITestOutputHelper output) - { - IOptions options = Options.Create(CreateOptions(workerExecutablePath)); - _loggerFactory = LoggerFactory.Create(builder => builder.AddProvider(new TestOutputLoggerProvider(output))); - WorkerProcessLauncher launcher = new( - options, - processFactory, - new WorkerProcessStartedProbe(), - _metrics); - SessionWorkerClientFactory workerClientFactory = new( - launcher, - options, - _metrics, - _loggerFactory); - SessionManager sessionManager = new( - _registry, - workerClientFactory, - options, - _metrics, - logger: _loggerFactory.CreateLogger()); - MxAccessGrpcMapper mapper = new(); - EventStreamService eventStreamService = new( - sessionManager, - options, - mapper, - _metrics, - _loggerFactory.CreateLogger()); - - Service = new MxAccessGatewayService( - sessionManager, - new GatewayRequestIdentityAccessor(), - new AllowAllConstraintEnforcer(), - new MxAccessGrpcRequestValidator(), - mapper, - eventStreamService, - _metrics, - _loggerFactory.CreateLogger()); - } - - /// - /// The assembled gateway service instance. - /// - public MxAccessGatewayService Service { get; } - - /// - /// Disposes the fixture resources and closes all sessions. - /// - public async ValueTask DisposeAsync() - { - foreach (GatewaySession session in _registry.Snapshot()) - { - await session.DisposeAsync().ConfigureAwait(false); - } - - _loggerFactory.Dispose(); - _metrics.Dispose(); - } - - private static GatewayOptions CreateOptions(string workerExecutablePath) - { - return new GatewayOptions - { - Worker = new WorkerOptions - { - ExecutablePath = workerExecutablePath, - StartupTimeoutSeconds = 30, - ShutdownTimeoutSeconds = 15, - HeartbeatIntervalSeconds = 5, - HeartbeatGraceSeconds = 15, - MaxMessageBytes = WorkerFrameProtocolOptions.DefaultMaxMessageBytes, - RequiredArchitecture = WorkerArchitecture.X86, - }, - Sessions = new SessionOptions - { - DefaultCommandTimeoutSeconds = 15, - MaxSessions = 1, - }, - Events = new EventOptions - { - QueueCapacity = 32, - }, - }; - } - } - - /// - /// Gathers messages written to a server stream for test inspection. - /// - private sealed class RecordingServerStreamWriter : IServerStreamWriter - { - private readonly object syncRoot = new(); - private readonly TaskCompletionSource firstMessage = new(TaskCreationOptions.RunContinuationsAsynchronously); - private readonly List messages = []; - - /// - /// All messages that have been written to the stream. - /// - public IReadOnlyList Messages - { - get - { - lock (syncRoot) - { - return messages.ToArray(); - } - } - } - - /// - /// Inherited write options. - /// - public WriteOptions? WriteOptions { get; set; } - - /// - /// Records the message and completes the first-message task. - /// - /// The message to write. - public Task WriteAsync(T message) - { - lock (syncRoot) - { - messages.Add(message); - } - - firstMessage.TrySetResult(message); - return Task.CompletedTask; - } - - /// - /// Waits for the first message up to the specified timeout. - /// - /// The maximum time to wait. - /// The first message written to the stream. - public async Task WaitForFirstMessageAsync(TimeSpan timeout) - { - return await firstMessage.Task.WaitAsync(timeout).ConfigureAwait(false); - } - } - - /// - /// Mock server call context for testing gRPC calls. - /// - private sealed class TestServerCallContext(CancellationToken cancellationToken = default) : ServerCallContext - { - private readonly Metadata requestHeaders = []; - private readonly Metadata responseTrailers = []; - private readonly Dictionary userState = []; - private Status status; - private WriteOptions? writeOptions; - - /// - protected override string MethodCore => "/mxaccess_gateway.v1.MxAccessGateway/Test"; - - /// - protected override string HostCore => "localhost"; - - /// - protected override string PeerCore => "ipv4:127.0.0.1:5000"; - - /// - protected override DateTime DeadlineCore => DateTime.UtcNow.AddMinutes(1); - - /// - protected override Metadata RequestHeadersCore => requestHeaders; - - /// - protected override CancellationToken CancellationTokenCore => cancellationToken; - - /// - protected override Metadata ResponseTrailersCore => responseTrailers; - - /// - protected override Status StatusCore - { - get => status; - set => status = value; - } - - /// - protected override WriteOptions? WriteOptionsCore - { - get => writeOptions; - set => writeOptions = value; - } - - /// - protected override AuthContext AuthContextCore { get; } = new( - string.Empty, - new Dictionary>(StringComparer.Ordinal)); - - /// - protected override IDictionary UserStateCore => userState; - - /// - protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders) - { - return Task.CompletedTask; - } - - /// - protected override ContextPropagationToken CreatePropagationTokenCore( - ContextPropagationOptions? options) - { - throw new NotSupportedException(); - } - } - - /// - /// Factory that launches worker processes and records their outputs for testing. - /// - private sealed class TestWorkerProcessFactory(ITestOutputHelper output) : IWorkerProcessFactory - { - private readonly ConcurrentBag processes = []; - - /// - public IWorkerProcess Start(ProcessStartInfo startInfo) - { - startInfo.RedirectStandardError = true; - startInfo.RedirectStandardOutput = true; - startInfo.UseShellExecute = false; - - Process process = new() - { - StartInfo = startInfo, - EnableRaisingEvents = true, - }; - - process.OutputDataReceived += (_, args) => WriteWorkerOutput("stdout", args.Data); - process.ErrorDataReceived += (_, args) => WriteWorkerOutput("stderr", args.Data); - - if (!process.Start()) - { - process.Dispose(); - throw new InvalidOperationException("Worker process failed to start."); - } - - process.BeginOutputReadLine(); - process.BeginErrorReadLine(); - - TestWorkerProcess workerProcess = new(process); - processes.Add(workerProcess); - output.WriteLine($"WorkerProcess started pid={workerProcess.Id} path={startInfo.FileName}"); - - return workerProcess; - } - - /// - public async Task WaitForProcessesAsync(TimeSpan timeout) - { - foreach (TestWorkerProcess process in processes) - { - if (process.HasExited) - { - output.WriteLine($"WorkerProcess exited pid={process.Id} exit_code={process.ExitCode}"); - continue; - } - - using CancellationTokenSource timeoutCancellation = new(timeout); - await process.WaitForExitAsync(timeoutCancellation.Token).ConfigureAwait(false); - output.WriteLine($"WorkerProcess exited pid={process.Id} exit_code={process.ExitCode}"); - } - } - - private void WriteWorkerOutput( - string streamName, - string? line) - { - if (!string.IsNullOrWhiteSpace(line)) - { - output.WriteLine($"worker_{streamName}: {line}"); - } - } - } - - /// - /// Adapter wrapping a System.Diagnostics.Process as IWorkerProcess for testing. - /// - private sealed class TestWorkerProcess(Process process) : IWorkerProcess - { - /// - public int Id => process.Id; - - /// - public bool HasExited => process.HasExited; - - /// - public int? ExitCode => process.HasExited ? process.ExitCode : null; - - /// - public async ValueTask WaitForExitAsync(CancellationToken cancellationToken) - { - await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); - } - - /// - public void Kill(bool entireProcessTree) - { - process.Kill(entireProcessTree); - } - - /// - public void Dispose() - { - process.Dispose(); - } - } - - /// - /// Logger provider that writes all output to the test output helper. - /// - private sealed class TestOutputLoggerProvider(ITestOutputHelper output) : ILoggerProvider - { - /// - public ILogger CreateLogger(string categoryName) - { - return new TestOutputLogger(output, categoryName); - } - - /// - public void Dispose() - { - } - } - - /// - /// Logger that writes messages to the test output helper. - /// - private sealed class TestOutputLogger( - ITestOutputHelper output, - string categoryName) : ILogger - { - /// - public IDisposable? BeginScope(TState state) - where TState : notnull - { - return null; - } - - /// - public bool IsEnabled(LogLevel logLevel) - { - return logLevel >= LogLevel.Information; - } - - /// - public void Log( - LogLevel logLevel, - EventId eventId, - TState state, - Exception? exception, - Func formatter) - { - if (!IsEnabled(logLevel)) - { - return; - } - - output.WriteLine($"{logLevel} {categoryName}: {formatter(state, exception)}"); - if (exception is not null) - { - output.WriteLine(exception.ToString()); - } - } - } - - private sealed class AllowAllConstraintEnforcer : IConstraintEnforcer - { - public Task CheckReadTagAsync( - ApiKeyIdentity? identity, - string tagAddress, - CancellationToken cancellationToken) => Task.FromResult(null); - - public Task CheckReadHandleAsync( - ApiKeyIdentity? identity, - GatewaySession session, - int serverHandle, - int itemHandle, - CancellationToken cancellationToken) => Task.FromResult(null); - - public Task CheckWriteHandleAsync( - ApiKeyIdentity? identity, - GatewaySession session, - int serverHandle, - int itemHandle, - CancellationToken cancellationToken) => Task.FromResult(null); - - public Task RecordDenialAsync( - ApiKeyIdentity? identity, - string commandKind, - string target, - ConstraintFailure failure, - CancellationToken cancellationToken) => Task.CompletedTask; - } -} diff --git a/src/MxGateway.Server/Configuration/AlarmsOptions.cs b/src/MxGateway.Server/Configuration/AlarmsOptions.cs deleted file mode 100644 index 2722fac..0000000 --- a/src/MxGateway.Server/Configuration/AlarmsOptions.cs +++ /dev/null @@ -1,48 +0,0 @@ -namespace MxGateway.Server.Configuration; - -/// -/// Per-gateway alarm-subsystem configuration. Drives the auto-subscribe -/// hook in : when -/// is true and a session reaches Ready, the -/// manager issues a SubscribeAlarmsCommand to the worker with -/// the configured . -/// -/// -/// Defaults preserve current behaviour (alarms disabled). Operators -/// opt in by setting MxGateway:Alarms:Enabled = true and -/// supplying a canonical -/// \\<machine>\Galaxy!<area> subscription -/// expression. The literal "Galaxy" provider is correct regardless of -/// the configured Galaxy database name (the wnwrap consumer doesn't -/// accept the database name as the provider). -/// -public sealed class AlarmsOptions -{ - /// Gate the auto-subscribe hook on session open. Default false. - public bool Enabled { get; init; } - - /// - /// AVEVA alarm-subscription expression. When empty and - /// is true, the gateway falls back to - /// \\$(MachineName)\Galaxy!$(DefaultArea) if - /// is set; otherwise the session open - /// fails with a configuration diagnostic. - /// - public string SubscriptionExpression { get; init; } = string.Empty; - - /// - /// Optional area name used to compose a default subscription when - /// is empty. Combined with - /// Environment.MachineName as - /// \\<MachineName>\Galaxy!<DefaultArea>. - /// - public string DefaultArea { get; init; } = string.Empty; - - /// - /// If true, an auto-subscribe failure faults the session. If false - /// (default), the failure is logged and the session remains Ready — - /// alarm-side commands return "not subscribed" but data subscriptions - /// work normally. - /// - public bool RequireSubscribeOnOpen { get; init; } -} diff --git a/src/MxGateway.Server/Dashboard/Components/Layout/DashboardLayout.razor b/src/MxGateway.Server/Dashboard/Components/Layout/DashboardLayout.razor deleted file mode 100644 index 09dfa1e..0000000 --- a/src/MxGateway.Server/Dashboard/Components/Layout/DashboardLayout.razor +++ /dev/null @@ -1,69 +0,0 @@ -@inherits LayoutComponentBase -@inject IOptions GatewayOptions - -
- -
- @Body -
-
- -@code { - private string DashboardPath(string relativePath) - { - string pathBase = GatewayOptions.Value.Dashboard.PathBase.TrimEnd('/'); - if (string.IsNullOrWhiteSpace(pathBase)) - { - pathBase = "/dashboard"; - } - - return $"{pathBase}{relativePath}"; - } -} diff --git a/src/MxGateway.Server/Dashboard/Components/Shared/StatusBadge.razor b/src/MxGateway.Server/Dashboard/Components/Shared/StatusBadge.razor deleted file mode 100644 index 791227c..0000000 --- a/src/MxGateway.Server/Dashboard/Components/Shared/StatusBadge.razor +++ /dev/null @@ -1,17 +0,0 @@ -@Text - -@code { - [Parameter] - public string? Text { get; set; } - - private string CssClass => Text switch - { - "Ready" or "Healthy" => "text-bg-success", - "Creating" or "StartingWorker" or "WaitingForPipe" or "InitializingWorker" or "Closing" => "text-bg-info", - "Closed" => "text-bg-secondary", - "Stale" => "text-bg-warning", - "Faulted" or "Unavailable" => "text-bg-danger", - "Unknown" => "text-bg-light text-dark border", - _ => "text-bg-light text-dark border" - }; -} diff --git a/src/MxGateway.Server/Dashboard/Components/_Imports.razor b/src/MxGateway.Server/Dashboard/Components/_Imports.razor deleted file mode 100644 index fbfa9f0..0000000 --- a/src/MxGateway.Server/Dashboard/Components/_Imports.razor +++ /dev/null @@ -1,13 +0,0 @@ -@using Microsoft.AspNetCore.Components.Authorization -@using Microsoft.AspNetCore.Components.Forms -@using Microsoft.AspNetCore.Components.Routing -@using Microsoft.AspNetCore.Components.Web -@using Microsoft.Extensions.Options -@using MxGateway.Contracts.Proto -@using MxGateway.Server.Configuration -@using MxGateway.Server.Dashboard -@using MxGateway.Server.Dashboard.Components.Layout -@using MxGateway.Server.Dashboard.Components.Shared -@using MxGateway.Server.Security.Authorization -@using MxGateway.Server.Workers -@using static Microsoft.AspNetCore.Components.Web.RenderMode diff --git a/src/MxGateway.Server/Galaxy/GalaxyRepositoryOptions.cs b/src/MxGateway.Server/Galaxy/GalaxyRepositoryOptions.cs deleted file mode 100644 index 921b1d7..0000000 --- a/src/MxGateway.Server/Galaxy/GalaxyRepositoryOptions.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace MxGateway.Server.Galaxy; - -/// -/// Connection settings for the AVEVA System Platform Galaxy Repository (ZB) database. -/// Bound to the MxGateway:Galaxy configuration section. -/// -public sealed class GalaxyRepositoryOptions -{ - public const string SectionName = "MxGateway:Galaxy"; - - /// The SQL Server connection string for the Galaxy Repository database. - public string ConnectionString { get; init; } = - "Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;"; - - /// The timeout in seconds for SQL commands executed against the Galaxy Repository. - public int CommandTimeoutSeconds { get; init; } = 60; - - /// - /// Interval (seconds) between background refreshes of the dashboard Galaxy summary - /// cache. SQL is hit at most once per interval regardless of dashboard render rate. - /// - public int DashboardRefreshIntervalSeconds { get; init; } = 30; -} diff --git a/src/MxGateway.Server/Security/Authentication/AuthSqliteConnectionFactory.cs b/src/MxGateway.Server/Security/Authentication/AuthSqliteConnectionFactory.cs deleted file mode 100644 index 84a3d43..0000000 --- a/src/MxGateway.Server/Security/Authentication/AuthSqliteConnectionFactory.cs +++ /dev/null @@ -1,33 +0,0 @@ -using Microsoft.Data.Sqlite; -using Microsoft.Extensions.Options; -using MxGateway.Server.Configuration; - -namespace MxGateway.Server.Security.Authentication; - -/// -/// Factory for creating SQLite connections to the authentication store. -/// -public sealed class AuthSqliteConnectionFactory(IOptions options) -{ - /// - /// Creates and configures a SQLite connection to the auth database. - /// - public SqliteConnection CreateConnection() - { - string sqlitePath = options.Value.Authentication.SqlitePath; - string? directory = Path.GetDirectoryName(sqlitePath); - - if (!string.IsNullOrWhiteSpace(directory)) - { - Directory.CreateDirectory(directory); - } - - SqliteConnectionStringBuilder builder = new() - { - DataSource = sqlitePath, - Mode = SqliteOpenMode.ReadWriteCreate - }; - - return new SqliteConnection(builder.ToString()); - } -} diff --git a/src/MxGateway.Server/Security/Authorization/GatewayScopes.cs b/src/MxGateway.Server/Security/Authorization/GatewayScopes.cs deleted file mode 100644 index f2205da..0000000 --- a/src/MxGateway.Server/Security/Authorization/GatewayScopes.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace MxGateway.Server.Security.Authorization; - -public static class GatewayScopes -{ - public const string SessionOpen = "session:open"; - public const string SessionClose = "session:close"; - public const string InvokeRead = "invoke:read"; - public const string InvokeWrite = "invoke:write"; - public const string InvokeSecure = "invoke:secure"; - public const string EventsRead = "events:read"; - public const string MetadataRead = "metadata:read"; - public const string Admin = "admin"; -} diff --git a/src/MxGateway.Server/Sessions/IAlarmRpcDispatcher.cs b/src/MxGateway.Server/Sessions/IAlarmRpcDispatcher.cs deleted file mode 100644 index 328b774..0000000 --- a/src/MxGateway.Server/Sessions/IAlarmRpcDispatcher.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using MxGateway.Contracts.Proto; - -namespace MxGateway.Server.Sessions; - -/// -/// PR A.6 / A.7 — gateway-side dispatcher for the alarm-RPC surface. -/// Bridges the public AcknowledgeAlarm + QueryActiveAlarms -/// gRPC handlers to the worker process that hosts -/// IMxAccessAlarmConsumer. -/// -/// -/// -/// Production implementations live in WorkerAlarmRpcDispatcher -/// (this PR ships a not-yet-wired default that returns a clear -/// worker-pending diagnostic) and route through the existing -/// worker-pipe IPC. Tests inject a fake to exercise the gateway -/// handler shape without spinning up a worker process. -/// -/// -/// The dispatcher is session-scoped: every call resolves the -/// session and forwards to that session's worker. The handler -/// constructs the / -/// stream from the dispatcher's -/// output without further translation. -/// -/// -public interface IAlarmRpcDispatcher -{ - /// Forward an Acknowledge to the worker that owns the session. - Task AcknowledgeAsync( - AcknowledgeAlarmRequest request, - CancellationToken cancellationToken); - - /// Walk active alarms on the worker that owns the session. - IAsyncEnumerable QueryActiveAlarmsAsync( - QueryActiveAlarmsRequest request, - CancellationToken cancellationToken); -} diff --git a/src/MxGateway.Server/Sessions/NotWiredAlarmRpcDispatcher.cs b/src/MxGateway.Server/Sessions/NotWiredAlarmRpcDispatcher.cs deleted file mode 100644 index 78389b4..0000000 --- a/src/MxGateway.Server/Sessions/NotWiredAlarmRpcDispatcher.cs +++ /dev/null @@ -1,52 +0,0 @@ -using System.Collections.Generic; -using System.Runtime.CompilerServices; -using System.Threading; -using System.Threading.Tasks; -using MxGateway.Contracts.Proto; -using MxGateway.Server.Grpc; - -namespace MxGateway.Server.Sessions; - -/// -/// PR A.6 / A.7 — default shipped while -/// the worker-side AlarmClient event subscription is gated on dev-rig -/// validation. Acknowledges with a structured "worker-pending" -/// diagnostic and yields an empty active-alarm stream. -/// -/// -/// -/// Replaces the inline diagnostic strings in -/// MxAccessGatewayService.AcknowledgeAlarm / -/// QueryActiveAlarms from PR A.3 with an injectable seam. -/// When the worker dispatcher (PR A.6/A.7 dev-rig follow-up) lands, -/// WorkerAlarmRpcDispatcher replaces this implementation in -/// the DI container and the same handler shape comes alive without -/// further changes to the public RPC surface. -/// -/// -public sealed class NotWiredAlarmRpcDispatcher : IAlarmRpcDispatcher -{ - /// - public Task AcknowledgeAsync( - AcknowledgeAlarmRequest request, - CancellationToken cancellationToken) - { - return Task.FromResult(new AcknowledgeAlarmReply - { - SessionId = request.SessionId, - CorrelationId = request.ClientCorrelationId, - ProtocolStatus = MxAccessGrpcMapper.Ok("AcknowledgeAlarm accepted; worker dispatch pending dev-rig wiring."), - DiagnosticMessage = "Gateway-side AcknowledgeAlarm accepted; the worker-side AlarmClient consumer (PR A.5) is in place but the dispatcher hookup is gated on validating the AVEVA alarm-provider event subscription on the dev rig.", - }); - } - - /// -#pragma warning disable CS1998 // Async method lacks 'await' operators — empty stream is intentional. - public async IAsyncEnumerable QueryActiveAlarmsAsync( - QueryActiveAlarmsRequest request, - [EnumeratorCancellation] CancellationToken cancellationToken) - { - yield break; - } -#pragma warning restore CS1998 -} diff --git a/src/MxGateway.Server/Sessions/WorkerAlarmRpcDispatcher.cs b/src/MxGateway.Server/Sessions/WorkerAlarmRpcDispatcher.cs deleted file mode 100644 index 81b1561..0000000 --- a/src/MxGateway.Server/Sessions/WorkerAlarmRpcDispatcher.cs +++ /dev/null @@ -1,231 +0,0 @@ -using System.Collections.Generic; -using System.Runtime.CompilerServices; -using System.Threading; -using System.Threading.Tasks; -using Google.Protobuf.WellKnownTypes; -using MxGateway.Contracts.Proto; -using MxGateway.Server.Grpc; - -namespace MxGateway.Server.Sessions; - -/// -/// Production that routes the public -/// AcknowledgeAlarm + QueryActiveAlarms RPCs through the -/// worker pipe IPC. Replaces -/// once the worker AlarmCommandHandler is wired in. -/// -/// -/// -/// QueryActiveAlarms is fully wired: issues a -/// over the pipe and yields -/// each from the -/// . -/// -/// -/// AcknowledgeAlarm is partially wired: the public RPC's -/// is a -/// Provider!Group.Tag string, but the worker's wnwrap consumer -/// acks by GUID. When the supplied reference parses as a GUID -/// directly, the dispatcher forwards it as-is. Otherwise it -/// returns an Unimplemented diagnostic. Resolving -/// reference→GUID requires an additional worker IPC command -/// (e.g. AlarmAckByName wrapping -/// wwAlarmConsumerClass.AlarmAckByName) and is tracked as -/// a follow-up. -/// -/// -public sealed class WorkerAlarmRpcDispatcher : IAlarmRpcDispatcher -{ - private readonly ISessionRegistry sessionRegistry; - private readonly TimeProvider timeProvider; - - public WorkerAlarmRpcDispatcher(ISessionRegistry sessionRegistry, TimeProvider? timeProvider = null) - { - this.sessionRegistry = sessionRegistry ?? throw new System.ArgumentNullException(nameof(sessionRegistry)); - this.timeProvider = timeProvider ?? TimeProvider.System; - } - - /// - /// Parse a full alarm reference of the form Provider!Group.Tag - /// into its components. Convention: the first ! separates - /// provider from Group.Tag; the first . after the - /// ! separates group from tag (the tag itself may contain - /// more dots — e.g. TestMachine_001.TestAlarm001). - /// - /// true on a well-formed reference; false otherwise. - public static bool TryParseAlarmReference( - string? reference, - out string providerName, - out string groupName, - out string alarmName) - { - providerName = string.Empty; - groupName = string.Empty; - alarmName = string.Empty; - if (string.IsNullOrWhiteSpace(reference)) return false; - - int bang = reference!.IndexOf('!'); - if (bang <= 0 || bang == reference.Length - 1) return false; - - string left = reference[..bang]; - string right = reference[(bang + 1)..]; - int dot = right.IndexOf('.'); - if (dot <= 0 || dot == right.Length - 1) return false; - - providerName = left; - groupName = right[..dot]; - alarmName = right[(dot + 1)..]; - return true; - } - - /// - public async Task AcknowledgeAsync( - AcknowledgeAlarmRequest request, - CancellationToken cancellationToken) - { - if (request is null) throw new System.ArgumentNullException(nameof(request)); - - if (!sessionRegistry.TryGet(request.SessionId, out GatewaySession session)) - { - return new AcknowledgeAlarmReply - { - SessionId = request.SessionId, - CorrelationId = request.ClientCorrelationId, - ProtocolStatus = MxAccessGrpcMapper.SessionNotFound( - $"Session '{request.SessionId}' not found."), - DiagnosticMessage = "AcknowledgeAlarm: session not found.", - }; - } - - WorkerCommand workerCommand; - if (System.Guid.TryParse(request.AlarmFullReference, out System.Guid guid)) - { - workerCommand = new WorkerCommand - { - Command = new MxCommand - { - Kind = MxCommandKind.AcknowledgeAlarm, - AcknowledgeAlarmCommand = new AcknowledgeAlarmCommand - { - AlarmGuid = guid.ToString(), - Comment = request.Comment ?? string.Empty, - OperatorUser = request.OperatorUser ?? string.Empty, - // Operator node/domain/full-name are not on the public - // RPC surface today; pass empty strings so the worker - // honours the existing AcknowledgeAlarmCommand schema. - OperatorNode = string.Empty, - OperatorDomain = string.Empty, - OperatorFullName = string.Empty, - }, - }, - EnqueueTimestamp = Timestamp.FromDateTimeOffset(timeProvider.GetUtcNow()), - }; - } - else if (TryParseAlarmReference( - request.AlarmFullReference, - out string providerName, - out string groupName, - out string alarmName)) - { - workerCommand = new WorkerCommand - { - Command = new MxCommand - { - Kind = MxCommandKind.AcknowledgeAlarmByName, - AcknowledgeAlarmByNameCommand = new AcknowledgeAlarmByNameCommand - { - AlarmName = alarmName, - ProviderName = providerName, - GroupName = groupName, - Comment = request.Comment ?? string.Empty, - OperatorUser = request.OperatorUser ?? string.Empty, - OperatorNode = string.Empty, - OperatorDomain = string.Empty, - OperatorFullName = string.Empty, - }, - }, - EnqueueTimestamp = Timestamp.FromDateTimeOffset(timeProvider.GetUtcNow()), - }; - } - else - { - return new AcknowledgeAlarmReply - { - SessionId = request.SessionId, - CorrelationId = request.ClientCorrelationId, - ProtocolStatus = new ProtocolStatus - { - Code = ProtocolStatusCode.InvalidRequest, - Message = "AlarmFullReference must be a canonical GUID or 'Provider!Group.Tag' format.", - }, - DiagnosticMessage = $"AcknowledgeAlarm received unrecognized reference '{request.AlarmFullReference}'.", - }; - } - - WorkerCommandReply workerReply = await session.InvokeAsync(workerCommand, cancellationToken) - .ConfigureAwait(false); - - MxCommandReply mxReply = workerReply.Reply ?? new MxCommandReply - { - ProtocolStatus = new ProtocolStatus - { - Code = ProtocolStatusCode.ProtocolViolation, - Message = "Worker reply did not include an MxCommandReply.", - }, - }; - - AcknowledgeAlarmReply reply = new AcknowledgeAlarmReply - { - SessionId = request.SessionId, - CorrelationId = request.ClientCorrelationId, - ProtocolStatus = mxReply.ProtocolStatus ?? MxAccessGrpcMapper.Ok(), - DiagnosticMessage = mxReply.DiagnosticMessage ?? string.Empty, - }; - if (mxReply.HasHresult) - { - reply.Hresult = mxReply.Hresult; - } - return reply; - } - - /// - public async IAsyncEnumerable QueryActiveAlarmsAsync( - QueryActiveAlarmsRequest request, - [EnumeratorCancellation] CancellationToken cancellationToken) - { - if (request is null) throw new System.ArgumentNullException(nameof(request)); - - if (!sessionRegistry.TryGet(request.SessionId, out GatewaySession session)) - { - yield break; - } - - WorkerCommand workerCommand = new WorkerCommand - { - Command = new MxCommand - { - Kind = MxCommandKind.QueryActiveAlarms, - QueryActiveAlarmsCommand = new QueryActiveAlarmsCommand - { - AlarmFilterPrefix = request.AlarmFilterPrefix ?? string.Empty, - }, - }, - EnqueueTimestamp = Timestamp.FromDateTimeOffset(timeProvider.GetUtcNow()), - }; - - WorkerCommandReply workerReply = await session.InvokeAsync(workerCommand, cancellationToken) - .ConfigureAwait(false); - - MxCommandReply? mxReply = workerReply.Reply; - if (mxReply?.ProtocolStatus?.Code != ProtocolStatusCode.Ok) yield break; - - QueryActiveAlarmsReplyPayload? payload = mxReply.QueryActiveAlarms; - if (payload is null) yield break; - - foreach (ActiveAlarmSnapshot snapshot in payload.Snapshots) - { - cancellationToken.ThrowIfCancellationRequested(); - yield return snapshot; - } - } -} diff --git a/src/MxGateway.Server/Workers/WorkerClientOptions.cs b/src/MxGateway.Server/Workers/WorkerClientOptions.cs deleted file mode 100644 index f9af162..0000000 --- a/src/MxGateway.Server/Workers/WorkerClientOptions.cs +++ /dev/null @@ -1,39 +0,0 @@ -namespace MxGateway.Server.Workers; - -/// Configurable options for worker client behavior. -public sealed class WorkerClientOptions -{ - /// Default maximum age of a heartbeat before the client enters faulted state. - public static readonly TimeSpan DefaultHeartbeatGrace = TimeSpan.FromSeconds(15); - - /// Default interval for checking heartbeat staleness. - public static readonly TimeSpan DefaultHeartbeatCheckInterval = TimeSpan.FromSeconds(1); - - /// Default timeout when the event queue is full. - public static readonly TimeSpan DefaultEventChannelFullModeTimeout = TimeSpan.FromSeconds(5); - - /// Initializes options with default values. - public WorkerClientOptions() - { - HeartbeatGrace = DefaultHeartbeatGrace; - HeartbeatCheckInterval = DefaultHeartbeatCheckInterval; - EventChannelCapacity = 1_024; - EventChannelFullModeTimeout = DefaultEventChannelFullModeTimeout; - MaxPendingCommands = 128; - } - - /// Maximum allowed age of the last heartbeat before faulting the client. - public TimeSpan HeartbeatGrace { get; init; } - - /// Interval at which to check for heartbeat expiration. - public TimeSpan HeartbeatCheckInterval { get; init; } - - /// Maximum number of events buffered before backpressure is applied. - public int EventChannelCapacity { get; init; } - - /// Time to wait for the event queue to drain before faulting. - public TimeSpan EventChannelFullModeTimeout { get; init; } - - /// Maximum number of concurrent pending commands. - public int MaxPendingCommands { get; init; } -} diff --git a/src/MxGateway.Server/wwwroot/css/dashboard.css b/src/MxGateway.Server/wwwroot/css/dashboard.css deleted file mode 100644 index 32375a5..0000000 --- a/src/MxGateway.Server/wwwroot/css/dashboard.css +++ /dev/null @@ -1,165 +0,0 @@ -:root { - --mxgw-surface: #f7f8fa; - --mxgw-border: #d8dee6; - --mxgw-ink-muted: #667085; - --mxgw-accent: #146c64; -} - -.dashboard-body { - background: var(--mxgw-surface); - color: #1f2933; -} - -.dashboard-navbar { - min-height: 3.5rem; -} - -.dashboard-content { - padding: 1.25rem; -} - -.dashboard-page-header { - align-items: center; - display: flex; - gap: 1rem; - justify-content: space-between; - margin-bottom: 1rem; -} - -.dashboard-page-header h1, -.section-heading h2 { - font-size: 1.35rem; - font-weight: 650; - letter-spacing: 0; - margin: 0; -} - -.section-heading { - margin-bottom: .75rem; -} - -.dashboard-section { - background: #fff; - border-top: 1px solid var(--mxgw-border); - margin-top: 1rem; - padding: 1rem 0 0; -} - -.metric-grid { - display: grid; - gap: .75rem; - grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); -} - -.metric-grid.compact { - grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); -} - -.metric-card { - border-color: var(--mxgw-border); - border-radius: .375rem; - box-shadow: none; -} - -.metric-label { - color: var(--mxgw-ink-muted); - font-size: .78rem; - font-weight: 650; - letter-spacing: 0; - text-transform: uppercase; -} - -.metric-value { - color: var(--mxgw-accent); - font-size: 1.7rem; - font-weight: 700; - letter-spacing: 0; - line-height: 1.25; - overflow-wrap: anywhere; -} - -.metric-detail { - color: var(--mxgw-ink-muted); - font-size: .85rem; - overflow-wrap: anywhere; -} - -.dashboard-table { - --bs-table-bg: #fff; - border-color: var(--mxgw-border); - margin-bottom: 0; -} - -.dashboard-table th { - color: #344054; - font-weight: 650; - white-space: nowrap; -} - -.dashboard-table td { - max-width: 24rem; - overflow-wrap: anywhere; -} - -.details-table th { - width: 14rem; -} - -.empty-state { - background: #fff; - border: 1px dashed var(--mxgw-border); - border-radius: .375rem; - color: var(--mxgw-ink-muted); - padding: 1rem; -} - -.dashboard-login { - max-width: 28rem; -} - -.login-card { - border-color: var(--mxgw-border); - border-radius: .375rem; -} - -.api-key-management-grid { - display: grid; - gap: .75rem; - grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr)); -} - -.scope-grid { - display: grid; - gap: .35rem .75rem; - grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); -} - -.one-time-secret { - display: block; - overflow-wrap: anywhere; - white-space: normal; -} - -.api-key-create-modal { - display: block; -} - -.api-key-create-modal .modal-body { - max-height: min(70vh, 44rem); - overflow-y: auto; -} - -@media (max-width: 700px) { - .dashboard-content { - padding: .75rem; - } - - .dashboard-page-header { - align-items: flex-start; - flex-direction: column; - } - - .details-table th { - width: 9rem; - } -} diff --git a/src/MxGateway.Tests/Contracts/GatewayContractInfoTests.cs b/src/MxGateway.Tests/Contracts/GatewayContractInfoTests.cs deleted file mode 100644 index df98abe..0000000 --- a/src/MxGateway.Tests/Contracts/GatewayContractInfoTests.cs +++ /dev/null @@ -1,27 +0,0 @@ -using MxGateway.Contracts; - -namespace MxGateway.Tests.Contracts; - -public sealed class GatewayContractInfoTests -{ - /// Verifies that the default backend name is "mxaccess-worker". - [Fact] - public void DefaultBackendName_IsMxAccessWorker() - { - Assert.Equal("mxaccess-worker", GatewayContractInfo.DefaultBackendName); - } - - /// Verifies that the gateway protocol version is bumped to three after the alarm proto extension. - [Fact] - public void GatewayProtocolVersion_IsVersionThree() - { - Assert.Equal(3u, GatewayContractInfo.GatewayProtocolVersion); - } - - /// Verifies that the worker protocol version starts at version one. - [Fact] - public void WorkerProtocolVersion_StartsAtVersionOne() - { - Assert.Equal(1u, GatewayContractInfo.WorkerProtocolVersion); - } -} diff --git a/src/MxGateway.Tests/Contracts/ProtobufContractRoundTripTests.cs b/src/MxGateway.Tests/Contracts/ProtobufContractRoundTripTests.cs deleted file mode 100644 index f746766..0000000 --- a/src/MxGateway.Tests/Contracts/ProtobufContractRoundTripTests.cs +++ /dev/null @@ -1,392 +0,0 @@ -using Google.Protobuf; -using Google.Protobuf.WellKnownTypes; -using MxGateway.Contracts; -using MxGateway.Contracts.Proto; - -namespace MxGateway.Tests.Contracts; - -public sealed class ProtobufContractRoundTripTests -{ - /// Verifies that gateway descriptor contains expected public service methods. - [Fact] - public void GatewayDescriptor_ContainsInitialPublicServiceMethods() - { - var service = Assert.Single( - MxaccessGatewayReflection.Descriptor.Services, - descriptor => descriptor.Name == "MxAccessGateway"); - - Assert.Contains(service.Methods, method => method.Name == "OpenSession"); - Assert.Contains(service.Methods, method => method.Name == "CloseSession"); - Assert.Contains(service.Methods, method => method.Name == "Invoke"); - Assert.Contains(service.Methods, method => method.Name == "StreamEvents"); - Assert.Contains(service.Methods, method => method.Name == "AcknowledgeAlarm"); - Assert.Contains(service.Methods, method => method.Name == "QueryActiveAlarms"); - } - - /// Verifies that worker envelope descriptor contains required correlation fields. - [Fact] - public void WorkerEnvelopeDescriptor_ContainsRequiredCorrelationFields() - { - var fields = WorkerEnvelope.Descriptor.Fields.InDeclarationOrder(); - - Assert.Contains(fields, field => field.Name == "protocol_version"); - Assert.Contains(fields, field => field.Name == "session_id"); - Assert.Contains(fields, field => field.Name == "sequence"); - Assert.Contains(fields, field => field.Name == "correlation_id"); - } - - /// Verifies that command request round-trips through serialization. - [Fact] - public void CommandRequest_RoundTripsMethodSpecificPayload() - { - var original = new MxCommandRequest - { - SessionId = "session-1", - ClientCorrelationId = "client-correlation-1", - Command = new MxCommand - { - Kind = MxCommandKind.Register, - Register = new RegisterCommand - { - ClientName = "mxaccessgw-test-client", - }, - }, - }; - - var parsed = MxCommandRequest.Parser.ParseFrom(original.ToByteArray()); - - Assert.Equal(original, parsed); - Assert.Equal(MxCommand.PayloadOneofCase.Register, parsed.Command.PayloadCase); - } - - /// Verifies that command reply round-trips with return values and statuses. - [Fact] - public void CommandReply_RoundTripsHResultReturnValueOutParamsAndStatuses() - { - var original = new MxCommandReply - { - SessionId = "session-1", - CorrelationId = "gateway-correlation-1", - Kind = MxCommandKind.AddItem, - ProtocolStatus = new ProtocolStatus - { - Code = ProtocolStatusCode.Ok, - }, - Hresult = 0, - ReturnValue = new MxValue - { - DataType = MxDataType.Integer, - Int32Value = 1234, - VariantType = "VT_I4", - }, - AddItem = new AddItemReply - { - ItemHandle = 1234, - }, - }; - original.Statuses.Add(new MxStatusProxy - { - Success = 1, - Category = MxStatusCategory.Ok, - DetectedBy = MxStatusSource.RespondingLmx, - Detail = 0, - }); - - var parsed = MxCommandReply.Parser.ParseFrom(original.ToByteArray()); - - Assert.Equal(original, parsed); - Assert.True(parsed.HasHresult); - Assert.Equal(MxCommandReply.PayloadOneofCase.AddItem, parsed.PayloadCase); - Assert.Single(parsed.Statuses); - } - - /// Verifies that event round-trips with value, status, and sequence. - [Fact] - public void Event_RoundTripsValueStatusSequenceAndBufferedBody() - { - var timestamp = Timestamp.FromDateTime(new DateTime(2026, 4, 26, 20, 0, 0, DateTimeKind.Utc)); - var original = new MxEvent - { - Family = MxEventFamily.OnBufferedDataChange, - SessionId = "session-1", - ServerHandle = 10, - ItemHandle = 20, - Value = new MxValue - { - DataType = MxDataType.Float, - ArrayValue = new MxArray - { - ElementDataType = MxDataType.Float, - FloatValues = new FloatArray - { - Values = { 1.5f, 2.5f }, - }, - Dimensions = { 2 }, - VariantType = "VT_ARRAY|VT_R4", - }, - }, - Quality = 192, - SourceTimestamp = timestamp, - WorkerSequence = 42, - WorkerTimestamp = timestamp, - GatewayReceiveTimestamp = timestamp, - OnBufferedDataChange = new OnBufferedDataChangeEvent - { - DataType = MxDataType.Float, - QualityValues = new MxArray - { - ElementDataType = MxDataType.Integer, - Int32Values = new Int32Array - { - Values = { 192, 192 }, - }, - Dimensions = { 2 }, - }, - TimestampValues = new MxArray - { - ElementDataType = MxDataType.Time, - TimestampValues = new TimestampArray - { - Values = { timestamp, timestamp }, - }, - Dimensions = { 2 }, - }, - }, - }; - original.Statuses.Add(new MxStatusProxy - { - Success = 1, - Category = MxStatusCategory.Ok, - DetectedBy = MxStatusSource.RespondingNmx, - Detail = 0, - }); - - var parsed = MxEvent.Parser.ParseFrom(original.ToByteArray()); - - Assert.Equal(original, parsed); - Assert.Equal(MxEvent.BodyOneofCase.OnBufferedDataChange, parsed.BodyCase); - Assert.Single(parsed.Statuses); - } - - /// Verifies that worker envelope round-trips through serialization preserving protocol and command fields. - [Fact] - public void WorkerEnvelope_RoundTripsProtocolFieldsAndCommandBody() - { - var original = new WorkerEnvelope - { - ProtocolVersion = GatewayContractInfo.WorkerProtocolVersion, - SessionId = "session-1", - Sequence = 7, - CorrelationId = "gateway-correlation-1", - WorkerCommand = new WorkerCommand - { - EnqueueTimestamp = Timestamp.FromDateTime( - new DateTime(2026, 4, 26, 20, 5, 0, DateTimeKind.Utc)), - Command = new MxCommand - { - Kind = MxCommandKind.Advise, - Advise = new AdviseCommand - { - ServerHandle = 10, - ItemHandle = 20, - }, - }, - }, - }; - - var parsed = WorkerEnvelope.Parser.ParseFrom(original.ToByteArray()); - - Assert.Equal(original, parsed); - Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerCommand, parsed.BodyCase); - Assert.Equal(MxCommand.PayloadOneofCase.Advise, parsed.WorkerCommand.Command.PayloadCase); - } - - /// Verifies that an OnAlarmTransition event round-trips with full payload. - [Fact] - public void Event_RoundTripsOnAlarmTransitionWithFullPayload() - { - var raise = Timestamp.FromDateTime(new DateTime(2026, 5, 1, 12, 0, 0, DateTimeKind.Utc)); - var ack = Timestamp.FromDateTime(new DateTime(2026, 5, 1, 12, 0, 30, DateTimeKind.Utc)); - var original = new MxEvent - { - Family = MxEventFamily.OnAlarmTransition, - SessionId = "session-1", - WorkerSequence = 99, - WorkerTimestamp = ack, - GatewayReceiveTimestamp = ack, - OnAlarmTransition = new OnAlarmTransitionEvent - { - AlarmFullReference = "Tank01.Level.HiHi", - SourceObjectReference = "Tank01", - AlarmTypeName = "AnalogLimitAlarm.HiHi", - TransitionKind = AlarmTransitionKind.Acknowledge, - Severity = 750, - OriginalRaiseTimestamp = raise, - TransitionTimestamp = ack, - OperatorUser = "operator1", - OperatorComment = "investigating", - Category = "Process", - Description = "Tank 01 high-high level", - CurrentValue = new MxValue - { - DataType = MxDataType.Float, - FloatValue = 95.4f, - VariantType = "VT_R4", - }, - LimitValue = new MxValue - { - DataType = MxDataType.Float, - FloatValue = 90.0f, - VariantType = "VT_R4", - }, - }, - }; - - var parsed = MxEvent.Parser.ParseFrom(original.ToByteArray()); - - Assert.Equal(original, parsed); - Assert.Equal(MxEvent.BodyOneofCase.OnAlarmTransition, parsed.BodyCase); - Assert.Equal(AlarmTransitionKind.Acknowledge, parsed.OnAlarmTransition.TransitionKind); - Assert.Equal(raise, parsed.OnAlarmTransition.OriginalRaiseTimestamp); - Assert.Equal("operator1", parsed.OnAlarmTransition.OperatorUser); - } - - /// Verifies that an OnAlarmTransition event round-trips with only the required fields populated. - [Fact] - public void Event_RoundTripsOnAlarmTransitionWithOptionalFieldsEmpty() - { - var raise = Timestamp.FromDateTime(new DateTime(2026, 5, 1, 12, 0, 0, DateTimeKind.Utc)); - var original = new MxEvent - { - Family = MxEventFamily.OnAlarmTransition, - SessionId = "session-1", - WorkerSequence = 100, - OnAlarmTransition = new OnAlarmTransitionEvent - { - AlarmFullReference = "Tank01.Level.HiHi", - AlarmTypeName = "AnalogLimitAlarm.HiHi", - TransitionKind = AlarmTransitionKind.Raise, - Severity = 750, - TransitionTimestamp = raise, - }, - }; - - var parsed = MxEvent.Parser.ParseFrom(original.ToByteArray()); - - Assert.Equal(original, parsed); - Assert.Equal(string.Empty, parsed.OnAlarmTransition.OperatorUser); - Assert.Equal(string.Empty, parsed.OnAlarmTransition.OperatorComment); - Assert.Null(parsed.OnAlarmTransition.OriginalRaiseTimestamp); - Assert.Null(parsed.OnAlarmTransition.CurrentValue); - } - - /// Verifies that an MxEvent body oneof rejects multiple bodies — last write wins per proto3 semantics. - [Fact] - public void Event_OneofGuard_LastBodyWins() - { - var ev = new MxEvent - { - Family = MxEventFamily.OnAlarmTransition, - OnDataChange = new OnDataChangeEvent(), - OnAlarmTransition = new OnAlarmTransitionEvent - { - AlarmFullReference = "X", - TransitionKind = AlarmTransitionKind.Raise, - }, - }; - - Assert.Equal(MxEvent.BodyOneofCase.OnAlarmTransition, ev.BodyCase); - Assert.Null(ev.OnDataChange); - } - - /// Verifies that AcknowledgeAlarmRequest round-trips through serialization. - [Fact] - public void AcknowledgeAlarmRequest_RoundTripsAllFields() - { - var original = new AcknowledgeAlarmRequest - { - SessionId = "session-1", - ClientCorrelationId = "client-correlation-7", - AlarmFullReference = "Tank01.Level.HiHi", - Comment = "shift handover", - OperatorUser = "operator2", - }; - - var parsed = AcknowledgeAlarmRequest.Parser.ParseFrom(original.ToByteArray()); - - Assert.Equal(original, parsed); - } - - /// Verifies that AcknowledgeAlarmReply round-trips with status, hresult, and diagnostics. - [Fact] - public void AcknowledgeAlarmReply_RoundTripsStatusAndHresult() - { - var original = new AcknowledgeAlarmReply - { - SessionId = "session-1", - CorrelationId = "gateway-correlation-7", - ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok }, - Hresult = 0, - Status = new MxStatusProxy - { - Success = 1, - Category = MxStatusCategory.Ok, - DetectedBy = MxStatusSource.RespondingLmx, - }, - DiagnosticMessage = "ack accepted", - }; - - var parsed = AcknowledgeAlarmReply.Parser.ParseFrom(original.ToByteArray()); - - Assert.Equal(original, parsed); - Assert.True(parsed.HasHresult); - } - - /// Verifies that ActiveAlarmSnapshot round-trips with current state and operator metadata. - [Fact] - public void ActiveAlarmSnapshot_RoundTripsAllFields() - { - var raise = Timestamp.FromDateTime(new DateTime(2026, 5, 1, 12, 0, 0, DateTimeKind.Utc)); - var ack = Timestamp.FromDateTime(new DateTime(2026, 5, 1, 12, 0, 30, DateTimeKind.Utc)); - var original = new ActiveAlarmSnapshot - { - AlarmFullReference = "Tank01.Level.HiHi", - SourceObjectReference = "Tank01", - AlarmTypeName = "AnalogLimitAlarm.HiHi", - Severity = 750, - OriginalRaiseTimestamp = raise, - CurrentState = AlarmConditionState.ActiveAcked, - Category = "Process", - Description = "Tank 01 high-high level", - LastTransitionTimestamp = ack, - OperatorUser = "operator2", - OperatorComment = "investigating", - }; - - var parsed = ActiveAlarmSnapshot.Parser.ParseFrom(original.ToByteArray()); - - Assert.Equal(original, parsed); - Assert.Equal(AlarmConditionState.ActiveAcked, parsed.CurrentState); - } - - /// Verifies that QueryActiveAlarmsRequest round-trips empty filter prefix. - [Fact] - public void QueryActiveAlarmsRequest_RoundTripsWithAndWithoutFilter() - { - var withoutFilter = new QueryActiveAlarmsRequest - { - SessionId = "session-1", - ClientCorrelationId = "client-correlation-8", - }; - - var withFilter = new QueryActiveAlarmsRequest - { - SessionId = "session-1", - ClientCorrelationId = "client-correlation-9", - AlarmFilterPrefix = "Tank01.", - }; - - Assert.Equal(withoutFilter, QueryActiveAlarmsRequest.Parser.ParseFrom(withoutFilter.ToByteArray())); - Assert.Equal(withFilter, QueryActiveAlarmsRequest.Parser.ParseFrom(withFilter.ToByteArray())); - } -} diff --git a/src/MxGateway.Tests/Galaxy/GalaxyHierarchyCacheTests.cs b/src/MxGateway.Tests/Galaxy/GalaxyHierarchyCacheTests.cs deleted file mode 100644 index ccd5ef8..0000000 --- a/src/MxGateway.Tests/Galaxy/GalaxyHierarchyCacheTests.cs +++ /dev/null @@ -1,139 +0,0 @@ -using MxGateway.Server.Galaxy; -using MxGateway.Contracts.Proto.Galaxy; - -namespace MxGateway.Tests.Galaxy; - -public sealed class GalaxyHierarchyCacheTests -{ - /// - /// Verifies cache returns empty entry before any refresh occurs. - /// - [Fact] - public void Current_BeforeAnyRefresh_ReturnsEmpty() - { - GalaxyDeployNotifier notifier = new(); - GalaxyHierarchyCache cache = CreateCache(notifier, new ManualTimeProvider()); - - GalaxyHierarchyCacheEntry entry = cache.Current; - - Assert.Equal(GalaxyCacheStatus.Unknown, entry.Status); - Assert.False(entry.HasData); - Assert.Equal(0, entry.ObjectCount); - Assert.Empty(entry.Objects); - } - - /// - /// Verifies cache marks unavailable and does not publish when SQL is unreachable. - /// - [Fact] - public async Task RefreshAsync_WhenSqlIsUnreachable_MarksUnavailableAndDoesNotPublish() - { - GalaxyDeployNotifier notifier = new(); - ManualTimeProvider clock = new(DateTimeOffset.Parse("2026-04-28T12:00:00Z")); - GalaxyHierarchyCache cache = CreateCache(notifier, clock); - - await cache.RefreshAsync(CancellationToken.None); - - Assert.Equal(GalaxyCacheStatus.Unavailable, cache.Current.Status); - Assert.False(string.IsNullOrWhiteSpace(cache.Current.LastError)); - Assert.Null(notifier.Latest); - Assert.True(cache.WaitForFirstLoadAsync(CancellationToken.None).IsCompletedSuccessfully); - } - - /// - /// Verifies HasData returns true for healthy cache entries. - /// - [Fact] - public void HasData_OnHealthyEntry_IsTrue() - { - GalaxyHierarchyCacheEntry entry = GalaxyHierarchyCacheEntry.Empty with - { - Status = GalaxyCacheStatus.Healthy, - LastSuccessAt = DateTimeOffset.UtcNow, - ObjectCount = 1, - }; - - Assert.True(entry.HasData); - } - - /// - /// Verifies HasData returns false for unknown cache entries. - /// - [Fact] - public void HasData_OnUnknownEntry_IsFalse() - { - Assert.False(GalaxyHierarchyCacheEntry.Empty.HasData); - } - - [Fact] - public void GalaxyHierarchyIndex_BuildsPathsAndTagLookupsWithoutThrowingOnBadMetadata() - { - GalaxyObject root = new() - { - GobjectId = 1, - TagName = "Area1", - ContainedName = "Area1", - }; - GalaxyObject duplicate = new() - { - GobjectId = 1, - TagName = "DuplicateArea", - ContainedName = "DuplicateArea", - }; - GalaxyObject child = new() - { - GobjectId = 2, - ParentGobjectId = 1, - TagName = "Pump_001", - ContainedName = "Pump", - Attributes = - { - new GalaxyAttribute - { - FullTagReference = "Pump_001.PV", - IsHistorized = true, - }, - }, - }; - GalaxyObject orphan = new() - { - GobjectId = 3, - ParentGobjectId = 99, - TagName = "Orphan_001", - ContainedName = "Orphan", - }; - - GalaxyHierarchyIndex index = GalaxyHierarchyIndex.Build([root, duplicate, child, orphan]); - - Assert.Equal("Area1/Pump", index.ObjectViewsById[2].ContainedPath); - Assert.Equal("Orphan", index.ObjectViewsById[3].ContainedPath); - Assert.Same(child, index.TagsByAddress["Pump_001.PV"].Object); - Assert.NotNull(index.TagsByAddress["Pump_001.PV"].Attribute); - Assert.Same(root, index.ObjectViewsById[1].Object); - } - - private static GalaxyHierarchyCache CreateCache(GalaxyDeployNotifier notifier, TimeProvider clock) - { - GalaxyRepositoryOptions options = new() - { - ConnectionString = "Server=127.0.0.1,65500;Database=ZB;Connection Timeout=1;Encrypt=False;", - CommandTimeoutSeconds = 1, - }; - MxGateway.Server.Galaxy.GalaxyRepository repository = new(options); - return new GalaxyHierarchyCache(repository, notifier, clock); - } - - private sealed class ManualTimeProvider(DateTimeOffset start = default) : TimeProvider - { - private DateTimeOffset _now = start == default ? DateTimeOffset.UtcNow : start; - - /// - public override DateTimeOffset GetUtcNow() => _now; - - /// - /// Advances the current time by the specified duration. - /// - /// Time duration to advance. - public void Advance(TimeSpan duration) => _now += duration; - } -} diff --git a/src/MxGateway.Tests/Gateway/GatewayApplicationTests.cs b/src/MxGateway.Tests/Gateway/GatewayApplicationTests.cs deleted file mode 100644 index 5bf3710..0000000 --- a/src/MxGateway.Tests/Gateway/GatewayApplicationTests.cs +++ /dev/null @@ -1,138 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; -using MxGateway.Server; -using MxGateway.Server.Metrics; - -namespace MxGateway.Tests.Gateway; - -public sealed class GatewayApplicationTests -{ - /// Verifies that Build maps the live health check endpoint. - [Fact] - public void Build_MapsLiveHealthEndpoint() - { - WebApplication app = GatewayApplication.Build([]); - - RouteEndpoint endpoint = Assert.Single( - ((IEndpointRouteBuilder)app).DataSources - .SelectMany(dataSource => dataSource.Endpoints) - .OfType(), - candidate => candidate.RoutePattern.RawText == "/health/live"); - - Assert.Equal("LiveHealth", endpoint.Metadata.GetMetadata()?.EndpointName); - } - - /// Verifies that Build registers the gateway metrics service. - [Fact] - public void Build_RegistersGatewayMetrics() - { - WebApplication app = GatewayApplication.Build([]); - - GatewayMetrics metrics = app.Services.GetRequiredService(); - - Assert.NotNull(metrics); - } - - /// Verifies that Build maps dashboard and authentication endpoints when the dashboard is enabled. - [Fact] - public void Build_WhenDashboardEnabled_MapsBlazorDashboardAndAuthEndpoints() - { - WebApplication app = GatewayApplication.Build([]); - IReadOnlyList endpoints = GetRouteEndpoints(app); - - Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/dashboard/"); - Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/dashboard/sessions"); - Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/dashboard/workers"); - Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/dashboard/events"); - Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/dashboard/settings"); - Assert.Contains(endpoints, endpoint => - endpoint.Metadata.GetMetadata()?.EndpointName == "DashboardLogin"); - Assert.Contains(endpoints, endpoint => - endpoint.Metadata.GetMetadata()?.EndpointName == "DashboardLogout"); - } - - /// Verifies that Build does not map dashboard routes when the dashboard is disabled. - [Fact] - public void Build_WhenDashboardEnabled_DashboardRoutesAllowAnonymousAccess() - { - WebApplication app = GatewayApplication.Build([]); - IReadOnlyList endpoints = GetRouteEndpoints(app) - .Where(endpoint => endpoint.RoutePattern.RawText?.StartsWith( - "/dashboard", - StringComparison.Ordinal) == true) - .ToArray(); - - Assert.NotEmpty(endpoints); - Assert.DoesNotContain(endpoints, endpoint => endpoint.Metadata.GetMetadata() is not null); - } - - [Fact] - public void Build_WhenDashboardDisabled_DoesNotMapDashboardRoutes() - { - WebApplication app = GatewayApplication.Build(["--MxGateway:Dashboard:Enabled=false"]); - IReadOnlyList endpoints = GetRouteEndpoints(app); - - Assert.DoesNotContain(endpoints, endpoint => - endpoint.RoutePattern.RawText?.StartsWith("/dashboard", StringComparison.Ordinal) == true); - Assert.DoesNotContain(endpoints, endpoint => - endpoint.Metadata.GetMetadata()?.EndpointName?.StartsWith( - "Dashboard", - StringComparison.Ordinal) == true); - } - - /// Verifies that StartAsync fails when gateway configuration is invalid. - /// Configuration key to override. - /// Invalid configuration value. - /// Expected validation error message. - [Theory] - [InlineData( - "MxGateway:Worker:ExecutablePath", - "worker.dll", - "MxGateway:Worker:ExecutablePath must point to a .exe file.")] - [InlineData( - "MxGateway:Events:QueueCapacity", - "0", - "MxGateway:Events:QueueCapacity must be greater than zero.")] - [InlineData( - "MxGateway:Authentication:PepperSecretName", - "", - "MxGateway:Authentication:PepperSecretName is required")] - [InlineData( - "MxGateway:Dashboard:PathBase", - "dashboard", - "MxGateway:Dashboard:PathBase must start with '/'.")] - [InlineData( - "MxGateway:Ldap:RequiredGroup", - "", - "MxGateway:Ldap:RequiredGroup is required when LDAP login is enabled.")] - [InlineData( - "MxGateway:Ldap:AllowInsecureLdap", - "false", - "MxGateway:Ldap:AllowInsecureLdap must be true when UseTls is false.")] - public async Task StartAsync_InvalidGatewayConfiguration_FailsStartup( - string key, - string value, - string expectedFailure) - { - await using WebApplication app = GatewayApplication.Build( - [$"--{key}={value}", "--urls=http://127.0.0.1:0"]); - - OptionsValidationException exception = await Assert.ThrowsAsync( - () => app.StartAsync()); - - Assert.Contains( - exception.Failures, - failure => failure.Contains(expectedFailure, StringComparison.Ordinal)); - } - - private static IReadOnlyList GetRouteEndpoints(WebApplication app) - { - return ((IEndpointRouteBuilder)app).DataSources - .SelectMany(dataSource => dataSource.Endpoints) - .OfType() - .ToArray(); - } -} diff --git a/src/MxGateway.Tests/Gateway/Sessions/NotWiredAlarmRpcDispatcherTests.cs b/src/MxGateway.Tests/Gateway/Sessions/NotWiredAlarmRpcDispatcherTests.cs deleted file mode 100644 index d416110..0000000 --- a/src/MxGateway.Tests/Gateway/Sessions/NotWiredAlarmRpcDispatcherTests.cs +++ /dev/null @@ -1,54 +0,0 @@ -using System.Threading; -using System.Threading.Tasks; -using MxGateway.Contracts.Proto; -using MxGateway.Server.Sessions; - -namespace MxGateway.Tests.Gateway.Sessions; - -/// -/// PR A.6 / A.7 — pins the not-yet-wired dispatcher's behaviour: -/// AcknowledgeAsync returns OK with a worker-pending diagnostic and -/// QueryActiveAlarmsAsync yields an empty stream. Production -/// WorkerAlarmRpcDispatcher (dev-rig follow-up) replaces this -/// impl in DI without changing the gateway handler shape. -/// -public sealed class NotWiredAlarmRpcDispatcherTests -{ - [Fact] - public async Task AcknowledgeAsync_returns_ok_with_worker_pending_diagnostic() - { - IAlarmRpcDispatcher dispatcher = new NotWiredAlarmRpcDispatcher(); - - AcknowledgeAlarmReply reply = await dispatcher.AcknowledgeAsync( - new AcknowledgeAlarmRequest - { - SessionId = "session-1", - ClientCorrelationId = "corr-1", - AlarmFullReference = "Tank01.Level.HiHi", - Comment = "investigating", - OperatorUser = "alice", - }, - CancellationToken.None); - - Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); - Assert.Equal("session-1", reply.SessionId); - Assert.Equal("corr-1", reply.CorrelationId); - Assert.Contains("worker", reply.DiagnosticMessage, System.StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task QueryActiveAlarmsAsync_yields_no_snapshots() - { - IAlarmRpcDispatcher dispatcher = new NotWiredAlarmRpcDispatcher(); - - int count = 0; - await foreach (ActiveAlarmSnapshot _ in dispatcher.QueryActiveAlarmsAsync( - new QueryActiveAlarmsRequest { SessionId = "session-1" }, - CancellationToken.None)) - { - count++; - } - - Assert.Equal(0, count); - } -} diff --git a/src/MxGateway.Tests/Gateway/Sessions/SessionManagerAlarmAutoSubscribeTests.cs b/src/MxGateway.Tests/Gateway/Sessions/SessionManagerAlarmAutoSubscribeTests.cs deleted file mode 100644 index 89dfc84..0000000 --- a/src/MxGateway.Tests/Gateway/Sessions/SessionManagerAlarmAutoSubscribeTests.cs +++ /dev/null @@ -1,266 +0,0 @@ -using System.Runtime.CompilerServices; -using Google.Protobuf.WellKnownTypes; -using Microsoft.Extensions.Options; -using MxGateway.Contracts.Proto; -using MxGateway.Server.Configuration; -using MxGateway.Server.Metrics; -using MxGateway.Server.Sessions; -using MxGateway.Server.Workers; - -namespace MxGateway.Tests.Gateway.Sessions; - -/// -/// Pins the alarm auto-subscribe hook on session open. Runs in -/// its own file because the cases are orthogonal to -/// (alarms-disabled vs. -/// alarms-enabled lanes), and the fake worker client below verifies -/// the issued SubscribeAlarms command shape directly. -/// -public sealed class SessionManagerAlarmAutoSubscribeTests -{ - [Fact] - public async Task OpenSessionAsync_DoesNotAutoSubscribe_WhenAlarmsDisabled() - { - AlarmAutoSubscribeWorkerClient worker = new(); - SessionManager manager = NewManager(worker, alarms: new AlarmsOptions { Enabled = false }); - - await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", CancellationToken.None); - - Assert.Equal(0, worker.SubscribeAlarmsInvokeCount); - } - - [Fact] - public async Task OpenSessionAsync_AutoSubscribes_WhenEnabledWithExpression() - { - AlarmAutoSubscribeWorkerClient worker = new(); - SessionManager manager = NewManager(worker, alarms: new AlarmsOptions - { - Enabled = true, - SubscriptionExpression = @"\\HOST\Galaxy!Area1", - }); - - GatewaySession session = await manager.OpenSessionAsync( - CreateOpenRequest(), "client-1", CancellationToken.None); - - Assert.Equal(SessionState.Ready, session.State); - Assert.Equal(1, worker.SubscribeAlarmsInvokeCount); - Assert.Equal(@"\\HOST\Galaxy!Area1", - worker.LastSubscribeAlarmsCommand!.SubscriptionExpression); - } - - [Fact] - public async Task OpenSessionAsync_FallsBackToDefaultArea_WhenExpressionEmpty() - { - AlarmAutoSubscribeWorkerClient worker = new(); - SessionManager manager = NewManager(worker, alarms: new AlarmsOptions - { - Enabled = true, - DefaultArea = "DEV", - }); - - await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", CancellationToken.None); - - Assert.Equal(1, worker.SubscribeAlarmsInvokeCount); - Assert.Contains(@"\Galaxy!DEV", - worker.LastSubscribeAlarmsCommand!.SubscriptionExpression); - } - - [Fact] - public async Task OpenSessionAsync_Succeeds_WhenAutoSubscribeFailsWithRequireOff() - { - // Worker rejects the SubscribeAlarms command. With RequireSubscribeOnOpen=false - // (the default), the session still opens — alarm-side commands later return - // "not subscribed", but data subscriptions work. - AlarmAutoSubscribeWorkerClient worker = new() - { - SubscribeAlarmsReplyFactory = _ => new MxCommandReply - { - Kind = MxCommandKind.SubscribeAlarms, - ProtocolStatus = new ProtocolStatus - { - Code = ProtocolStatusCode.MxaccessFailure, - Message = "wnwrap subscribe failed", - }, - DiagnosticMessage = "alarm provider unavailable", - }, - }; - SessionManager manager = NewManager(worker, alarms: new AlarmsOptions - { - Enabled = true, - SubscriptionExpression = @"\\HOST\Galaxy!Area1", - RequireSubscribeOnOpen = false, - }); - - GatewaySession session = await manager.OpenSessionAsync( - CreateOpenRequest(), "client-1", CancellationToken.None); - - Assert.Equal(SessionState.Ready, session.State); - Assert.Equal(1, worker.SubscribeAlarmsInvokeCount); - } - - [Fact] - public async Task OpenSessionAsync_Throws_WhenAutoSubscribeFailsWithRequireOn() - { - AlarmAutoSubscribeWorkerClient worker = new() - { - SubscribeAlarmsReplyFactory = _ => new MxCommandReply - { - Kind = MxCommandKind.SubscribeAlarms, - ProtocolStatus = new ProtocolStatus - { - Code = ProtocolStatusCode.MxaccessFailure, - Message = "wnwrap subscribe failed", - }, - }, - }; - SessionManager manager = NewManager(worker, alarms: new AlarmsOptions - { - Enabled = true, - SubscriptionExpression = @"\\HOST\Galaxy!Area1", - RequireSubscribeOnOpen = true, - }); - - await Assert.ThrowsAsync( - async () => await manager.OpenSessionAsync( - CreateOpenRequest(), "client-1", CancellationToken.None)); - } - - [Fact] - public async Task OpenSessionAsync_Throws_WhenEnabledButNoExpressionAndRequireOn() - { - AlarmAutoSubscribeWorkerClient worker = new(); - SessionManager manager = NewManager(worker, alarms: new AlarmsOptions - { - Enabled = true, - // No SubscriptionExpression and no DefaultArea. - RequireSubscribeOnOpen = true, - }); - - await Assert.ThrowsAsync( - async () => await manager.OpenSessionAsync( - CreateOpenRequest(), "client-1", CancellationToken.None)); - Assert.Equal(0, worker.SubscribeAlarmsInvokeCount); - } - - [Fact] - public async Task OpenSessionAsync_Succeeds_WhenEnabledButNoExpressionAndRequireOff() - { - AlarmAutoSubscribeWorkerClient worker = new(); - SessionManager manager = NewManager(worker, alarms: new AlarmsOptions - { - Enabled = true, - // No SubscriptionExpression and no DefaultArea — default require=false. - }); - - GatewaySession session = await manager.OpenSessionAsync( - CreateOpenRequest(), "client-1", CancellationToken.None); - - Assert.Equal(SessionState.Ready, session.State); - Assert.Equal(0, worker.SubscribeAlarmsInvokeCount); - } - - private static SessionManager NewManager( - AlarmAutoSubscribeWorkerClient worker, - AlarmsOptions alarms) - { - FakeSessionWorkerClientFactory factory = new(worker); - GatewayOptions options = new GatewayOptions - { - Sessions = new SessionOptions - { - DefaultCommandTimeoutSeconds = 30, - MaxSessions = 64, - DefaultLeaseSeconds = 1800, - }, - Worker = new WorkerOptions - { - StartupTimeoutSeconds = 30, - ShutdownTimeoutSeconds = 10, - }, - Alarms = alarms, - }; - return new SessionManager( - new SessionRegistry(), - factory, - Options.Create(options), - new GatewayMetrics()); - } - - private static SessionOpenRequest CreateOpenRequest() - { - return new SessionOpenRequest( - RequestedBackend: null, - ClientSessionName: "test-session", - ClientCorrelationId: "client-correlation-1", - CommandTimeout: Duration.FromTimeSpan(TimeSpan.FromSeconds(5))); - } - - private sealed class FakeSessionWorkerClientFactory(IWorkerClient client) : ISessionWorkerClientFactory - { - public Task CreateAsync( - GatewaySession session, - CancellationToken cancellationToken) - { - return Task.FromResult(client); - } - } - - private sealed class AlarmAutoSubscribeWorkerClient : IWorkerClient - { - public string SessionId { get; } = "session-1"; - public int? ProcessId { get; } = 1234; - public WorkerClientState State { get; set; } = WorkerClientState.Ready; - public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow; - - public int SubscribeAlarmsInvokeCount { get; private set; } - public SubscribeAlarmsCommand? LastSubscribeAlarmsCommand { get; private set; } - public Func? SubscribeAlarmsReplyFactory { get; init; } - - public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; - - public Task InvokeAsync( - WorkerCommand command, TimeSpan timeout, CancellationToken cancellationToken) - { - if (command.Command?.Kind == MxCommandKind.SubscribeAlarms) - { - SubscribeAlarmsInvokeCount++; - LastSubscribeAlarmsCommand = command.Command.SubscribeAlarms; - MxCommandReply reply = SubscribeAlarmsReplyFactory?.Invoke(command) - ?? new MxCommandReply - { - Kind = MxCommandKind.SubscribeAlarms, - ProtocolStatus = new ProtocolStatus - { - Code = ProtocolStatusCode.Ok, - Message = "OK", - }, - }; - return Task.FromResult(new WorkerCommandReply { Reply = reply }); - } - return Task.FromResult(new WorkerCommandReply - { - Reply = new MxCommandReply - { - Kind = command.Command?.Kind ?? MxCommandKind.Unspecified, - ProtocolStatus = new ProtocolStatus - { - Code = ProtocolStatusCode.Ok, - Message = "OK", - }, - }, - }); - } - - public async IAsyncEnumerable ReadEventsAsync( - [EnumeratorCancellation] CancellationToken cancellationToken) - { - await Task.CompletedTask; - yield break; - } - - public Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken) - => Task.CompletedTask; - public void Kill(string reason) { } - public ValueTask DisposeAsync() => ValueTask.CompletedTask; - } -} diff --git a/src/MxGateway.Tests/Gateway/Sessions/WorkerAlarmRpcDispatcherTests.cs b/src/MxGateway.Tests/Gateway/Sessions/WorkerAlarmRpcDispatcherTests.cs deleted file mode 100644 index dce9148..0000000 --- a/src/MxGateway.Tests/Gateway/Sessions/WorkerAlarmRpcDispatcherTests.cs +++ /dev/null @@ -1,374 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Runtime.CompilerServices; -using System.Threading; -using System.Threading.Tasks; -using MxGateway.Contracts.Proto; -using MxGateway.Server.Sessions; -using MxGateway.Server.Workers; - -namespace MxGateway.Tests.Gateway.Sessions; - -/// -/// Pins the production 's behaviour: -/// resolves the session by id, issues the matching MxCommand over the -/// worker pipe, and unwraps the reply into AcknowledgeAlarmReply or the -/// ActiveAlarmSnapshot stream. -/// -public sealed class WorkerAlarmRpcDispatcherTests -{ - [Fact] - public async Task AcknowledgeAsync_returns_session_not_found_when_session_missing() - { - SessionRegistry registry = new SessionRegistry(); - WorkerAlarmRpcDispatcher dispatcher = new WorkerAlarmRpcDispatcher(registry); - - AcknowledgeAlarmReply reply = await dispatcher.AcknowledgeAsync( - new AcknowledgeAlarmRequest - { - SessionId = "missing", - ClientCorrelationId = "c1", - AlarmFullReference = Guid.NewGuid().ToString(), - }, - CancellationToken.None); - - Assert.Equal(ProtocolStatusCode.SessionNotFound, reply.ProtocolStatus.Code); - } - - [Fact] - public async Task AcknowledgeAsync_forwards_guid_and_returns_native_status() - { - SessionRegistry registry = new SessionRegistry(); - Guid alarmGuid = Guid.NewGuid(); - FakeAlarmWorkerClient worker = new FakeAlarmWorkerClient - { - ReplyFactory = command => - { - Assert.Equal(MxCommandKind.AcknowledgeAlarm, command.Command.Kind); - Assert.Equal(alarmGuid.ToString(), command.Command.AcknowledgeAlarmCommand.AlarmGuid); - Assert.Equal("ack", command.Command.AcknowledgeAlarmCommand.Comment); - Assert.Equal("alice", command.Command.AcknowledgeAlarmCommand.OperatorUser); - return new MxCommandReply - { - Kind = MxCommandKind.AcknowledgeAlarm, - ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok, Message = "OK" }, - Hresult = 0, - AcknowledgeAlarm = new AcknowledgeAlarmReplyPayload { NativeStatus = 0 }, - }; - }, - }; - GatewaySession session = NewSession("s1"); - session.AttachWorkerClient(worker); - session.MarkReady(); - registry.TryAdd(session); - - WorkerAlarmRpcDispatcher dispatcher = new WorkerAlarmRpcDispatcher(registry); - - AcknowledgeAlarmReply reply = await dispatcher.AcknowledgeAsync( - new AcknowledgeAlarmRequest - { - SessionId = "s1", - ClientCorrelationId = "c1", - AlarmFullReference = alarmGuid.ToString(), - Comment = "ack", - OperatorUser = "alice", - }, - CancellationToken.None); - - Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); - Assert.Equal(0, reply.Hresult); - Assert.Equal("s1", reply.SessionId); - Assert.Equal("c1", reply.CorrelationId); - Assert.Equal(1, worker.InvokeCount); - } - - [Fact] - public async Task AcknowledgeAsync_propagates_worker_diagnostic_on_failure() - { - SessionRegistry registry = new SessionRegistry(); - FakeAlarmWorkerClient worker = new FakeAlarmWorkerClient - { - ReplyFactory = _ => new MxCommandReply - { - Kind = MxCommandKind.AcknowledgeAlarm, - ProtocolStatus = new ProtocolStatus - { - Code = ProtocolStatusCode.MxaccessFailure, - Message = "AVEVA Acknowledge failed.", - }, - Hresult = -123, - DiagnosticMessage = "AVEVA AlarmAckByGUID returned non-zero status -123.", - }, - }; - GatewaySession session = NewSession("s1"); - session.AttachWorkerClient(worker); - session.MarkReady(); - registry.TryAdd(session); - - WorkerAlarmRpcDispatcher dispatcher = new WorkerAlarmRpcDispatcher(registry); - - AcknowledgeAlarmReply reply = await dispatcher.AcknowledgeAsync( - new AcknowledgeAlarmRequest - { - SessionId = "s1", - AlarmFullReference = Guid.NewGuid().ToString(), - }, - CancellationToken.None); - - Assert.Equal(ProtocolStatusCode.MxaccessFailure, reply.ProtocolStatus.Code); - Assert.Equal(-123, reply.Hresult); - Assert.Contains("-123", reply.DiagnosticMessage); - } - - [Theory] - [InlineData("Galaxy!TestArea.TestMachine_001.TestAlarm001", "Galaxy", "TestArea", "TestMachine_001.TestAlarm001")] - [InlineData("Galaxy!Area.Tag", "Galaxy", "Area", "Tag")] - [InlineData("Provider!Group.Tag.With.Dots", "Provider", "Group", "Tag.With.Dots")] - public void TryParseAlarmReference_decomposes_provider_group_tag( - string reference, string expectedProvider, string expectedGroup, string expectedName) - { - Assert.True(WorkerAlarmRpcDispatcher.TryParseAlarmReference( - reference, out string provider, out string group, out string name)); - Assert.Equal(expectedProvider, provider); - Assert.Equal(expectedGroup, group); - Assert.Equal(expectedName, name); - } - - [Theory] - [InlineData("")] - [InlineData(" ")] - [InlineData(null)] - [InlineData("no-bang-here")] - [InlineData("!Group.Tag")] // empty provider - [InlineData("Galaxy!")] // bang at end - [InlineData("Galaxy!Group")] // missing dot - [InlineData("Galaxy!.Tag")] // empty group - [InlineData("Galaxy!Group.")] // empty tag - public void TryParseAlarmReference_rejects_malformed_references(string? reference) - { - Assert.False(WorkerAlarmRpcDispatcher.TryParseAlarmReference( - reference, out _, out _, out _)); - } - - [Fact] - public async Task AcknowledgeAsync_routes_provider_group_tag_via_AckByName() - { - SessionRegistry registry = new SessionRegistry(); - AcknowledgeAlarmByNameCommand? observed = null; - FakeAlarmWorkerClient worker = new FakeAlarmWorkerClient - { - ReplyFactory = command => - { - Assert.Equal(MxCommandKind.AcknowledgeAlarmByName, command.Command.Kind); - observed = command.Command.AcknowledgeAlarmByNameCommand; - return new MxCommandReply - { - Kind = MxCommandKind.AcknowledgeAlarmByName, - ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok, Message = "OK" }, - Hresult = 0, - AcknowledgeAlarm = new AcknowledgeAlarmReplyPayload { NativeStatus = 0 }, - }; - }, - }; - GatewaySession session = NewSession("s1"); - session.AttachWorkerClient(worker); - session.MarkReady(); - registry.TryAdd(session); - - WorkerAlarmRpcDispatcher dispatcher = new WorkerAlarmRpcDispatcher(registry); - - AcknowledgeAlarmReply reply = await dispatcher.AcknowledgeAsync( - new AcknowledgeAlarmRequest - { - SessionId = "s1", - ClientCorrelationId = "c1", - AlarmFullReference = "Galaxy!TestArea.TestMachine_001.TestAlarm001", - Comment = "ack-by-name", - OperatorUser = "bob", - }, - CancellationToken.None); - - Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); - Assert.NotNull(observed); - Assert.Equal("TestMachine_001.TestAlarm001", observed!.AlarmName); - Assert.Equal("Galaxy", observed.ProviderName); - Assert.Equal("TestArea", observed.GroupName); - Assert.Equal("bob", observed.OperatorUser); - Assert.Equal("ack-by-name", observed.Comment); - } - - [Fact] - public async Task AcknowledgeAsync_returns_invalid_request_for_unparseable_reference() - { - SessionRegistry registry = new SessionRegistry(); - FakeAlarmWorkerClient worker = new FakeAlarmWorkerClient(); - GatewaySession session = NewSession("s1"); - session.AttachWorkerClient(worker); - session.MarkReady(); - registry.TryAdd(session); - - WorkerAlarmRpcDispatcher dispatcher = new WorkerAlarmRpcDispatcher(registry); - - AcknowledgeAlarmReply reply = await dispatcher.AcknowledgeAsync( - new AcknowledgeAlarmRequest - { - SessionId = "s1", - AlarmFullReference = "no-bang-no-dot", - }, - CancellationToken.None); - - Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code); - Assert.Equal(0, worker.InvokeCount); - } - - [Fact] - public async Task QueryActiveAlarmsAsync_yields_each_snapshot_from_payload() - { - SessionRegistry registry = new SessionRegistry(); - FakeAlarmWorkerClient worker = new FakeAlarmWorkerClient - { - ReplyFactory = command => - { - Assert.Equal(MxCommandKind.QueryActiveAlarms, command.Command.Kind); - QueryActiveAlarmsReplyPayload payload = new QueryActiveAlarmsReplyPayload(); - payload.Snapshots.Add(new ActiveAlarmSnapshot - { - AlarmFullReference = "Galaxy!A.T1", - CurrentState = AlarmConditionState.Active, - Severity = 500, - }); - payload.Snapshots.Add(new ActiveAlarmSnapshot - { - AlarmFullReference = "Galaxy!A.T2", - CurrentState = AlarmConditionState.ActiveAcked, - Severity = 100, - }); - return new MxCommandReply - { - Kind = MxCommandKind.QueryActiveAlarms, - ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok, Message = "OK" }, - QueryActiveAlarms = payload, - }; - }, - }; - GatewaySession session = NewSession("s1"); - session.AttachWorkerClient(worker); - session.MarkReady(); - registry.TryAdd(session); - - WorkerAlarmRpcDispatcher dispatcher = new WorkerAlarmRpcDispatcher(registry); - - List collected = new List(); - await foreach (ActiveAlarmSnapshot snap in dispatcher.QueryActiveAlarmsAsync( - new QueryActiveAlarmsRequest { SessionId = "s1" }, - CancellationToken.None)) - { - collected.Add(snap); - } - - Assert.Equal(2, collected.Count); - Assert.Equal("Galaxy!A.T1", collected[0].AlarmFullReference); - Assert.Equal("Galaxy!A.T2", collected[1].AlarmFullReference); - } - - [Fact] - public async Task QueryActiveAlarmsAsync_yields_empty_when_session_missing() - { - SessionRegistry registry = new SessionRegistry(); - WorkerAlarmRpcDispatcher dispatcher = new WorkerAlarmRpcDispatcher(registry); - - List collected = new List(); - await foreach (ActiveAlarmSnapshot snap in dispatcher.QueryActiveAlarmsAsync( - new QueryActiveAlarmsRequest { SessionId = "missing" }, - CancellationToken.None)) - { - collected.Add(snap); - } - - Assert.Empty(collected); - } - - [Fact] - public async Task QueryActiveAlarmsAsync_yields_empty_on_worker_failure() - { - SessionRegistry registry = new SessionRegistry(); - FakeAlarmWorkerClient worker = new FakeAlarmWorkerClient - { - ReplyFactory = _ => new MxCommandReply - { - Kind = MxCommandKind.QueryActiveAlarms, - ProtocolStatus = new ProtocolStatus - { - Code = ProtocolStatusCode.MxaccessFailure, - Message = "alarm consumer not subscribed", - }, - }, - }; - GatewaySession session = NewSession("s1"); - session.AttachWorkerClient(worker); - session.MarkReady(); - registry.TryAdd(session); - - WorkerAlarmRpcDispatcher dispatcher = new WorkerAlarmRpcDispatcher(registry); - - List collected = new List(); - await foreach (ActiveAlarmSnapshot snap in dispatcher.QueryActiveAlarmsAsync( - new QueryActiveAlarmsRequest { SessionId = "s1" }, - CancellationToken.None)) - { - collected.Add(snap); - } - - Assert.Empty(collected); - } - - private static GatewaySession NewSession(string sessionId) - { - return new GatewaySession( - sessionId, - "mxaccess", - $"mxaccess-gateway-1-{sessionId}", - "nonce", - "client-1", - "test-session", - "client-correlation-1", - commandTimeout: TimeSpan.FromSeconds(30), - startupTimeout: TimeSpan.FromSeconds(5), - shutdownTimeout: TimeSpan.FromSeconds(5), - leaseDuration: TimeSpan.FromMinutes(30), - openedAt: DateTimeOffset.UtcNow); - } - - private sealed class FakeAlarmWorkerClient : IWorkerClient - { - public string SessionId { get; } = "session-1"; - public int? ProcessId { get; } = 1; - public WorkerClientState State { get; } = WorkerClientState.Ready; - public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow; - - public Func? ReplyFactory { get; set; } - public int InvokeCount { get; private set; } - - public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; - - public Task InvokeAsync( - WorkerCommand command, TimeSpan timeout, CancellationToken cancellationToken) - { - InvokeCount++; - MxCommandReply reply = ReplyFactory?.Invoke(command) ?? new MxCommandReply(); - return Task.FromResult(new WorkerCommandReply { Reply = reply }); - } - - public async IAsyncEnumerable ReadEventsAsync( - [EnumeratorCancellation] CancellationToken cancellationToken) - { - await Task.CompletedTask; - yield break; - } - - public Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken) => Task.CompletedTask; - public void Kill(string reason) { } - public ValueTask DisposeAsync() => ValueTask.CompletedTask; - } -} diff --git a/src/MxGateway.Tests/Security/Authorization/GatewayGrpcAuthorizationInterceptorTests.cs b/src/MxGateway.Tests/Security/Authorization/GatewayGrpcAuthorizationInterceptorTests.cs deleted file mode 100644 index 718593c..0000000 --- a/src/MxGateway.Tests/Security/Authorization/GatewayGrpcAuthorizationInterceptorTests.cs +++ /dev/null @@ -1,298 +0,0 @@ -using Grpc.Core; -using Microsoft.Extensions.Options; -using MxGateway.Contracts.Proto; -using MxGateway.Server.Configuration; -using MxGateway.Server.Security.Authentication; -using MxGateway.Server.Security.Authorization; - -namespace MxGateway.Tests.Security.Authorization; - -public sealed class GatewayGrpcAuthorizationInterceptorTests -{ - /// Verifies that missing API key returns unauthenticated status. - [Fact] - public async Task UnaryServerHandler_MissingApiKey_ReturnsUnauthenticated() - { - GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor( - new FakeApiKeyVerifier(ApiKeyVerificationResult.Fail( - ApiKeyVerificationFailure.MissingOrMalformedCredentials)), - new GatewayRequestIdentityAccessor()); - - RpcException exception = await Assert.ThrowsAsync( - () => interceptor.UnaryServerHandler( - new OpenSessionRequest(), - new TestServerCallContext([]), - (_, _) => Task.FromResult(new OpenSessionReply()))); - - Assert.Equal(StatusCode.Unauthenticated, exception.StatusCode); - Assert.DoesNotContain("secret", exception.Status.Detail, StringComparison.OrdinalIgnoreCase); - } - - /// Verifies that invalid API key error does not expose raw credentials. - [Fact] - public async Task UnaryServerHandler_InvalidApiKey_DoesNotExposeRawCredentialInStatus() - { - GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor( - new FakeApiKeyVerifier(ApiKeyVerificationResult.Fail(ApiKeyVerificationFailure.SecretMismatch)), - new GatewayRequestIdentityAccessor()); - - RpcException exception = await Assert.ThrowsAsync( - () => interceptor.UnaryServerHandler( - new OpenSessionRequest(), - ContextWithAuthorization("Bearer mxgw_operator01_super-secret"), - (_, _) => Task.FromResult(new OpenSessionReply()))); - - Assert.Equal(StatusCode.Unauthenticated, exception.StatusCode); - Assert.DoesNotContain("super-secret", exception.Status.Detail, StringComparison.Ordinal); - } - - /// Verifies that valid key without required scope returns permission denied. - [Fact] - public async Task UnaryServerHandler_ValidApiKeyMissingScope_ReturnsPermissionDenied() - { - GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor( - new FakeApiKeyVerifier(SuccessWithScopes(GatewayScopes.EventsRead)), - new GatewayRequestIdentityAccessor()); - - RpcException exception = await Assert.ThrowsAsync( - () => interceptor.UnaryServerHandler( - new OpenSessionRequest(), - ContextWithAuthorization("Bearer mxgw_operator01_secret"), - (_, _) => Task.FromResult(new OpenSessionReply()))); - - Assert.Equal(StatusCode.PermissionDenied, exception.StatusCode); - Assert.Contains(GatewayScopes.SessionOpen, exception.Status.Detail, StringComparison.Ordinal); - } - - /// Verifies that valid key with scope sets request identity for the handler. - [Fact] - public async Task UnaryServerHandler_ValidApiKeyWithScope_SetsRequestIdentity() - { - GatewayRequestIdentityAccessor identityAccessor = new(); - ApiKeyIdentity? identitySeenByHandler = null; - GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor( - new FakeApiKeyVerifier(SuccessWithScopes(GatewayScopes.SessionOpen)), - identityAccessor); - - OpenSessionReply reply = await interceptor.UnaryServerHandler( - new OpenSessionRequest(), - ContextWithAuthorization("Bearer mxgw_operator01_secret"), - (_, _) => - { - identitySeenByHandler = identityAccessor.Current; - - return Task.FromResult(new OpenSessionReply { SessionId = "session-1" }); - }); - - Assert.Equal("session-1", reply.SessionId); - Assert.NotNull(identitySeenByHandler); - Assert.Equal("operator01", identitySeenByHandler.KeyId); - Assert.Null(identityAccessor.Current); - } - - /// Verifies that server stream handler requires proper scope. - [Fact] - public async Task ServerStreamingServerHandler_ValidApiKeyMissingScope_ReturnsPermissionDenied() - { - GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor( - new FakeApiKeyVerifier(SuccessWithScopes(GatewayScopes.SessionOpen)), - new GatewayRequestIdentityAccessor()); - - RpcException exception = await Assert.ThrowsAsync( - () => interceptor.ServerStreamingServerHandler( - new StreamEventsRequest(), - new TestServerStreamWriter(), - ContextWithAuthorization("Bearer mxgw_operator01_secret"), - (_, _, _) => Task.CompletedTask)); - - Assert.Equal(StatusCode.PermissionDenied, exception.StatusCode); - Assert.Contains(GatewayScopes.EventsRead, exception.Status.Detail, StringComparison.Ordinal); - } - - /// Verifies that server stream handler allows streams with proper scope. - [Fact] - public async Task ServerStreamingServerHandler_ValidApiKeyWithScope_AllowsStream() - { - GatewayRequestIdentityAccessor identityAccessor = new(); - GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor( - new FakeApiKeyVerifier(SuccessWithScopes(GatewayScopes.EventsRead)), - identityAccessor); - TestServerStreamWriter streamWriter = new(); - - await interceptor.ServerStreamingServerHandler( - new StreamEventsRequest(), - streamWriter, - ContextWithAuthorization("Bearer mxgw_operator01_secret"), - async (_, writer, _) => - { - Assert.Equal("operator01", identityAccessor.Current?.KeyId); - await writer.WriteAsync(new MxEvent { SessionId = "session-1" }); - }); - - MxEvent eventMessage = Assert.Single(streamWriter.Messages); - Assert.Equal("session-1", eventMessage.SessionId); - Assert.Null(identityAccessor.Current); - } - - /// Verifies that disabled authentication skips API key verification. - [Fact] - public async Task UnaryServerHandler_AuthenticationDisabled_SkipsApiKeyVerification() - { - GatewayRequestIdentityAccessor identityAccessor = new(); - FakeApiKeyVerifier verifier = new(ApiKeyVerificationResult.Fail( - ApiKeyVerificationFailure.MissingOrMalformedCredentials)); - GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor( - verifier, - identityAccessor, - AuthenticationMode.Disabled); - - OpenSessionReply reply = await interceptor.UnaryServerHandler( - new OpenSessionRequest(), - new TestServerCallContext([]), - (_, _) => Task.FromResult(new OpenSessionReply { SessionId = "session-1" })); - - Assert.Equal("session-1", reply.SessionId); - Assert.False(verifier.WasCalled); - Assert.Null(identityAccessor.Current); - } - - private static GatewayGrpcAuthorizationInterceptor CreateInterceptor( - IApiKeyVerifier apiKeyVerifier, - IGatewayRequestIdentityAccessor identityAccessor, - AuthenticationMode authenticationMode = AuthenticationMode.ApiKey) - { - return new GatewayGrpcAuthorizationInterceptor( - apiKeyVerifier, - new GatewayGrpcScopeResolver(), - identityAccessor, - Options.Create(new GatewayOptions - { - Authentication = new AuthenticationOptions - { - Mode = authenticationMode - } - })); - } - - private static ApiKeyVerificationResult SuccessWithScopes(params string[] scopes) - { - return ApiKeyVerificationResult.Success(new ApiKeyIdentity( - KeyId: "operator01", - KeyPrefix: "mxgw_operator01", - DisplayName: "Operator Key", - Scopes: new HashSet(scopes, StringComparer.Ordinal))); - } - - private static TestServerCallContext ContextWithAuthorization(string authorizationHeader) - { - return new TestServerCallContext([new Metadata.Entry("authorization", authorizationHeader)]); - } - - private sealed class FakeApiKeyVerifier(ApiKeyVerificationResult result) : IApiKeyVerifier - { - /// Gets whether the verifier was called. - public bool WasCalled { get; private set; } - - /// Gets the last authorization header seen by the verifier. - public string? LastAuthorizationHeader { get; private set; } - - /// Verifies the authorization header against stored result. - /// The authorization header to verify. - /// Cancellation token. - /// Configured verification result. - public Task VerifyAsync( - string? authorizationHeader, - CancellationToken cancellationToken) - { - WasCalled = true; - LastAuthorizationHeader = authorizationHeader; - - return Task.FromResult(result); - } - } - - private sealed class TestServerStreamWriter : IServerStreamWriter - { - /// Gets messages written to the stream. - public List Messages { get; } = []; - - /// Gets or sets write options for the stream. - public WriteOptions? WriteOptions { get; set; } - - /// Writes a message to the stream. - /// The message to write. - /// Task representing the write operation. - public Task WriteAsync(T message) - { - Messages.Add(message); - - return Task.CompletedTask; - } - } - - private sealed class TestServerCallContext( - Metadata requestHeaders, - CancellationToken cancellationToken = default) : ServerCallContext - { - private readonly Metadata responseTrailers = []; - private readonly Dictionary userState = []; - private Status status; - private WriteOptions? writeOptions; - - /// - protected override string MethodCore => "/mxaccess_gateway.v1.MxAccessGateway/Test"; - - /// - protected override string HostCore => "localhost"; - - /// - protected override string PeerCore => "ipv4:127.0.0.1:5000"; - - /// - protected override DateTime DeadlineCore => DateTime.UtcNow.AddMinutes(1); - - /// - protected override Metadata RequestHeadersCore => requestHeaders; - - /// - protected override CancellationToken CancellationTokenCore => cancellationToken; - - /// - protected override Metadata ResponseTrailersCore => responseTrailers; - - /// - protected override Status StatusCore - { - get => status; - set => status = value; - } - - /// - protected override WriteOptions? WriteOptionsCore - { - get => writeOptions; - set => writeOptions = value; - } - - /// - protected override AuthContext AuthContextCore { get; } = new( - string.Empty, - new Dictionary>(StringComparer.Ordinal)); - - /// - protected override IDictionary UserStateCore => userState; - - /// - protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders) - { - return Task.CompletedTask; - } - - /// - protected override ContextPropagationToken CreatePropagationTokenCore( - ContextPropagationOptions? options) - { - throw new NotSupportedException(); - } - } -} diff --git a/src/MxGateway.Worker.Tests/Ipc/WorkerFrameProtocolTests.cs b/src/MxGateway.Worker.Tests/Ipc/WorkerFrameProtocolTests.cs deleted file mode 100644 index c128af9..0000000 --- a/src/MxGateway.Worker.Tests/Ipc/WorkerFrameProtocolTests.cs +++ /dev/null @@ -1,169 +0,0 @@ -using System; -using System.IO; -using System.Linq; -using System.Threading.Tasks; -using Google.Protobuf; -using MxGateway.Contracts; -using MxGateway.Contracts.Proto; -using MxGateway.Worker.Ipc; - -namespace MxGateway.Worker.Tests.Ipc; - -public sealed class WorkerFrameProtocolTests -{ - private const string SessionId = "session-1"; - private const string Nonce = "nonce-secret"; - - /// Verifies that valid envelopes round-trip through write and read. - [Fact] - public async Task WriteAndReadAsync_WithValidEnvelope_RoundTripsFrame() - { - WorkerFrameProtocolOptions options = CreateOptions(); - MemoryStream stream = new(); - WorkerEnvelope original = CreateGatewayHelloEnvelope(); - - WorkerFrameWriter writer = new(stream, options); - await writer.WriteAsync(original); - stream.Position = 0; - - WorkerFrameReader reader = new(stream, options); - WorkerEnvelope parsed = await reader.ReadAsync(); - - Assert.Equal(original, parsed); - } - - /// Verifies that wrong protocol version throws mismatch error. - [Fact] - public async Task ReadAsync_WithWrongProtocolVersion_ThrowsProtocolVersionMismatch() - { - WorkerFrameProtocolOptions options = CreateOptions(); - WorkerEnvelope envelope = CreateGatewayHelloEnvelope(); - envelope.ProtocolVersion++; - MemoryStream stream = new(CreateFrame(envelope)); - - WorkerFrameReader reader = new(stream, options); - WorkerFrameProtocolException exception = - await Assert.ThrowsAsync( - async () => await reader.ReadAsync()); - - Assert.Equal(WorkerFrameProtocolErrorCode.ProtocolVersionMismatch, exception.ErrorCode); - } - - /// Verifies that wrong session ID throws mismatch error. - [Fact] - public async Task ReadAsync_WithWrongSessionId_ThrowsSessionMismatch() - { - WorkerFrameProtocolOptions options = CreateOptions(); - WorkerEnvelope envelope = CreateGatewayHelloEnvelope(); - envelope.SessionId = "different-session"; - MemoryStream stream = new(CreateFrame(envelope)); - - WorkerFrameReader reader = new(stream, options); - WorkerFrameProtocolException exception = - await Assert.ThrowsAsync( - async () => await reader.ReadAsync()); - - Assert.Equal(WorkerFrameProtocolErrorCode.SessionMismatch, exception.ErrorCode); - } - - /// Verifies that malformed length throws error. - [Fact] - public async Task ReadAsync_WithMalformedLength_ThrowsMalformedLength() - { - WorkerFrameProtocolOptions options = CreateOptions(); - MemoryStream stream = new(new byte[sizeof(uint)]); - - WorkerFrameReader reader = new(stream, options); - WorkerFrameProtocolException exception = - await Assert.ThrowsAsync( - async () => await reader.ReadAsync()); - - Assert.Equal(WorkerFrameProtocolErrorCode.MalformedLength, exception.ErrorCode); - } - - /// Verifies that malformed payload throws invalid envelope error. - [Fact] - public async Task ReadAsync_WithMalformedPayload_ThrowsInvalidEnvelope() - { - WorkerFrameProtocolOptions options = CreateOptions(); - MemoryStream stream = new(CreateFrame(new byte[] { 0x80 })); - - WorkerFrameReader reader = new(stream, options); - WorkerFrameProtocolException exception = - await Assert.ThrowsAsync( - async () => await reader.ReadAsync()); - - Assert.Equal(WorkerFrameProtocolErrorCode.InvalidEnvelope, exception.ErrorCode); - } - - /// Verifies that concurrent writes produce complete serialized frames. - [Fact] - public async Task WriteAsync_WithConcurrentCalls_SerializesCompleteFrames() - { - WorkerFrameProtocolOptions options = CreateOptions(); - MemoryStream stream = new(); - WorkerFrameWriter writer = new(stream, options); - - await Task.WhenAll( - writer.WriteAsync(CreateGatewayHelloEnvelope(sequence: 1)), - writer.WriteAsync(CreateGatewayHelloEnvelope(sequence: 2)), - writer.WriteAsync(CreateGatewayHelloEnvelope(sequence: 3))); - - stream.Position = 0; - WorkerFrameReader reader = new(stream, options); - - WorkerEnvelope first = await reader.ReadAsync(); - WorkerEnvelope second = await reader.ReadAsync(); - WorkerEnvelope third = await reader.ReadAsync(); - - Assert.Equal(new ulong[] { 1, 2, 3 }, new[] { first.Sequence, second.Sequence, third.Sequence }.OrderBy(sequence => sequence)); - } - - private static WorkerFrameProtocolOptions CreateOptions() - { - return new WorkerFrameProtocolOptions( - SessionId, - GatewayContractInfo.WorkerProtocolVersion, - Nonce); - } - - private static WorkerEnvelope CreateGatewayHelloEnvelope(ulong sequence = 1) - { - return new WorkerEnvelope - { - ProtocolVersion = GatewayContractInfo.WorkerProtocolVersion, - SessionId = SessionId, - Sequence = sequence, - GatewayHello = new GatewayHello - { - SupportedProtocolVersion = GatewayContractInfo.WorkerProtocolVersion, - Nonce = Nonce, - GatewayVersion = "test-gateway", - }, - }; - } - - private static byte[] CreateFrame(IMessage message) - { - return CreateFrame(message.ToByteArray()); - } - - private static byte[] CreateFrame(byte[] payload) - { - byte[] frame = new byte[sizeof(uint) + payload.Length]; - WriteUInt32LittleEndian(frame, (uint)payload.Length); - payload.CopyTo(frame, sizeof(uint)); - - return frame; - } - - private static void WriteUInt32LittleEndian( - byte[] buffer, - uint value) - { - buffer[0] = (byte)value; - buffer[1] = (byte)(value >> 8); - buffer[2] = (byte)(value >> 16); - buffer[3] = (byte)(value >> 24); - } -} diff --git a/src/MxGateway.Worker.Tests/MxAccess/MxAccessStaSessionTests.cs b/src/MxGateway.Worker.Tests/MxAccess/MxAccessStaSessionTests.cs deleted file mode 100644 index 1dedfb8..0000000 --- a/src/MxGateway.Worker.Tests/MxAccess/MxAccessStaSessionTests.cs +++ /dev/null @@ -1,202 +0,0 @@ -using System; -using System.Runtime.InteropServices; -using System.Threading; -using System.Threading.Tasks; -using MxGateway.Contracts.Proto; -using MxGateway.Worker.MxAccess; -using MxGateway.Worker.Sta; - -namespace MxGateway.Worker.Tests.MxAccess; - -/// -/// Tests for . -/// -public sealed class MxAccessStaSessionTests -{ - /// - /// Verifies that StartAsync creates the MXAccess COM object and attaches the event sink on the STA thread. - /// - [Fact] - public async Task StartAsync_CreatesComObjectAndAttachesEventSinkOnStaThread() - { - FakeMxAccessComObjectFactory factory = new(); - FakeMxAccessEventSink eventSink = new(); - using StaRuntime runtime = CreateRuntime(); - using MxAccessStaSession session = new(runtime, factory, eventSink); - - WorkerReady ready = await session.StartAsync("session-1", workerProcessId: 1234); - - Assert.Equal(1234, ready.WorkerProcessId); - Assert.Equal(MxAccessInteropInfo.ProgId, ready.MxaccessProgid); - Assert.Equal(MxAccessInteropInfo.Clsid, ready.MxaccessClsid); - Assert.NotNull(ready.ReadyTimestamp); - Assert.Equal(runtime.StaThreadId, factory.CreateThreadId); - Assert.Equal(runtime.StaThreadId, eventSink.AttachThreadId); - Assert.Equal(ApartmentState.STA, factory.CreateApartmentState); - Assert.Same(factory.CreatedObject, eventSink.AttachedObject); - Assert.Equal("session-1", eventSink.SessionId); - } - - /// - /// Verifies that StartAsync maps creation exceptions with HResult when the factory fails. - /// - [Fact] - public async Task StartAsync_WhenFactoryFails_MapsCreationExceptionWithHResult() - { - const int hresult = unchecked((int)0x80040154); - FakeMxAccessComObjectFactory factory = new(new COMException("Class not registered.", hresult)); - FakeMxAccessEventSink eventSink = new(); - using StaRuntime runtime = CreateRuntime(); - using MxAccessStaSession session = new(runtime, factory, eventSink); - - MxAccessCreationException exception = await Assert.ThrowsAsync( - () => session.StartAsync(workerProcessId: 1234)); - - Assert.Equal(hresult, exception.CapturedHResult); - Assert.Equal(MxAccessInteropInfo.ProgId, exception.AttemptedProgId); - Assert.Equal(MxAccessInteropInfo.Clsid, exception.AttemptedClsid); - Assert.Null(eventSink.AttachedObject); - } - - /// - /// Verifies that Dispose detaches the event sink on the STA thread. - /// - [Fact] - public async Task Dispose_DetachesEventSinkOnStaThread() - { - FakeMxAccessComObjectFactory factory = new(); - FakeMxAccessEventSink eventSink = new(); - using StaRuntime runtime = CreateRuntime(); - MxAccessStaSession session = new(runtime, factory, eventSink); - await session.StartAsync(workerProcessId: 1234); - - session.Dispose(); - - Assert.Equal(runtime.StaThreadId, eventSink.DetachThreadId); - } - - private static StaRuntime CreateRuntime() - { - return new StaRuntime( - new NoopComApartmentInitializer(), - new StaMessagePump(), - TimeSpan.FromMilliseconds(25)); - } - - /// - /// Fake MXAccess COM object factory for testing. - /// - private sealed class FakeMxAccessComObjectFactory : IMxAccessComObjectFactory - { - private readonly Exception? exception; - - /// - /// Initializes a fake factory that optionally throws an exception. - /// - /// Exception to throw when Create is called; null to succeed. - public FakeMxAccessComObjectFactory(Exception? exception = null) - { - this.exception = exception; - } - - /// - /// Gets the COM object created by this factory. - /// - public object CreatedObject { get; } = new(); - - /// - /// Gets the managed thread ID when Create was called. - /// - public int? CreateThreadId { get; private set; } - - /// - /// Gets the apartment state when Create was called. - /// - public ApartmentState? CreateApartmentState { get; private set; } - - /// - /// Creates the COM object or throws the configured exception. - /// - public object Create() - { - CreateThreadId = Thread.CurrentThread.ManagedThreadId; - CreateApartmentState = Thread.CurrentThread.GetApartmentState(); - - if (exception is not null) - { - throw exception; - } - - return CreatedObject; - } - } - - /// - /// Fake MXAccess event sink for testing. - /// - private sealed class FakeMxAccessEventSink : IMxAccessEventSink - { - /// - /// Gets the attached MXAccess COM object. - /// - public object? AttachedObject { get; private set; } - - /// - /// Gets the managed thread ID when Attach was called. - /// - public int? AttachThreadId { get; private set; } - - /// - /// Gets the managed thread ID when Detach was called. - /// - public int? DetachThreadId { get; private set; } - - /// - /// Gets the session identifier. - /// - public string? SessionId { get; private set; } - - /// - /// Attaches the MXAccess COM object and records thread context. - /// - /// MXAccess COM object to attach. - /// Identifier of the session. - public void Attach( - object mxAccessComObject, - string sessionId) - { - AttachedObject = mxAccessComObject; - AttachThreadId = Thread.CurrentThread.ManagedThreadId; - SessionId = sessionId; - } - - /// - /// Detaches the MXAccess COM object and records thread context. - /// - public void Detach() - { - DetachThreadId = Thread.CurrentThread.ManagedThreadId; - AttachedObject = null; - } - } - - /// - /// Noop STA COM apartment initializer for testing. - /// - private sealed class NoopComApartmentInitializer : IStaComApartmentInitializer - { - /// - /// Initializes the COM apartment (no-op). - /// - public void Initialize() - { - } - - /// - /// Uninitializes the COM apartment (no-op). - /// - public void Uninitialize() - { - } - } -} diff --git a/src/MxGateway.Worker.Tests/MxAccess/WnWrapAlarmConsumerXmlTests.cs b/src/MxGateway.Worker.Tests/MxAccess/WnWrapAlarmConsumerXmlTests.cs deleted file mode 100644 index 3a3d90d..0000000 --- a/src/MxGateway.Worker.Tests/MxAccess/WnWrapAlarmConsumerXmlTests.cs +++ /dev/null @@ -1,112 +0,0 @@ -using System; -using System.Linq; -using MxGateway.Worker.MxAccess; - -namespace MxGateway.Worker.Tests.MxAccess; - -/// -/// Unit-test coverage for 's pure -/// parsing helpers — XML payload → -/// dictionary, and the 32-char-hex GUID round-trip. The COM-side -/// polling loop is verified separately by the Skip-gated -/// WnWrapConsumerProbeTests on a live AVEVA install. -/// -public sealed class WnWrapAlarmConsumerXmlTests -{ - /// Captured XML from the dev rig (probe run 2026-05-01). - private const string SingleAlarmActiveXml = - "" + - "BCC4705395424D65BDAABCDEA6A32A73" + - "2026/5/1" + - "2400" + - "DESKTOP-6JL3KKO" + - "Galaxy" + - "TestArea" + - "TestMachine_001.TestAlarm001" + - "DSCtruetrue" + - "500UNACK_ALM" + - "" + - "Test alarm #1" + - ""; - - private const string EmptyXml = - ""; - - [Fact] - public void ParseSnapshotXml_returns_empty_dictionary_for_empty_payload() - { - var records = WnWrapAlarmConsumer.ParseSnapshotXml(EmptyXml); - Assert.Empty(records); - } - - [Fact] - public void ParseSnapshotXml_returns_empty_dictionary_for_null_or_whitespace() - { - Assert.Empty(WnWrapAlarmConsumer.ParseSnapshotXml("")); - Assert.Empty(WnWrapAlarmConsumer.ParseSnapshotXml(" ")); - } - - [Fact] - public void ParseSnapshotXml_decodes_single_active_alarm_record() - { - var records = WnWrapAlarmConsumer.ParseSnapshotXml(SingleAlarmActiveXml); - - Assert.Single(records); - Guid expectedGuid = new Guid("BCC47053-9542-4D65-BDAA-BCDEA6A32A73"); - var record = records[expectedGuid]; - Assert.Equal(expectedGuid, record.AlarmGuid); - Assert.Equal("DESKTOP-6JL3KKO", record.ProviderNode); - Assert.Equal("Galaxy", record.ProviderName); - Assert.Equal("TestArea", record.Group); - Assert.Equal("TestMachine_001.TestAlarm001", record.TagName); - Assert.Equal("DSC", record.Type); - Assert.Equal("true", record.Value); - Assert.Equal("true", record.Limit); - Assert.Equal(500, record.Priority); - Assert.Equal(MxAlarmStateKind.UnackAlm, record.State); - Assert.Equal("Test alarm #1", record.AlarmComment); - Assert.Equal(DateTimeKind.Utc, record.TransitionTimestampUtc.Kind); - // 13:26:14.709 EDT (UTC-4, DSTADJUST=0) + 240 minutes = 17:26:14.709 UTC. - Assert.Equal(17, record.TransitionTimestampUtc.Hour); - Assert.Equal(26, record.TransitionTimestampUtc.Minute); - } - - [Fact] - public void ParseSnapshotXml_silently_drops_records_with_invalid_guids() - { - string xml = SingleAlarmActiveXml.Replace( - "BCC4705395424D65BDAABCDEA6A32A73", - "not-a-guid"); - Assert.Empty(WnWrapAlarmConsumer.ParseSnapshotXml(xml)); - } - - [Theory] - [InlineData("BCC4705395424D65BDAABCDEA6A32A73", "BCC47053-9542-4D65-BDAA-BCDEA6A32A73")] - [InlineData("00000000000000000000000000000000", "00000000-0000-0000-0000-000000000000")] - public void TryParseHexGuid_handles_dashless_32_char_hex(string hex, string expected) - { - Assert.True(WnWrapAlarmConsumer.TryParseHexGuid(hex, out Guid guid)); - Assert.Equal(new Guid(expected), guid); - } - - [Theory] - [InlineData("BCC47053-9542-4D65-BDAA-BCDEA6A32A73")] - public void TryParseHexGuid_accepts_canonical_dashed_form(string canonical) - { - Assert.True(WnWrapAlarmConsumer.TryParseHexGuid(canonical, out Guid guid)); - Assert.Equal(new Guid(canonical), guid); - } - - [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData(" ")] - [InlineData("nope")] - [InlineData("0123456789ABCDEF")] // too short - [InlineData("BCC4705395424D65BDAABCDEA6A32A73XX")] // too long - public void TryParseHexGuid_rejects_invalid_input(string? hex) - { - Assert.False(WnWrapAlarmConsumer.TryParseHexGuid(hex, out Guid guid)); - Assert.Equal(Guid.Empty, guid); - } -} diff --git a/src/MxGateway.Worker/Ipc/WorkerPipeSessionOptions.cs b/src/MxGateway.Worker/Ipc/WorkerPipeSessionOptions.cs deleted file mode 100644 index 4b82ff0..0000000 --- a/src/MxGateway.Worker/Ipc/WorkerPipeSessionOptions.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System; - -namespace MxGateway.Worker.Ipc; - -/// Configuration options for worker pipe sessions including heartbeat parameters. -public sealed class WorkerPipeSessionOptions -{ - /// Default heartbeat interval (5 seconds). - public static readonly TimeSpan DefaultHeartbeatInterval = TimeSpan.FromSeconds(5); - /// Default heartbeat grace period (15 seconds). - public static readonly TimeSpan DefaultHeartbeatGrace = TimeSpan.FromSeconds(15); - - /// Initializes a new instance of the WorkerPipeSessionOptions class with default values. - public WorkerPipeSessionOptions() - { - HeartbeatInterval = DefaultHeartbeatInterval; - HeartbeatGrace = DefaultHeartbeatGrace; - } - - /// Gets or sets the heartbeat interval. - public TimeSpan HeartbeatInterval { get; set; } - - /// Gets or sets the heartbeat grace period. - public TimeSpan HeartbeatGrace { get; set; } - - /// Validates the session options. - public void Validate() - { - if (HeartbeatInterval <= TimeSpan.Zero) - { - throw new ArgumentOutOfRangeException( - nameof(HeartbeatInterval), - "Worker heartbeat interval must be greater than zero."); - } - - if (HeartbeatGrace <= TimeSpan.Zero) - { - throw new ArgumentOutOfRangeException( - nameof(HeartbeatGrace), - "Worker heartbeat grace must be greater than zero."); - } - } -} diff --git a/src/MxGateway.Worker/MxAccess/IMxAccessServer.cs b/src/MxGateway.Worker/MxAccess/IMxAccessServer.cs deleted file mode 100644 index 2645e54..0000000 --- a/src/MxGateway.Worker/MxAccess/IMxAccessServer.cs +++ /dev/null @@ -1,59 +0,0 @@ -namespace MxGateway.Worker.MxAccess; - -public interface IMxAccessServer -{ - /// Registers a client and returns a server handle. - /// Name of the client requesting registration. - /// Server handle for subsequent operations. - int Register(string clientName); - - /// Unregisters a server handle. - /// Server handle to unregister. - void Unregister(int serverHandle); - - /// Adds an item to a server and returns an item handle. - /// Server handle identifying the registration. - /// Item definition string. - /// Item handle for the added item. - int AddItem( - int serverHandle, - string itemDefinition); - - /// Adds an item with context to a server and returns an item handle. - /// Server handle identifying the registration. - /// Item definition string. - /// Item context string. - /// Item handle for the added item. - int AddItem2( - int serverHandle, - string itemDefinition, - string itemContext); - - /// Removes an item from a server. - /// Server handle identifying the registration. - /// Item handle to remove. - void RemoveItem( - int serverHandle, - int itemHandle); - - /// Subscribes to change notifications for an item. - /// Server handle identifying the registration. - /// Item handle to subscribe to. - void Advise( - int serverHandle, - int itemHandle); - - /// Unsubscribes from change notifications for an item. - /// Server handle identifying the registration. - /// Item handle to unsubscribe from. - void UnAdvise( - int serverHandle, - int itemHandle); - - /// Subscribes to supervisory change notifications for an item. - /// Server handle identifying the registration. - /// Item handle to subscribe to. - void AdviseSupervisory( - int serverHandle, - int itemHandle); -} diff --git a/src/MxGateway.Worker/MxAccess/MxAccessBaseEventSink.cs b/src/MxGateway.Worker/MxAccess/MxAccessBaseEventSink.cs deleted file mode 100644 index 5c5ae69..0000000 --- a/src/MxGateway.Worker/MxAccess/MxAccessBaseEventSink.cs +++ /dev/null @@ -1,160 +0,0 @@ -using System; -using ArchestrA.MxAccess; -using Proto = MxGateway.Contracts.Proto; - -namespace MxGateway.Worker.MxAccess; - -/// Sink for MXAccess COM events that converts them to protobuf format. -public sealed class MxAccessBaseEventSink : IMxAccessEventSink -{ - private readonly MxAccessEventMapper eventMapper; - private readonly MxAccessEventQueue eventQueue; - private LMXProxyServerClass? server; - private string sessionId = string.Empty; - - /// Initializes a new instance of the MxAccessBaseEventSink class with a default queue. - public MxAccessBaseEventSink() - : this(new MxAccessEventQueue()) - { - } - - /// Initializes a new instance of the MxAccessBaseEventSink class with a provided queue. - /// Queue for buffering converted MXAccess events. - public MxAccessBaseEventSink(MxAccessEventQueue eventQueue) - : this(eventQueue, new MxAccessEventMapper()) - { - } - - /// Initializes a new instance of the MxAccessBaseEventSink class with provided queue and mapper. - /// Queue for buffering converted MXAccess events. - /// Converter for MXAccess events to protobuf format. - public MxAccessBaseEventSink( - MxAccessEventQueue eventQueue, - MxAccessEventMapper eventMapper) - { - this.eventQueue = eventQueue ?? throw new ArgumentNullException(nameof(eventQueue)); - this.eventMapper = eventMapper ?? throw new ArgumentNullException(nameof(eventMapper)); - } - - /// - public void Attach( - object mxAccessComObject, - string sessionId) - { - this.sessionId = sessionId ?? string.Empty; - server = (LMXProxyServerClass)mxAccessComObject; - server.OnDataChange += OnDataChange; - server.OnWriteComplete += OnWriteComplete; - server.OperationComplete += OperationComplete; - server.OnBufferedDataChange += OnBufferedDataChange; - } - - /// - public void Detach() - { - if (server is null) - { - return; - } - - server.OnDataChange -= OnDataChange; - server.OnWriteComplete -= OnWriteComplete; - server.OperationComplete -= OperationComplete; - server.OnBufferedDataChange -= OnBufferedDataChange; - server = null; - sessionId = string.Empty; - } - - private void OnDataChange( - int hLMXServerHandle, - int phItemHandle, - object pvItemValue, - int pwItemQuality, - object pftItemTimeStamp, - ref MXSTATUS_PROXY[] pVars) - { - MXSTATUS_PROXY[] statuses = pVars; - EnqueueEvent(() => eventMapper.CreateOnDataChange( - sessionId, - hLMXServerHandle, - phItemHandle, - pvItemValue, - pwItemQuality, - pftItemTimeStamp, - statuses)); - } - - private void OnWriteComplete( - int hLMXServerHandle, - int phItemHandle, - ref MXSTATUS_PROXY[] pVars) - { - MXSTATUS_PROXY[] statuses = pVars; - EnqueueEvent(() => eventMapper.CreateOnWriteComplete( - sessionId, - hLMXServerHandle, - phItemHandle, - statuses)); - } - - private void OperationComplete( - int hLMXServerHandle, - int phItemHandle, - ref MXSTATUS_PROXY[] pVars) - { - MXSTATUS_PROXY[] statuses = pVars; - EnqueueEvent(() => eventMapper.CreateOperationComplete( - sessionId, - hLMXServerHandle, - phItemHandle, - statuses)); - } - - private void OnBufferedDataChange( - int hLMXServerHandle, - int phItemHandle, - MxDataType dtDataType, - object pvItemValue, - object pwItemQuality, - object pftItemTimeStamp, - ref MXSTATUS_PROXY[] pVars) - { - MXSTATUS_PROXY[] statuses = pVars; - EnqueueEvent(() => eventMapper.CreateOnBufferedDataChange( - sessionId, - hLMXServerHandle, - phItemHandle, - (int)dtDataType, - pvItemValue, - pwItemQuality, - pftItemTimeStamp, - statuses)); - } - - private void EnqueueEvent(Func createEvent) - { - try - { - eventQueue.Enqueue(createEvent()); - } - catch (Exception exception) - { - eventQueue.RecordFault(CreateEventConversionFault(exception)); - } - } - - private Proto.WorkerFault CreateEventConversionFault(Exception exception) - { - return new Proto.WorkerFault - { - Category = Proto.WorkerFaultCategory.MxaccessEventConversionFailed, - ExceptionType = exception.GetType().FullName ?? string.Empty, - DiagnosticMessage = $"{exception.GetType().FullName}: HRESULT 0x{unchecked((uint)exception.HResult):X8}", - ProtocolStatus = new Proto.ProtocolStatus - { - Code = Proto.ProtocolStatusCode.MxaccessFailure, - Message = "MXAccess event conversion failed.", - }, - }; - } -} diff --git a/src/MxGateway.Worker/MxAccess/MxAccessComServer.cs b/src/MxGateway.Worker/MxAccess/MxAccessComServer.cs deleted file mode 100644 index f9fad28..0000000 --- a/src/MxGateway.Worker/MxAccess/MxAccessComServer.cs +++ /dev/null @@ -1,151 +0,0 @@ -using System; -using System.Reflection; -using System.Runtime.ExceptionServices; -using ArchestrA.MxAccess; - -namespace MxGateway.Worker.MxAccess; - -/// -/// Adapter exposing MXAccess COM object methods through the IMxAccessServer interface. -/// -public sealed class MxAccessComServer : IMxAccessServer -{ - private readonly object mxAccessComObject; - - /// - /// Initializes the adapter with the MXAccess COM object. - /// - /// MXAccess COM object instance. - public MxAccessComServer(object mxAccessComObject) - { - this.mxAccessComObject = mxAccessComObject ?? throw new ArgumentNullException(nameof(mxAccessComObject)); - } - - /// - public int Register(string clientName) - { - if (mxAccessComObject is ILMXProxyServer mxAccessServer) - { - return mxAccessServer.Register(clientName); - } - - return (int)Invoke(nameof(Register), clientName); - } - - /// - public void Unregister(int serverHandle) - { - if (mxAccessComObject is ILMXProxyServer mxAccessServer) - { - mxAccessServer.Unregister(serverHandle); - return; - } - - Invoke(nameof(Unregister), serverHandle); - } - - /// - public int AddItem( - int serverHandle, - string itemDefinition) - { - if (mxAccessComObject is ILMXProxyServer mxAccessServer) - { - return mxAccessServer.AddItem(serverHandle, itemDefinition); - } - - return (int)Invoke(nameof(AddItem), serverHandle, itemDefinition); - } - - /// - public int AddItem2( - int serverHandle, - string itemDefinition, - string itemContext) - { - if (mxAccessComObject is ILMXProxyServer3 mxAccessServer) - { - return mxAccessServer.AddItem2(serverHandle, itemDefinition, itemContext); - } - - return (int)Invoke(nameof(AddItem2), serverHandle, itemDefinition, itemContext); - } - - /// - public void RemoveItem( - int serverHandle, - int itemHandle) - { - if (mxAccessComObject is ILMXProxyServer mxAccessServer) - { - mxAccessServer.RemoveItem(serverHandle, itemHandle); - return; - } - - Invoke(nameof(RemoveItem), serverHandle, itemHandle); - } - - /// - public void Advise( - int serverHandle, - int itemHandle) - { - if (mxAccessComObject is ILMXProxyServer mxAccessServer) - { - mxAccessServer.Advise(serverHandle, itemHandle); - return; - } - - Invoke(nameof(Advise), serverHandle, itemHandle); - } - - /// - public void UnAdvise( - int serverHandle, - int itemHandle) - { - if (mxAccessComObject is ILMXProxyServer mxAccessServer) - { - mxAccessServer.UnAdvise(serverHandle, itemHandle); - return; - } - - Invoke(nameof(UnAdvise), serverHandle, itemHandle); - } - - /// - public void AdviseSupervisory( - int serverHandle, - int itemHandle) - { - if (mxAccessComObject is ILMXProxyServer4 mxAccessServer) - { - mxAccessServer.AdviseSupervisory(serverHandle, itemHandle); - return; - } - - Invoke(nameof(AdviseSupervisory), serverHandle, itemHandle); - } - - private object Invoke( - string methodName, - params object[] arguments) - { - try - { - return mxAccessComObject - .GetType() - .InvokeMember( - methodName, - BindingFlags.Instance | BindingFlags.Public | BindingFlags.InvokeMethod, - binder: null, - target: mxAccessComObject, - args: arguments); - } - catch (TargetInvocationException exception) when (exception.InnerException is not null) - { - ExceptionDispatchInfo.Capture(exception.InnerException).Throw(); - throw; - } - } -} diff --git a/src/MxGateway.Worker/MxAccess/MxAlarmSnapshot.cs b/src/MxGateway.Worker/MxAccess/MxAlarmSnapshot.cs deleted file mode 100644 index 23c1816..0000000 --- a/src/MxGateway.Worker/MxAccess/MxAlarmSnapshot.cs +++ /dev/null @@ -1,59 +0,0 @@ -using System; - -namespace MxGateway.Worker.MxAccess; - -/// -/// Library-agnostic alarm-state enum. Mirrors the four STATE -/// values returned by AVEVA's WNWRAPCONSUMERLib XML payload — -/// UNACK_ALM, ACK_ALM, UNACK_RTN, ACK_RTN. -/// Decoupling the consumer from any specific COM library keeps the -/// proto-build path testable without an AVEVA install. -/// -public enum MxAlarmStateKind -{ - Unspecified = 0, - UnackAlm = 1, - AckAlm = 2, - UnackRtn = 3, - AckRtn = 4, -} - -/// -/// Single alarm record as emitted by the wnwrapConsumer XML stream. -/// Field names match the captured XML schema (see -/// docs/AlarmClientDiscovery.md "Option A — captured" section). -/// -public sealed class MxAlarmSnapshotRecord -{ - public Guid AlarmGuid { get; set; } - public DateTime TransitionTimestampUtc { get; set; } - public string ProviderNode { get; set; } = string.Empty; - public string ProviderName { get; set; } = string.Empty; - public string Group { get; set; } = string.Empty; - public string TagName { get; set; } = string.Empty; - public string Type { get; set; } = string.Empty; - public string Value { get; set; } = string.Empty; - public string Limit { get; set; } = string.Empty; - public int Priority { get; set; } - public MxAlarmStateKind State { get; set; } - public string OperatorNode { get; set; } = string.Empty; - public string OperatorName { get; set; } = string.Empty; - public string AlarmComment { get; set; } = string.Empty; -} - -/// -/// One transition emitted by the consumer's snapshot diff. Pairs the -/// latest record with its previous state so the proto layer can decide -/// whether the transition is a Raise / Acknowledge / Clear. -/// -public sealed class MxAlarmTransitionEvent : EventArgs -{ - public MxAlarmSnapshotRecord Record { get; set; } = new MxAlarmSnapshotRecord(); - - /// - /// The state on the consumer's previous polled snapshot, or - /// when this is the - /// first time the GUID has been observed. - /// - public MxAlarmStateKind PreviousState { get; set; } -} diff --git a/src/MxGateway.sln b/src/MxGateway.sln deleted file mode 100644 index 4af5b7a..0000000 --- a/src/MxGateway.sln +++ /dev/null @@ -1,104 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.0.31903.59 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MxGateway.Contracts", "MxGateway.Contracts\MxGateway.Contracts.csproj", "{484053B1-30E8-4411-9ACE-E3AE5EE65EB8}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MxGateway.Server", "MxGateway.Server\MxGateway.Server.csproj", "{2752A666-898C-4D2A-A5A6-4F2FD17F64AE}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MxGateway.Tests", "MxGateway.Tests\MxGateway.Tests.csproj", "{6E069780-A892-487E-AEED-051E26C829A4}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MxGateway.IntegrationTests", "MxGateway.IntegrationTests\MxGateway.IntegrationTests.csproj", "{6D0BDEA5-F3F5-4F7C-9152-040BF88E4F2D}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MxGateway.Worker", "MxGateway.Worker\MxGateway.Worker.csproj", "{5F2E4C90-B101-4D5D-A9D4-F9F7B53C1A85}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MxGateway.Worker.Tests", "MxGateway.Worker.Tests\MxGateway.Worker.Tests.csproj", "{91255F30-8D43-47C9-AC52-AA0DDA4E9348}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Debug|x64 = Debug|x64 - Debug|x86 = Debug|x86 - Release|Any CPU = Release|Any CPU - Release|x64 = Release|x64 - Release|x86 = Release|x86 - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {484053B1-30E8-4411-9ACE-E3AE5EE65EB8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {484053B1-30E8-4411-9ACE-E3AE5EE65EB8}.Debug|Any CPU.Build.0 = Debug|Any CPU - {484053B1-30E8-4411-9ACE-E3AE5EE65EB8}.Debug|x64.ActiveCfg = Debug|Any CPU - {484053B1-30E8-4411-9ACE-E3AE5EE65EB8}.Debug|x64.Build.0 = Debug|Any CPU - {484053B1-30E8-4411-9ACE-E3AE5EE65EB8}.Debug|x86.ActiveCfg = Debug|Any CPU - {484053B1-30E8-4411-9ACE-E3AE5EE65EB8}.Debug|x86.Build.0 = Debug|Any CPU - {484053B1-30E8-4411-9ACE-E3AE5EE65EB8}.Release|Any CPU.ActiveCfg = Release|Any CPU - {484053B1-30E8-4411-9ACE-E3AE5EE65EB8}.Release|Any CPU.Build.0 = Release|Any CPU - {484053B1-30E8-4411-9ACE-E3AE5EE65EB8}.Release|x64.ActiveCfg = Release|Any CPU - {484053B1-30E8-4411-9ACE-E3AE5EE65EB8}.Release|x64.Build.0 = Release|Any CPU - {484053B1-30E8-4411-9ACE-E3AE5EE65EB8}.Release|x86.ActiveCfg = Release|Any CPU - {484053B1-30E8-4411-9ACE-E3AE5EE65EB8}.Release|x86.Build.0 = Release|Any CPU - {2752A666-898C-4D2A-A5A6-4F2FD17F64AE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {2752A666-898C-4D2A-A5A6-4F2FD17F64AE}.Debug|Any CPU.Build.0 = Debug|Any CPU - {2752A666-898C-4D2A-A5A6-4F2FD17F64AE}.Debug|x64.ActiveCfg = Debug|Any CPU - {2752A666-898C-4D2A-A5A6-4F2FD17F64AE}.Debug|x64.Build.0 = Debug|Any CPU - {2752A666-898C-4D2A-A5A6-4F2FD17F64AE}.Debug|x86.ActiveCfg = Debug|Any CPU - {2752A666-898C-4D2A-A5A6-4F2FD17F64AE}.Debug|x86.Build.0 = Debug|Any CPU - {2752A666-898C-4D2A-A5A6-4F2FD17F64AE}.Release|Any CPU.ActiveCfg = Release|Any CPU - {2752A666-898C-4D2A-A5A6-4F2FD17F64AE}.Release|Any CPU.Build.0 = Release|Any CPU - {2752A666-898C-4D2A-A5A6-4F2FD17F64AE}.Release|x64.ActiveCfg = Release|Any CPU - {2752A666-898C-4D2A-A5A6-4F2FD17F64AE}.Release|x64.Build.0 = Release|Any CPU - {2752A666-898C-4D2A-A5A6-4F2FD17F64AE}.Release|x86.ActiveCfg = Release|Any CPU - {2752A666-898C-4D2A-A5A6-4F2FD17F64AE}.Release|x86.Build.0 = Release|Any CPU - {6E069780-A892-487E-AEED-051E26C829A4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {6E069780-A892-487E-AEED-051E26C829A4}.Debug|Any CPU.Build.0 = Debug|Any CPU - {6E069780-A892-487E-AEED-051E26C829A4}.Debug|x64.ActiveCfg = Debug|Any CPU - {6E069780-A892-487E-AEED-051E26C829A4}.Debug|x64.Build.0 = Debug|Any CPU - {6E069780-A892-487E-AEED-051E26C829A4}.Debug|x86.ActiveCfg = Debug|Any CPU - {6E069780-A892-487E-AEED-051E26C829A4}.Debug|x86.Build.0 = Debug|Any CPU - {6E069780-A892-487E-AEED-051E26C829A4}.Release|Any CPU.ActiveCfg = Release|Any CPU - {6E069780-A892-487E-AEED-051E26C829A4}.Release|Any CPU.Build.0 = Release|Any CPU - {6E069780-A892-487E-AEED-051E26C829A4}.Release|x64.ActiveCfg = Release|Any CPU - {6E069780-A892-487E-AEED-051E26C829A4}.Release|x64.Build.0 = Release|Any CPU - {6E069780-A892-487E-AEED-051E26C829A4}.Release|x86.ActiveCfg = Release|Any CPU - {6E069780-A892-487E-AEED-051E26C829A4}.Release|x86.Build.0 = Release|Any CPU - {6D0BDEA5-F3F5-4F7C-9152-040BF88E4F2D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {6D0BDEA5-F3F5-4F7C-9152-040BF88E4F2D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {6D0BDEA5-F3F5-4F7C-9152-040BF88E4F2D}.Debug|x64.ActiveCfg = Debug|Any CPU - {6D0BDEA5-F3F5-4F7C-9152-040BF88E4F2D}.Debug|x64.Build.0 = Debug|Any CPU - {6D0BDEA5-F3F5-4F7C-9152-040BF88E4F2D}.Debug|x86.ActiveCfg = Debug|Any CPU - {6D0BDEA5-F3F5-4F7C-9152-040BF88E4F2D}.Debug|x86.Build.0 = Debug|Any CPU - {6D0BDEA5-F3F5-4F7C-9152-040BF88E4F2D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {6D0BDEA5-F3F5-4F7C-9152-040BF88E4F2D}.Release|Any CPU.Build.0 = Release|Any CPU - {6D0BDEA5-F3F5-4F7C-9152-040BF88E4F2D}.Release|x64.ActiveCfg = Release|Any CPU - {6D0BDEA5-F3F5-4F7C-9152-040BF88E4F2D}.Release|x64.Build.0 = Release|Any CPU - {6D0BDEA5-F3F5-4F7C-9152-040BF88E4F2D}.Release|x86.ActiveCfg = Release|Any CPU - {6D0BDEA5-F3F5-4F7C-9152-040BF88E4F2D}.Release|x86.Build.0 = Release|Any CPU - {5F2E4C90-B101-4D5D-A9D4-F9F7B53C1A85}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {5F2E4C90-B101-4D5D-A9D4-F9F7B53C1A85}.Debug|Any CPU.Build.0 = Debug|Any CPU - {5F2E4C90-B101-4D5D-A9D4-F9F7B53C1A85}.Debug|x64.ActiveCfg = Debug|Any CPU - {5F2E4C90-B101-4D5D-A9D4-F9F7B53C1A85}.Debug|x64.Build.0 = Debug|Any CPU - {5F2E4C90-B101-4D5D-A9D4-F9F7B53C1A85}.Debug|x86.ActiveCfg = Debug|Any CPU - {5F2E4C90-B101-4D5D-A9D4-F9F7B53C1A85}.Debug|x86.Build.0 = Debug|Any CPU - {5F2E4C90-B101-4D5D-A9D4-F9F7B53C1A85}.Release|Any CPU.ActiveCfg = Release|Any CPU - {5F2E4C90-B101-4D5D-A9D4-F9F7B53C1A85}.Release|Any CPU.Build.0 = Release|Any CPU - {5F2E4C90-B101-4D5D-A9D4-F9F7B53C1A85}.Release|x64.ActiveCfg = Release|Any CPU - {5F2E4C90-B101-4D5D-A9D4-F9F7B53C1A85}.Release|x64.Build.0 = Release|Any CPU - {5F2E4C90-B101-4D5D-A9D4-F9F7B53C1A85}.Release|x86.ActiveCfg = Release|Any CPU - {5F2E4C90-B101-4D5D-A9D4-F9F7B53C1A85}.Release|x86.Build.0 = Release|Any CPU - {91255F30-8D43-47C9-AC52-AA0DDA4E9348}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {91255F30-8D43-47C9-AC52-AA0DDA4E9348}.Debug|Any CPU.Build.0 = Debug|Any CPU - {91255F30-8D43-47C9-AC52-AA0DDA4E9348}.Debug|x64.ActiveCfg = Debug|Any CPU - {91255F30-8D43-47C9-AC52-AA0DDA4E9348}.Debug|x64.Build.0 = Debug|Any CPU - {91255F30-8D43-47C9-AC52-AA0DDA4E9348}.Debug|x86.ActiveCfg = Debug|Any CPU - {91255F30-8D43-47C9-AC52-AA0DDA4E9348}.Debug|x86.Build.0 = Debug|Any CPU - {91255F30-8D43-47C9-AC52-AA0DDA4E9348}.Release|Any CPU.ActiveCfg = Release|Any CPU - {91255F30-8D43-47C9-AC52-AA0DDA4E9348}.Release|Any CPU.Build.0 = Release|Any CPU - {91255F30-8D43-47C9-AC52-AA0DDA4E9348}.Release|x64.ActiveCfg = Release|Any CPU - {91255F30-8D43-47C9-AC52-AA0DDA4E9348}.Release|x64.Build.0 = Release|Any CPU - {91255F30-8D43-47C9-AC52-AA0DDA4E9348}.Release|x86.ActiveCfg = Release|Any CPU - {91255F30-8D43-47C9-AC52-AA0DDA4E9348}.Release|x86.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection -EndGlobal diff --git a/src/ZB.MOM.WW.MxGateway.Contracts/GatewayContractInfo.cs b/src/ZB.MOM.WW.MxGateway.Contracts/GatewayContractInfo.cs new file mode 100644 index 0000000..074af3c --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Contracts/GatewayContractInfo.cs @@ -0,0 +1,26 @@ +namespace ZB.MOM.WW.MxGateway.Contracts; + +/// +/// Holds the protocol version constants shared by gateway components. +/// is advertised to clients in +/// OpenSessionReply; is used to +/// validate WorkerEnvelope protocol framing on the gateway↔worker pipe. +/// +public static class GatewayContractInfo +{ + public const uint GatewayProtocolVersion = 3; + + public const uint WorkerProtocolVersion = 1; + + public const string DefaultBackendName = "mxaccess-worker"; + + /// + /// Environment variable name that opts an xUnit suite into running live + /// MXAccess COM tests. Single source of truth shared by both + /// ZB.MOM.WW.MxGateway.IntegrationTests.LiveMxAccessFactAttribute and + /// ZB.MOM.WW.MxGateway.Worker.Tests.TestSupport.LiveMxAccessFactAttribute + /// so any future opt-in tweak does not silently leave one project + /// behind — see Worker.Tests-025. + /// + public const string LiveMxAccessOptInVariableName = "MXGATEWAY_RUN_LIVE_MXACCESS_TESTS"; +} diff --git a/src/MxGateway.Contracts/Generated/GalaxyRepository.cs b/src/ZB.MOM.WW.MxGateway.Contracts/Generated/GalaxyRepository.cs similarity index 94% rename from src/MxGateway.Contracts/Generated/GalaxyRepository.cs rename to src/ZB.MOM.WW.MxGateway.Contracts/Generated/GalaxyRepository.cs index 3224a65..37e775a 100644 --- a/src/MxGateway.Contracts/Generated/GalaxyRepository.cs +++ b/src/ZB.MOM.WW.MxGateway.Contracts/Generated/GalaxyRepository.cs @@ -9,7 +9,7 @@ using pb = global::Google.Protobuf; using pbc = global::Google.Protobuf.Collections; using pbr = global::Google.Protobuf.Reflection; using scg = global::System.Collections.Generic; -namespace MxGateway.Contracts.Proto.Galaxy { +namespace ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy { /// Holder for reflection information generated from galaxy_repository.proto public static partial class GalaxyRepositoryReflection { @@ -72,21 +72,21 @@ namespace MxGateway.Contracts.Proto.Galaxy { "dBosLmdhbGF4eV9yZXBvc2l0b3J5LnYxLkRpc2NvdmVySGllcmFyY2h5UmVw", "bHkSaAoRV2F0Y2hEZXBsb3lFdmVudHMSLi5nYWxheHlfcmVwb3NpdG9yeS52", "MS5XYXRjaERlcGxveUV2ZW50c1JlcXVlc3QaIS5nYWxheHlfcmVwb3NpdG9y", - "eS52MS5EZXBsb3lFdmVudDABQiOqAiBNeEdhdGV3YXkuQ29udHJhY3RzLlBy", - "b3RvLkdhbGF4eWIGcHJvdG8z")); + "eS52MS5EZXBsb3lFdmVudDABQi2qAipaQi5NT00uV1cuTXhHYXRld2F5LkNv", + "bnRyYWN0cy5Qcm90by5HYWxheHliBnByb3RvMw==")); descriptor = pbr::FileDescriptor.FromGeneratedCode(descriptorData, new pbr::FileDescriptor[] { global::Google.Protobuf.WellKnownTypes.TimestampReflection.Descriptor, global::Google.Protobuf.WellKnownTypes.WrappersReflection.Descriptor, }, new pbr::GeneratedClrTypeInfo(null, null, new pbr::GeneratedClrTypeInfo[] { - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.Galaxy.TestConnectionRequest), global::MxGateway.Contracts.Proto.Galaxy.TestConnectionRequest.Parser, null, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.Galaxy.TestConnectionReply), global::MxGateway.Contracts.Proto.Galaxy.TestConnectionReply.Parser, new[]{ "Ok" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeRequest), global::MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeRequest.Parser, null, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeReply), global::MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeReply.Parser, new[]{ "Present", "TimeOfLastDeploy" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyRequest), global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyRequest.Parser, new[]{ "PageSize", "PageToken", "RootGobjectId", "RootTagName", "RootContainedPath", "MaxDepth", "CategoryIds", "TemplateChainContains", "TagNameGlob", "IncludeAttributes", "AlarmBearingOnly", "HistorizedOnly" }, new[]{ "Root", "IncludeAttributes" }, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyReply), global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyReply.Parser, new[]{ "Objects", "NextPageToken", "TotalObjectCount" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.Galaxy.WatchDeployEventsRequest), global::MxGateway.Contracts.Proto.Galaxy.WatchDeployEventsRequest.Parser, new[]{ "LastSeenDeployTime" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.Galaxy.DeployEvent), global::MxGateway.Contracts.Proto.Galaxy.DeployEvent.Parser, new[]{ "Sequence", "ObservedAt", "TimeOfLastDeploy", "TimeOfLastDeployPresent", "ObjectCount", "AttributeCount" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.Galaxy.GalaxyObject), global::MxGateway.Contracts.Proto.Galaxy.GalaxyObject.Parser, new[]{ "GobjectId", "TagName", "ContainedName", "BrowseName", "ParentGobjectId", "IsArea", "CategoryId", "HostedByGobjectId", "TemplateChain", "Attributes" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.Galaxy.GalaxyAttribute), global::MxGateway.Contracts.Proto.Galaxy.GalaxyAttribute.Parser, new[]{ "AttributeName", "FullTagReference", "MxDataType", "DataTypeName", "IsArray", "ArrayDimension", "ArrayDimensionPresent", "MxAttributeCategory", "SecurityClassification", "IsHistorized", "IsAlarm" }, null, null, null, null) + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.TestConnectionRequest), global::ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.TestConnectionRequest.Parser, null, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.TestConnectionReply), global::ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.TestConnectionReply.Parser, new[]{ "Ok" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeRequest), global::ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeRequest.Parser, null, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeReply), global::ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeReply.Parser, new[]{ "Present", "TimeOfLastDeploy" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyRequest), global::ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyRequest.Parser, new[]{ "PageSize", "PageToken", "RootGobjectId", "RootTagName", "RootContainedPath", "MaxDepth", "CategoryIds", "TemplateChainContains", "TagNameGlob", "IncludeAttributes", "AlarmBearingOnly", "HistorizedOnly" }, new[]{ "Root", "IncludeAttributes" }, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyReply), global::ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyReply.Parser, new[]{ "Objects", "NextPageToken", "TotalObjectCount" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.WatchDeployEventsRequest), global::ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.WatchDeployEventsRequest.Parser, new[]{ "LastSeenDeployTime" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.DeployEvent), global::ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.DeployEvent.Parser, new[]{ "Sequence", "ObservedAt", "TimeOfLastDeploy", "TimeOfLastDeployPresent", "ObjectCount", "AttributeCount" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.GalaxyObject), global::ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.GalaxyObject.Parser, new[]{ "GobjectId", "TagName", "ContainedName", "BrowseName", "ParentGobjectId", "IsArea", "CategoryId", "HostedByGobjectId", "TemplateChain", "Attributes" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.GalaxyAttribute), global::ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.GalaxyAttribute.Parser, new[]{ "AttributeName", "FullTagReference", "MxDataType", "DataTypeName", "IsArray", "ArrayDimension", "ArrayDimensionPresent", "MxAttributeCategory", "SecurityClassification", "IsHistorized", "IsAlarm" }, null, null, null, null) })); } #endregion @@ -108,7 +108,7 @@ namespace MxGateway.Contracts.Proto.Galaxy { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.Galaxy.GalaxyRepositoryReflection.Descriptor.MessageTypes[0]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.GalaxyRepositoryReflection.Descriptor.MessageTypes[0]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -269,7 +269,7 @@ namespace MxGateway.Contracts.Proto.Galaxy { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.Galaxy.GalaxyRepositoryReflection.Descriptor.MessageTypes[1]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.GalaxyRepositoryReflection.Descriptor.MessageTypes[1]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -467,7 +467,7 @@ namespace MxGateway.Contracts.Proto.Galaxy { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.Galaxy.GalaxyRepositoryReflection.Descriptor.MessageTypes[2]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.GalaxyRepositoryReflection.Descriptor.MessageTypes[2]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -628,7 +628,7 @@ namespace MxGateway.Contracts.Proto.Galaxy { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.Galaxy.GalaxyRepositoryReflection.Descriptor.MessageTypes[3]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.GalaxyRepositoryReflection.Descriptor.MessageTypes[3]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -873,7 +873,7 @@ namespace MxGateway.Contracts.Proto.Galaxy { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.Galaxy.GalaxyRepositoryReflection.Descriptor.MessageTypes[4]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.GalaxyRepositoryReflection.Descriptor.MessageTypes[4]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -1589,7 +1589,7 @@ namespace MxGateway.Contracts.Proto.Galaxy { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.Galaxy.GalaxyRepositoryReflection.Descriptor.MessageTypes[5]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.GalaxyRepositoryReflection.Descriptor.MessageTypes[5]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -1623,12 +1623,12 @@ namespace MxGateway.Contracts.Proto.Galaxy { /// Field number for the "objects" field. public const int ObjectsFieldNumber = 1; - private static readonly pb::FieldCodec _repeated_objects_codec - = pb::FieldCodec.ForMessage(10, global::MxGateway.Contracts.Proto.Galaxy.GalaxyObject.Parser); - private readonly pbc::RepeatedField objects_ = new pbc::RepeatedField(); + private static readonly pb::FieldCodec _repeated_objects_codec + = pb::FieldCodec.ForMessage(10, global::ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.GalaxyObject.Parser); + private readonly pbc::RepeatedField objects_ = new pbc::RepeatedField(); [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public pbc::RepeatedField Objects { + public pbc::RepeatedField Objects { get { return objects_; } } @@ -1856,7 +1856,7 @@ namespace MxGateway.Contracts.Proto.Galaxy { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.Galaxy.GalaxyRepositoryReflection.Descriptor.MessageTypes[6]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.GalaxyRepositoryReflection.Descriptor.MessageTypes[6]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -2067,7 +2067,7 @@ namespace MxGateway.Contracts.Proto.Galaxy { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.Galaxy.GalaxyRepositoryReflection.Descriptor.MessageTypes[7]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.GalaxyRepositoryReflection.Descriptor.MessageTypes[7]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -2477,7 +2477,7 @@ namespace MxGateway.Contracts.Proto.Galaxy { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.Galaxy.GalaxyRepositoryReflection.Descriptor.MessageTypes[8]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.GalaxyRepositoryReflection.Descriptor.MessageTypes[8]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -2625,12 +2625,12 @@ namespace MxGateway.Contracts.Proto.Galaxy { /// Field number for the "attributes" field. public const int AttributesFieldNumber = 10; - private static readonly pb::FieldCodec _repeated_attributes_codec - = pb::FieldCodec.ForMessage(82, global::MxGateway.Contracts.Proto.Galaxy.GalaxyAttribute.Parser); - private readonly pbc::RepeatedField attributes_ = new pbc::RepeatedField(); + private static readonly pb::FieldCodec _repeated_attributes_codec + = pb::FieldCodec.ForMessage(82, global::ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.GalaxyAttribute.Parser); + private readonly pbc::RepeatedField attributes_ = new pbc::RepeatedField(); [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public pbc::RepeatedField Attributes { + public pbc::RepeatedField Attributes { get { return attributes_; } } @@ -2986,7 +2986,7 @@ namespace MxGateway.Contracts.Proto.Galaxy { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.Galaxy.GalaxyRepositoryReflection.Descriptor.MessageTypes[9]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.GalaxyRepositoryReflection.Descriptor.MessageTypes[9]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -3053,6 +3053,14 @@ namespace MxGateway.Contracts.Proto.Galaxy { /// Field number for the "mx_data_type" field. public const int MxDataTypeFieldNumber = 3; private int mxDataType_; + /// + /// Raw Galaxy SQL `dbo.data_type` identifier, passed through unchanged. + /// This is NOT a member of `mxaccess_gateway.v1.MxDataType` — Galaxy's + /// type enumeration is distinct from MXAccess's wire data-type enum and + /// the two must not be cast or compared. The GalaxyRepository service is + /// metadata-only and deliberately does not share types with + /// mxaccess_gateway.proto. See docs/GalaxyRepository.md. + /// [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public int MxDataType { @@ -3065,6 +3073,10 @@ namespace MxGateway.Contracts.Proto.Galaxy { /// Field number for the "data_type_name" field. public const int DataTypeNameFieldNumber = 4; private string dataTypeName_ = ""; + /// + /// Human-readable name from Galaxy's `dbo.data_type` table (e.g. "Float", + /// "Integer", "Boolean"). Free-form Galaxy text; not a stable enum. + /// [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public string DataTypeName { @@ -3113,6 +3125,11 @@ namespace MxGateway.Contracts.Proto.Galaxy { /// Field number for the "mx_attribute_category" field. public const int MxAttributeCategoryFieldNumber = 8; private int mxAttributeCategory_; + /// + /// Raw Galaxy SQL attribute-category identifier, passed through unchanged. + /// Galaxy-specific; not mapped to any gateway enum. See + /// docs/GalaxyRepository.md. + /// [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public int MxAttributeCategory { @@ -3125,6 +3142,11 @@ namespace MxGateway.Contracts.Proto.Galaxy { /// Field number for the "security_classification" field. public const int SecurityClassificationFieldNumber = 9; private int securityClassification_; + /// + /// Raw Galaxy SQL security-classification identifier, passed through + /// unchanged. Galaxy-specific; not mapped to any gateway enum. See + /// docs/GalaxyRepository.md. + /// [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public int SecurityClassification { diff --git a/src/MxGateway.Contracts/Generated/GalaxyRepositoryGrpc.cs b/src/ZB.MOM.WW.MxGateway.Contracts/Generated/GalaxyRepositoryGrpc.cs similarity index 57% rename from src/MxGateway.Contracts/Generated/GalaxyRepositoryGrpc.cs rename to src/ZB.MOM.WW.MxGateway.Contracts/Generated/GalaxyRepositoryGrpc.cs index 7f00f0a..0062d92 100644 --- a/src/MxGateway.Contracts/Generated/GalaxyRepositoryGrpc.cs +++ b/src/ZB.MOM.WW.MxGateway.Contracts/Generated/GalaxyRepositoryGrpc.cs @@ -7,7 +7,7 @@ using grpc = global::Grpc.Core; -namespace MxGateway.Contracts.Proto.Galaxy { +namespace ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy { /// /// Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL /// database). Lets clients enumerate the deployed object hierarchy and each @@ -52,24 +52,24 @@ namespace MxGateway.Contracts.Proto.Galaxy { } [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - static readonly grpc::Marshaller __Marshaller_galaxy_repository_v1_TestConnectionRequest = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::MxGateway.Contracts.Proto.Galaxy.TestConnectionRequest.Parser)); + static readonly grpc::Marshaller __Marshaller_galaxy_repository_v1_TestConnectionRequest = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.TestConnectionRequest.Parser)); [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - static readonly grpc::Marshaller __Marshaller_galaxy_repository_v1_TestConnectionReply = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::MxGateway.Contracts.Proto.Galaxy.TestConnectionReply.Parser)); + static readonly grpc::Marshaller __Marshaller_galaxy_repository_v1_TestConnectionReply = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.TestConnectionReply.Parser)); [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - static readonly grpc::Marshaller __Marshaller_galaxy_repository_v1_GetLastDeployTimeRequest = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeRequest.Parser)); + static readonly grpc::Marshaller __Marshaller_galaxy_repository_v1_GetLastDeployTimeRequest = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeRequest.Parser)); [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - static readonly grpc::Marshaller __Marshaller_galaxy_repository_v1_GetLastDeployTimeReply = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeReply.Parser)); + static readonly grpc::Marshaller __Marshaller_galaxy_repository_v1_GetLastDeployTimeReply = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeReply.Parser)); [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - static readonly grpc::Marshaller __Marshaller_galaxy_repository_v1_DiscoverHierarchyRequest = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyRequest.Parser)); + static readonly grpc::Marshaller __Marshaller_galaxy_repository_v1_DiscoverHierarchyRequest = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyRequest.Parser)); [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - static readonly grpc::Marshaller __Marshaller_galaxy_repository_v1_DiscoverHierarchyReply = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyReply.Parser)); + static readonly grpc::Marshaller __Marshaller_galaxy_repository_v1_DiscoverHierarchyReply = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyReply.Parser)); [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - static readonly grpc::Marshaller __Marshaller_galaxy_repository_v1_WatchDeployEventsRequest = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::MxGateway.Contracts.Proto.Galaxy.WatchDeployEventsRequest.Parser)); + static readonly grpc::Marshaller __Marshaller_galaxy_repository_v1_WatchDeployEventsRequest = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.WatchDeployEventsRequest.Parser)); [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - static readonly grpc::Marshaller __Marshaller_galaxy_repository_v1_DeployEvent = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::MxGateway.Contracts.Proto.Galaxy.DeployEvent.Parser)); + static readonly grpc::Marshaller __Marshaller_galaxy_repository_v1_DeployEvent = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.DeployEvent.Parser)); [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - static readonly grpc::Method __Method_TestConnection = new grpc::Method( + static readonly grpc::Method __Method_TestConnection = new grpc::Method( grpc::MethodType.Unary, __ServiceName, "TestConnection", @@ -77,7 +77,7 @@ namespace MxGateway.Contracts.Proto.Galaxy { __Marshaller_galaxy_repository_v1_TestConnectionReply); [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - static readonly grpc::Method __Method_GetLastDeployTime = new grpc::Method( + static readonly grpc::Method __Method_GetLastDeployTime = new grpc::Method( grpc::MethodType.Unary, __ServiceName, "GetLastDeployTime", @@ -85,7 +85,7 @@ namespace MxGateway.Contracts.Proto.Galaxy { __Marshaller_galaxy_repository_v1_GetLastDeployTimeReply); [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - static readonly grpc::Method __Method_DiscoverHierarchy = new grpc::Method( + static readonly grpc::Method __Method_DiscoverHierarchy = new grpc::Method( grpc::MethodType.Unary, __ServiceName, "DiscoverHierarchy", @@ -93,7 +93,7 @@ namespace MxGateway.Contracts.Proto.Galaxy { __Marshaller_galaxy_repository_v1_DiscoverHierarchyReply); [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - static readonly grpc::Method __Method_WatchDeployEvents = new grpc::Method( + static readonly grpc::Method __Method_WatchDeployEvents = new grpc::Method( grpc::MethodType.ServerStreaming, __ServiceName, "WatchDeployEvents", @@ -103,7 +103,7 @@ namespace MxGateway.Contracts.Proto.Galaxy { /// Service descriptor public static global::Google.Protobuf.Reflection.ServiceDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.Galaxy.GalaxyRepositoryReflection.Descriptor.Services[0]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.GalaxyRepositoryReflection.Descriptor.Services[0]; } } /// Base class for server-side implementations of GalaxyRepository @@ -111,19 +111,19 @@ namespace MxGateway.Contracts.Proto.Galaxy { public abstract partial class GalaxyRepositoryBase { [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - public virtual global::System.Threading.Tasks.Task TestConnection(global::MxGateway.Contracts.Proto.Galaxy.TestConnectionRequest request, grpc::ServerCallContext context) + public virtual global::System.Threading.Tasks.Task TestConnection(global::ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.TestConnectionRequest request, grpc::ServerCallContext context) { throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, "")); } [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - public virtual global::System.Threading.Tasks.Task GetLastDeployTime(global::MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeRequest request, grpc::ServerCallContext context) + public virtual global::System.Threading.Tasks.Task GetLastDeployTime(global::ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeRequest request, grpc::ServerCallContext context) { throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, "")); } [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - public virtual global::System.Threading.Tasks.Task DiscoverHierarchy(global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyRequest request, grpc::ServerCallContext context) + public virtual global::System.Threading.Tasks.Task DiscoverHierarchy(global::ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyRequest request, grpc::ServerCallContext context) { throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, "")); } @@ -141,7 +141,7 @@ namespace MxGateway.Contracts.Proto.Galaxy { /// The context of the server-side call handler being invoked. /// A task indicating completion of the handler. [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - public virtual global::System.Threading.Tasks.Task WatchDeployEvents(global::MxGateway.Contracts.Proto.Galaxy.WatchDeployEventsRequest request, grpc::IServerStreamWriter responseStream, grpc::ServerCallContext context) + public virtual global::System.Threading.Tasks.Task WatchDeployEvents(global::ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.WatchDeployEventsRequest request, grpc::IServerStreamWriter responseStream, grpc::ServerCallContext context) { throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, "")); } @@ -176,62 +176,62 @@ namespace MxGateway.Contracts.Proto.Galaxy { } [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - public virtual global::MxGateway.Contracts.Proto.Galaxy.TestConnectionReply TestConnection(global::MxGateway.Contracts.Proto.Galaxy.TestConnectionRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) + public virtual global::ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.TestConnectionReply TestConnection(global::ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.TestConnectionRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) { return TestConnection(request, new grpc::CallOptions(headers, deadline, cancellationToken)); } [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - public virtual global::MxGateway.Contracts.Proto.Galaxy.TestConnectionReply TestConnection(global::MxGateway.Contracts.Proto.Galaxy.TestConnectionRequest request, grpc::CallOptions options) + public virtual global::ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.TestConnectionReply TestConnection(global::ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.TestConnectionRequest request, grpc::CallOptions options) { return CallInvoker.BlockingUnaryCall(__Method_TestConnection, null, options, request); } [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - public virtual grpc::AsyncUnaryCall TestConnectionAsync(global::MxGateway.Contracts.Proto.Galaxy.TestConnectionRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) + public virtual grpc::AsyncUnaryCall TestConnectionAsync(global::ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.TestConnectionRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) { return TestConnectionAsync(request, new grpc::CallOptions(headers, deadline, cancellationToken)); } [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - public virtual grpc::AsyncUnaryCall TestConnectionAsync(global::MxGateway.Contracts.Proto.Galaxy.TestConnectionRequest request, grpc::CallOptions options) + public virtual grpc::AsyncUnaryCall TestConnectionAsync(global::ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.TestConnectionRequest request, grpc::CallOptions options) { return CallInvoker.AsyncUnaryCall(__Method_TestConnection, null, options, request); } [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - public virtual global::MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeReply GetLastDeployTime(global::MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) + public virtual global::ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeReply GetLastDeployTime(global::ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) { return GetLastDeployTime(request, new grpc::CallOptions(headers, deadline, cancellationToken)); } [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - public virtual global::MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeReply GetLastDeployTime(global::MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeRequest request, grpc::CallOptions options) + public virtual global::ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeReply GetLastDeployTime(global::ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeRequest request, grpc::CallOptions options) { return CallInvoker.BlockingUnaryCall(__Method_GetLastDeployTime, null, options, request); } [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - public virtual grpc::AsyncUnaryCall GetLastDeployTimeAsync(global::MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) + public virtual grpc::AsyncUnaryCall GetLastDeployTimeAsync(global::ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) { return GetLastDeployTimeAsync(request, new grpc::CallOptions(headers, deadline, cancellationToken)); } [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - public virtual grpc::AsyncUnaryCall GetLastDeployTimeAsync(global::MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeRequest request, grpc::CallOptions options) + public virtual grpc::AsyncUnaryCall GetLastDeployTimeAsync(global::ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeRequest request, grpc::CallOptions options) { return CallInvoker.AsyncUnaryCall(__Method_GetLastDeployTime, null, options, request); } [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - public virtual global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyReply DiscoverHierarchy(global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) + public virtual global::ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyReply DiscoverHierarchy(global::ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) { return DiscoverHierarchy(request, new grpc::CallOptions(headers, deadline, cancellationToken)); } [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - public virtual global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyReply DiscoverHierarchy(global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyRequest request, grpc::CallOptions options) + public virtual global::ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyReply DiscoverHierarchy(global::ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyRequest request, grpc::CallOptions options) { return CallInvoker.BlockingUnaryCall(__Method_DiscoverHierarchy, null, options, request); } [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - public virtual grpc::AsyncUnaryCall DiscoverHierarchyAsync(global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) + public virtual grpc::AsyncUnaryCall DiscoverHierarchyAsync(global::ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) { return DiscoverHierarchyAsync(request, new grpc::CallOptions(headers, deadline, cancellationToken)); } [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - public virtual grpc::AsyncUnaryCall DiscoverHierarchyAsync(global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyRequest request, grpc::CallOptions options) + public virtual grpc::AsyncUnaryCall DiscoverHierarchyAsync(global::ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyRequest request, grpc::CallOptions options) { return CallInvoker.AsyncUnaryCall(__Method_DiscoverHierarchy, null, options, request); } @@ -249,7 +249,7 @@ namespace MxGateway.Contracts.Proto.Galaxy { /// An optional token for canceling the call. /// The call object. [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - public virtual grpc::AsyncServerStreamingCall WatchDeployEvents(global::MxGateway.Contracts.Proto.Galaxy.WatchDeployEventsRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) + public virtual grpc::AsyncServerStreamingCall WatchDeployEvents(global::ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.WatchDeployEventsRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) { return WatchDeployEvents(request, new grpc::CallOptions(headers, deadline, cancellationToken)); } @@ -265,7 +265,7 @@ namespace MxGateway.Contracts.Proto.Galaxy { /// The options for the call. /// The call object. [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - public virtual grpc::AsyncServerStreamingCall WatchDeployEvents(global::MxGateway.Contracts.Proto.Galaxy.WatchDeployEventsRequest request, grpc::CallOptions options) + public virtual grpc::AsyncServerStreamingCall WatchDeployEvents(global::ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.WatchDeployEventsRequest request, grpc::CallOptions options) { return CallInvoker.AsyncServerStreamingCall(__Method_WatchDeployEvents, null, options, request); } @@ -296,10 +296,10 @@ namespace MxGateway.Contracts.Proto.Galaxy { [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] public static void BindService(grpc::ServiceBinderBase serviceBinder, GalaxyRepositoryBase serviceImpl) { - serviceBinder.AddMethod(__Method_TestConnection, serviceImpl == null ? null : new grpc::UnaryServerMethod(serviceImpl.TestConnection)); - serviceBinder.AddMethod(__Method_GetLastDeployTime, serviceImpl == null ? null : new grpc::UnaryServerMethod(serviceImpl.GetLastDeployTime)); - serviceBinder.AddMethod(__Method_DiscoverHierarchy, serviceImpl == null ? null : new grpc::UnaryServerMethod(serviceImpl.DiscoverHierarchy)); - serviceBinder.AddMethod(__Method_WatchDeployEvents, serviceImpl == null ? null : new grpc::ServerStreamingServerMethod(serviceImpl.WatchDeployEvents)); + serviceBinder.AddMethod(__Method_TestConnection, serviceImpl == null ? null : new grpc::UnaryServerMethod(serviceImpl.TestConnection)); + serviceBinder.AddMethod(__Method_GetLastDeployTime, serviceImpl == null ? null : new grpc::UnaryServerMethod(serviceImpl.GetLastDeployTime)); + serviceBinder.AddMethod(__Method_DiscoverHierarchy, serviceImpl == null ? null : new grpc::UnaryServerMethod(serviceImpl.DiscoverHierarchy)); + serviceBinder.AddMethod(__Method_WatchDeployEvents, serviceImpl == null ? null : new grpc::ServerStreamingServerMethod(serviceImpl.WatchDeployEvents)); } } diff --git a/src/MxGateway.Contracts/Generated/MxaccessGateway.cs b/src/ZB.MOM.WW.MxGateway.Contracts/Generated/MxaccessGateway.cs similarity index 74% rename from src/MxGateway.Contracts/Generated/MxaccessGateway.cs rename to src/ZB.MOM.WW.MxGateway.Contracts/Generated/MxaccessGateway.cs index ed8b018..e7bb8d1 100644 --- a/src/MxGateway.Contracts/Generated/MxaccessGateway.cs +++ b/src/ZB.MOM.WW.MxGateway.Contracts/Generated/MxaccessGateway.cs @@ -9,7 +9,7 @@ using pb = global::Google.Protobuf; using pbc = global::Google.Protobuf.Collections; using pbr = global::Google.Protobuf.Reflection; using scg = global::System.Collections.Generic; -namespace MxGateway.Contracts.Proto { +namespace ZB.MOM.WW.MxGateway.Contracts.Proto { /// Holder for reflection information generated from mxaccess_gateway.proto public static partial class MxaccessGatewayReflection { @@ -46,7 +46,7 @@ namespace MxGateway.Contracts.Proto { "ZnRlcl93b3JrZXJfc2VxdWVuY2UYAiABKAQidgoQTXhDb21tYW5kUmVxdWVz", "dBISCgpzZXNzaW9uX2lkGAEgASgJEh0KFWNsaWVudF9jb3JyZWxhdGlvbl9p", "ZBgCIAEoCRIvCgdjb21tYW5kGAMgASgLMh4ubXhhY2Nlc3NfZ2F0ZXdheS52", - "MS5NeENvbW1hbmQi7xIKCU14Q29tbWFuZBIwCgRraW5kGAEgASgOMiIubXhh", + "MS5NeENvbW1hbmQiwBUKCU14Q29tbWFuZBIwCgRraW5kGAEgASgOMiIubXhh", "Y2Nlc3NfZ2F0ZXdheS52MS5NeENvbW1hbmRLaW5kEjgKCHJlZ2lzdGVyGAog", "ASgLMiQubXhhY2Nlc3NfZ2F0ZXdheS52MS5SZWdpc3RlckNvbW1hbmRIABI8", "Cgp1bnJlZ2lzdGVyGAsgASgLMiYubXhhY2Nlc3NfZ2F0ZXdheS52MS5VbnJl", @@ -92,417 +92,493 @@ namespace MxGateway.Contracts.Proto { "ZBglIAEoCzItLm14YWNjZXNzX2dhdGV3YXkudjEuUXVlcnlBY3RpdmVBbGFy", "bXNDb21tYW5kSAASXwohYWNrbm93bGVkZ2VfYWxhcm1fYnlfbmFtZV9jb21t", "YW5kGCYgASgLMjIubXhhY2Nlc3NfZ2F0ZXdheS52MS5BY2tub3dsZWRnZUFs", - "YXJtQnlOYW1lQ29tbWFuZEgAEjAKBHBpbmcYZCABKAsyIC5teGFjY2Vzc19n", - "YXRld2F5LnYxLlBpbmdDb21tYW5kSAASSAoRZ2V0X3Nlc3Npb25fc3RhdGUY", - "ZSABKAsyKy5teGFjY2Vzc19nYXRld2F5LnYxLkdldFNlc3Npb25TdGF0ZUNv", - "bW1hbmRIABJECg9nZXRfd29ya2VyX2luZm8YZiABKAsyKS5teGFjY2Vzc19n", - "YXRld2F5LnYxLkdldFdvcmtlckluZm9Db21tYW5kSAASPwoMZHJhaW5fZXZl", - "bnRzGGcgASgLMicubXhhY2Nlc3NfZ2F0ZXdheS52MS5EcmFpbkV2ZW50c0Nv", - "bW1hbmRIABJFCg9zaHV0ZG93bl93b3JrZXIYaCABKAsyKi5teGFjY2Vzc19n", - "YXRld2F5LnYxLlNodXRkb3duV29ya2VyQ29tbWFuZEgAQgkKB3BheWxvYWQi", - "JgoPUmVnaXN0ZXJDb21tYW5kEhMKC2NsaWVudF9uYW1lGAEgASgJIioKEVVu", - "cmVnaXN0ZXJDb21tYW5kEhUKDXNlcnZlcl9oYW5kbGUYASABKAUiQAoOQWRk", - "SXRlbUNvbW1hbmQSFQoNc2VydmVyX2hhbmRsZRgBIAEoBRIXCg9pdGVtX2Rl", - "ZmluaXRpb24YAiABKAkiVwoPQWRkSXRlbTJDb21tYW5kEhUKDXNlcnZlcl9o", - "YW5kbGUYASABKAUSFwoPaXRlbV9kZWZpbml0aW9uGAIgASgJEhQKDGl0ZW1f", - "Y29udGV4dBgDIAEoCSI/ChFSZW1vdmVJdGVtQ29tbWFuZBIVCg1zZXJ2ZXJf", - "aGFuZGxlGAEgASgFEhMKC2l0ZW1faGFuZGxlGAIgASgFIjsKDUFkdmlzZUNv", - "bW1hbmQSFQoNc2VydmVyX2hhbmRsZRgBIAEoBRITCgtpdGVtX2hhbmRsZRgC", - "IAEoBSI9Cg9VbkFkdmlzZUNvbW1hbmQSFQoNc2VydmVyX2hhbmRsZRgBIAEo", - "BRITCgtpdGVtX2hhbmRsZRgCIAEoBSJGChhBZHZpc2VTdXBlcnZpc29yeUNv", - "bW1hbmQSFQoNc2VydmVyX2hhbmRsZRgBIAEoBRITCgtpdGVtX2hhbmRsZRgC", - "IAEoBSJeChZBZGRCdWZmZXJlZEl0ZW1Db21tYW5kEhUKDXNlcnZlcl9oYW5k", - "bGUYASABKAUSFwoPaXRlbV9kZWZpbml0aW9uGAIgASgJEhQKDGl0ZW1fY29u", - "dGV4dBgDIAEoCSJfCiBTZXRCdWZmZXJlZFVwZGF0ZUludGVydmFsQ29tbWFu", - "ZBIVCg1zZXJ2ZXJfaGFuZGxlGAEgASgFEiQKHHVwZGF0ZV9pbnRlcnZhbF9t", - "aWxsaXNlY29uZHMYAiABKAUiPAoOU3VzcGVuZENvbW1hbmQSFQoNc2VydmVy", - "X2hhbmRsZRgBIAEoBRITCgtpdGVtX2hhbmRsZRgCIAEoBSI9Cg9BY3RpdmF0", - "ZUNvbW1hbmQSFQoNc2VydmVyX2hhbmRsZRgBIAEoBRITCgtpdGVtX2hhbmRs", - "ZRgCIAEoBSJ4CgxXcml0ZUNvbW1hbmQSFQoNc2VydmVyX2hhbmRsZRgBIAEo", - "BRITCgtpdGVtX2hhbmRsZRgCIAEoBRIrCgV2YWx1ZRgDIAEoCzIcLm14YWNj", - "ZXNzX2dhdGV3YXkudjEuTXhWYWx1ZRIPCgd1c2VyX2lkGAQgASgFIrABCg1X", - "cml0ZTJDb21tYW5kEhUKDXNlcnZlcl9oYW5kbGUYASABKAUSEwoLaXRlbV9o", - "YW5kbGUYAiABKAUSKwoFdmFsdWUYAyABKAsyHC5teGFjY2Vzc19nYXRld2F5", - "LnYxLk14VmFsdWUSNQoPdGltZXN0YW1wX3ZhbHVlGAQgASgLMhwubXhhY2Nl", - "c3NfZ2F0ZXdheS52MS5NeFZhbHVlEg8KB3VzZXJfaWQYBSABKAUioQEKE1dy", - "aXRlU2VjdXJlZENvbW1hbmQSFQoNc2VydmVyX2hhbmRsZRgBIAEoBRITCgtp", - "dGVtX2hhbmRsZRgCIAEoBRIXCg9jdXJyZW50X3VzZXJfaWQYAyABKAUSGAoQ", - "dmVyaWZpZXJfdXNlcl9pZBgEIAEoBRIrCgV2YWx1ZRgFIAEoCzIcLm14YWNj", - "ZXNzX2dhdGV3YXkudjEuTXhWYWx1ZSLZAQoUV3JpdGVTZWN1cmVkMkNvbW1h", - "bmQSFQoNc2VydmVyX2hhbmRsZRgBIAEoBRITCgtpdGVtX2hhbmRsZRgCIAEo", - "BRIXCg9jdXJyZW50X3VzZXJfaWQYAyABKAUSGAoQdmVyaWZpZXJfdXNlcl9p", - "ZBgEIAEoBRIrCgV2YWx1ZRgFIAEoCzIcLm14YWNjZXNzX2dhdGV3YXkudjEu", - "TXhWYWx1ZRI1Cg90aW1lc3RhbXBfdmFsdWUYBiABKAsyHC5teGFjY2Vzc19n", - "YXRld2F5LnYxLk14VmFsdWUiYwoXQXV0aGVudGljYXRlVXNlckNvbW1hbmQS", - "FQoNc2VydmVyX2hhbmRsZRgBIAEoBRITCgt2ZXJpZnlfdXNlchgCIAEoCRIc", - "ChR2ZXJpZnlfdXNlcl9wYXNzd29yZBgDIAEoCSJHChhBcmNoZXN0ckFVc2Vy", - "VG9JZENvbW1hbmQSFQoNc2VydmVyX2hhbmRsZRgBIAEoBRIUCgx1c2VyX2lk", - "X2d1aWQYAiABKAkiQgoSQWRkSXRlbUJ1bGtDb21tYW5kEhUKDXNlcnZlcl9o", - "YW5kbGUYASABKAUSFQoNdGFnX2FkZHJlc3NlcxgCIAMoCSJEChVBZHZpc2VJ", - "dGVtQnVsa0NvbW1hbmQSFQoNc2VydmVyX2hhbmRsZRgBIAEoBRIUCgxpdGVt", - "X2hhbmRsZXMYAiADKAUiRAoVUmVtb3ZlSXRlbUJ1bGtDb21tYW5kEhUKDXNl", - "cnZlcl9oYW5kbGUYASABKAUSFAoMaXRlbV9oYW5kbGVzGAIgAygFIkYKF1Vu", - "QWR2aXNlSXRlbUJ1bGtDb21tYW5kEhUKDXNlcnZlcl9oYW5kbGUYASABKAUS", - "FAoMaXRlbV9oYW5kbGVzGAIgAygFIkQKFFN1YnNjcmliZUJ1bGtDb21tYW5k", - "EhUKDXNlcnZlcl9oYW5kbGUYASABKAUSFQoNdGFnX2FkZHJlc3NlcxgCIAMo", - "CSI5ChZTdWJzY3JpYmVBbGFybXNDb21tYW5kEh8KF3N1YnNjcmlwdGlvbl9l", - "eHByZXNzaW9uGAEgASgJIhoKGFVuc3Vic2NyaWJlQWxhcm1zQ29tbWFuZCKh", - "AQoXQWNrbm93bGVkZ2VBbGFybUNvbW1hbmQSEgoKYWxhcm1fZ3VpZBgBIAEo", - "CRIPCgdjb21tZW50GAIgASgJEhUKDW9wZXJhdG9yX3VzZXIYAyABKAkSFQoN", - "b3BlcmF0b3Jfbm9kZRgEIAEoCRIXCg9vcGVyYXRvcl9kb21haW4YBSABKAkS", - "GgoSb3BlcmF0b3JfZnVsbF9uYW1lGAYgASgJIjcKGFF1ZXJ5QWN0aXZlQWxh", - "cm1zQ29tbWFuZBIbChNhbGFybV9maWx0ZXJfcHJlZml4GAEgASgJItIBCh1B", - "Y2tub3dsZWRnZUFsYXJtQnlOYW1lQ29tbWFuZBISCgphbGFybV9uYW1lGAEg", - "ASgJEhUKDXByb3ZpZGVyX25hbWUYAiABKAkSEgoKZ3JvdXBfbmFtZRgDIAEo", - "CRIPCgdjb21tZW50GAQgASgJEhUKDW9wZXJhdG9yX3VzZXIYBSABKAkSFQoN", - "b3BlcmF0b3Jfbm9kZRgGIAEoCRIXCg9vcGVyYXRvcl9kb21haW4YByABKAkS", - "GgoSb3BlcmF0b3JfZnVsbF9uYW1lGAggASgJIkUKFlVuc3Vic2NyaWJlQnVs", - "a0NvbW1hbmQSFQoNc2VydmVyX2hhbmRsZRgBIAEoBRIUCgxpdGVtX2hhbmRs", - "ZXMYAiADKAUiHgoLUGluZ0NvbW1hbmQSDwoHbWVzc2FnZRgBIAEoCSIYChZH", - "ZXRTZXNzaW9uU3RhdGVDb21tYW5kIhYKFEdldFdvcmtlckluZm9Db21tYW5k", - "IigKEkRyYWluRXZlbnRzQ29tbWFuZBISCgptYXhfZXZlbnRzGAEgASgNIkgK", - "FVNodXRkb3duV29ya2VyQ29tbWFuZBIvCgxncmFjZV9wZXJpb2QYASABKAsy", - "GS5nb29nbGUucHJvdG9idWYuRHVyYXRpb24izwwKDk14Q29tbWFuZFJlcGx5", - "EhIKCnNlc3Npb25faWQYASABKAkSFgoOY29ycmVsYXRpb25faWQYAiABKAkS", - "MAoEa2luZBgDIAEoDjIiLm14YWNjZXNzX2dhdGV3YXkudjEuTXhDb21tYW5k", - "S2luZBI8Cg9wcm90b2NvbF9zdGF0dXMYBCABKAsyIy5teGFjY2Vzc19nYXRl", - "d2F5LnYxLlByb3RvY29sU3RhdHVzEhQKB2hyZXN1bHQYBSABKAVIAYgBARIy", - "CgxyZXR1cm5fdmFsdWUYBiABKAsyHC5teGFjY2Vzc19nYXRld2F5LnYxLk14", - "VmFsdWUSNAoIc3RhdHVzZXMYByADKAsyIi5teGFjY2Vzc19nYXRld2F5LnYx", - "Lk14U3RhdHVzUHJveHkSGgoSZGlhZ25vc3RpY19tZXNzYWdlGAggASgJEjYK", - "CHJlZ2lzdGVyGBQgASgLMiIubXhhY2Nlc3NfZ2F0ZXdheS52MS5SZWdpc3Rl", - "clJlcGx5SAASNQoIYWRkX2l0ZW0YFSABKAsyIS5teGFjY2Vzc19nYXRld2F5", - "LnYxLkFkZEl0ZW1SZXBseUgAEjcKCWFkZF9pdGVtMhgWIAEoCzIiLm14YWNj", - "ZXNzX2dhdGV3YXkudjEuQWRkSXRlbTJSZXBseUgAEkYKEWFkZF9idWZmZXJl", - "ZF9pdGVtGBcgASgLMikubXhhY2Nlc3NfZ2F0ZXdheS52MS5BZGRCdWZmZXJl", - "ZEl0ZW1SZXBseUgAEjQKB3N1c3BlbmQYGCABKAsyIS5teGFjY2Vzc19nYXRl", - "d2F5LnYxLlN1c3BlbmRSZXBseUgAEjYKCGFjdGl2YXRlGBkgASgLMiIubXhh", - "Y2Nlc3NfZ2F0ZXdheS52MS5BY3RpdmF0ZVJlcGx5SAASRwoRYXV0aGVudGlj", - "YXRlX3VzZXIYGiABKAsyKi5teGFjY2Vzc19nYXRld2F5LnYxLkF1dGhlbnRp", - "Y2F0ZVVzZXJSZXBseUgAEksKFGFyY2hlc3RyYV91c2VyX3RvX2lkGBsgASgL", - "MisubXhhY2Nlc3NfZ2F0ZXdheS52MS5BcmNoZXN0ckFVc2VyVG9JZFJlcGx5", - "SAASQAoNYWRkX2l0ZW1fYnVsaxgcIAEoCzInLm14YWNjZXNzX2dhdGV3YXku", - "djEuQnVsa1N1YnNjcmliZVJlcGx5SAASQwoQYWR2aXNlX2l0ZW1fYnVsaxgd", - "IAEoCzInLm14YWNjZXNzX2dhdGV3YXkudjEuQnVsa1N1YnNjcmliZVJlcGx5", - "SAASQwoQcmVtb3ZlX2l0ZW1fYnVsaxgeIAEoCzInLm14YWNjZXNzX2dhdGV3", - "YXkudjEuQnVsa1N1YnNjcmliZVJlcGx5SAASRgoTdW5fYWR2aXNlX2l0ZW1f", - "YnVsaxgfIAEoCzInLm14YWNjZXNzX2dhdGV3YXkudjEuQnVsa1N1YnNjcmli", - "ZVJlcGx5SAASQQoOc3Vic2NyaWJlX2J1bGsYICABKAsyJy5teGFjY2Vzc19n", - "YXRld2F5LnYxLkJ1bGtTdWJzY3JpYmVSZXBseUgAEkMKEHVuc3Vic2NyaWJl", - "X2J1bGsYISABKAsyJy5teGFjY2Vzc19nYXRld2F5LnYxLkJ1bGtTdWJzY3Jp", - "YmVSZXBseUgAEk4KEWFja25vd2xlZGdlX2FsYXJtGCIgASgLMjEubXhhY2Nl", - "c3NfZ2F0ZXdheS52MS5BY2tub3dsZWRnZUFsYXJtUmVwbHlQYXlsb2FkSAAS", - "UQoTcXVlcnlfYWN0aXZlX2FsYXJtcxgjIAEoCzIyLm14YWNjZXNzX2dhdGV3", - "YXkudjEuUXVlcnlBY3RpdmVBbGFybXNSZXBseVBheWxvYWRIABI/Cg1zZXNz", - "aW9uX3N0YXRlGGQgASgLMiYubXhhY2Nlc3NfZ2F0ZXdheS52MS5TZXNzaW9u", - "U3RhdGVSZXBseUgAEjsKC3dvcmtlcl9pbmZvGGUgASgLMiQubXhhY2Nlc3Nf", - "Z2F0ZXdheS52MS5Xb3JrZXJJbmZvUmVwbHlIABI9CgxkcmFpbl9ldmVudHMY", - "ZiABKAsyJS5teGFjY2Vzc19nYXRld2F5LnYxLkRyYWluRXZlbnRzUmVwbHlI", - "AEIJCgdwYXlsb2FkQgoKCF9ocmVzdWx0IiYKDVJlZ2lzdGVyUmVwbHkSFQoN", - "c2VydmVyX2hhbmRsZRgBIAEoBSIjCgxBZGRJdGVtUmVwbHkSEwoLaXRlbV9o", - "YW5kbGUYASABKAUiJAoNQWRkSXRlbTJSZXBseRITCgtpdGVtX2hhbmRsZRgB", - "IAEoBSIrChRBZGRCdWZmZXJlZEl0ZW1SZXBseRITCgtpdGVtX2hhbmRsZRgB", - "IAEoBSJCCgxTdXNwZW5kUmVwbHkSMgoGc3RhdHVzGAEgASgLMiIubXhhY2Nl", - "c3NfZ2F0ZXdheS52MS5NeFN0YXR1c1Byb3h5IkMKDUFjdGl2YXRlUmVwbHkS", - "MgoGc3RhdHVzGAEgASgLMiIubXhhY2Nlc3NfZ2F0ZXdheS52MS5NeFN0YXR1", - "c1Byb3h5IigKFUF1dGhlbnRpY2F0ZVVzZXJSZXBseRIPCgd1c2VyX2lkGAEg", - "ASgFIikKFkFyY2hlc3RyQVVzZXJUb0lkUmVwbHkSDwoHdXNlcl9pZBgBIAEo", - "BSKBAQoPU3Vic2NyaWJlUmVzdWx0EhUKDXNlcnZlcl9oYW5kbGUYASABKAUS", - "EwoLdGFnX2FkZHJlc3MYAiABKAkSEwoLaXRlbV9oYW5kbGUYAyABKAUSFgoO", - "d2FzX3N1Y2Nlc3NmdWwYBCABKAgSFQoNZXJyb3JfbWVzc2FnZRgFIAEoCSJL", - "ChJCdWxrU3Vic2NyaWJlUmVwbHkSNQoHcmVzdWx0cxgBIAMoCzIkLm14YWNj", - "ZXNzX2dhdGV3YXkudjEuU3Vic2NyaWJlUmVzdWx0IkUKEVNlc3Npb25TdGF0", - "ZVJlcGx5EjAKBXN0YXRlGAEgASgOMiEubXhhY2Nlc3NfZ2F0ZXdheS52MS5T", - "ZXNzaW9uU3RhdGUidQoPV29ya2VySW5mb1JlcGx5EhkKEXdvcmtlcl9wcm9j", - "ZXNzX2lkGAEgASgFEhYKDndvcmtlcl92ZXJzaW9uGAIgASgJEhcKD214YWNj", - "ZXNzX3Byb2dpZBgDIAEoCRIWCg5teGFjY2Vzc19jbHNpZBgEIAEoCSJAChBE", - "cmFpbkV2ZW50c1JlcGx5EiwKBmV2ZW50cxgBIAMoCzIcLm14YWNjZXNzX2dh", - "dGV3YXkudjEuTXhFdmVudCI1ChxBY2tub3dsZWRnZUFsYXJtUmVwbHlQYXls", - "b2FkEhUKDW5hdGl2ZV9zdGF0dXMYASABKAUiXAodUXVlcnlBY3RpdmVBbGFy", - "bXNSZXBseVBheWxvYWQSOwoJc25hcHNob3RzGAEgAygLMigubXhhY2Nlc3Nf", - "Z2F0ZXdheS52MS5BY3RpdmVBbGFybVNuYXBzaG90IucGCgdNeEV2ZW50EjIK", - "BmZhbWlseRgBIAEoDjIiLm14YWNjZXNzX2dhdGV3YXkudjEuTXhFdmVudEZh", - "bWlseRISCgpzZXNzaW9uX2lkGAIgASgJEhUKDXNlcnZlcl9oYW5kbGUYAyAB", - "KAUSEwoLaXRlbV9oYW5kbGUYBCABKAUSKwoFdmFsdWUYBSABKAsyHC5teGFj", - "Y2Vzc19nYXRld2F5LnYxLk14VmFsdWUSDwoHcXVhbGl0eRgGIAEoBRI0ChBz", - "b3VyY2VfdGltZXN0YW1wGAcgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVz", - "dGFtcBI0CghzdGF0dXNlcxgIIAMoCzIiLm14YWNjZXNzX2dhdGV3YXkudjEu", - "TXhTdGF0dXNQcm94eRIXCg93b3JrZXJfc2VxdWVuY2UYCSABKAQSNAoQd29y", - "a2VyX3RpbWVzdGFtcBgKIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3Rh", - "bXASPQoZZ2F0ZXdheV9yZWNlaXZlX3RpbWVzdGFtcBgLIAEoCzIaLmdvb2ds", - "ZS5wcm90b2J1Zi5UaW1lc3RhbXASFAoHaHJlc3VsdBgMIAEoBUgBiAEBEhIK", - "CnJhd19zdGF0dXMYDSABKAkSQAoOb25fZGF0YV9jaGFuZ2UYFCABKAsyJi5t", - "eGFjY2Vzc19nYXRld2F5LnYxLk9uRGF0YUNoYW5nZUV2ZW50SAASRgoRb25f", - "d3JpdGVfY29tcGxldGUYFSABKAsyKS5teGFjY2Vzc19nYXRld2F5LnYxLk9u", - "V3JpdGVDb21wbGV0ZUV2ZW50SAASSQoSb3BlcmF0aW9uX2NvbXBsZXRlGBYg", - "ASgLMisubXhhY2Nlc3NfZ2F0ZXdheS52MS5PcGVyYXRpb25Db21wbGV0ZUV2", - "ZW50SAASUQoXb25fYnVmZmVyZWRfZGF0YV9jaGFuZ2UYFyABKAsyLi5teGFj", - "Y2Vzc19nYXRld2F5LnYxLk9uQnVmZmVyZWREYXRhQ2hhbmdlRXZlbnRIABJK", - "ChNvbl9hbGFybV90cmFuc2l0aW9uGBggASgLMisubXhhY2Nlc3NfZ2F0ZXdh", - "eS52MS5PbkFsYXJtVHJhbnNpdGlvbkV2ZW50SABCBgoEYm9keUIKCghfaHJl", - "c3VsdCITChFPbkRhdGFDaGFuZ2VFdmVudCIWChRPbldyaXRlQ29tcGxldGVF", - "dmVudCIYChZPcGVyYXRpb25Db21wbGV0ZUV2ZW50ItQBChlPbkJ1ZmZlcmVk", - "RGF0YUNoYW5nZUV2ZW50EjIKCWRhdGFfdHlwZRgBIAEoDjIfLm14YWNjZXNz", - "X2dhdGV3YXkudjEuTXhEYXRhVHlwZRI0Cg5xdWFsaXR5X3ZhbHVlcxgCIAEo", - "CzIcLm14YWNjZXNzX2dhdGV3YXkudjEuTXhBcnJheRI2ChB0aW1lc3RhbXBf", - "dmFsdWVzGAMgASgLMhwubXhhY2Nlc3NfZ2F0ZXdheS52MS5NeEFycmF5EhUK", - "DXJhd19kYXRhX3R5cGUYBCABKAUi/QMKFk9uQWxhcm1UcmFuc2l0aW9uRXZl", - "bnQSHAoUYWxhcm1fZnVsbF9yZWZlcmVuY2UYASABKAkSHwoXc291cmNlX29i", - "amVjdF9yZWZlcmVuY2UYAiABKAkSFwoPYWxhcm1fdHlwZV9uYW1lGAMgASgJ", - "EkEKD3RyYW5zaXRpb25fa2luZBgEIAEoDjIoLm14YWNjZXNzX2dhdGV3YXku", - "djEuQWxhcm1UcmFuc2l0aW9uS2luZBIQCghzZXZlcml0eRgFIAEoBRI8Chhv", - "cmlnaW5hbF9yYWlzZV90aW1lc3RhbXAYBiABKAsyGi5nb29nbGUucHJvdG9i", - "dWYuVGltZXN0YW1wEjgKFHRyYW5zaXRpb25fdGltZXN0YW1wGAcgASgLMhou", - "Z29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIVCg1vcGVyYXRvcl91c2VyGAgg", - "ASgJEhgKEG9wZXJhdG9yX2NvbW1lbnQYCSABKAkSEAoIY2F0ZWdvcnkYCiAB", - "KAkSEwoLZGVzY3JpcHRpb24YCyABKAkSMwoNY3VycmVudF92YWx1ZRgMIAEo", - "CzIcLm14YWNjZXNzX2dhdGV3YXkudjEuTXhWYWx1ZRIxCgtsaW1pdF92YWx1", - "ZRgNIAEoCzIcLm14YWNjZXNzX2dhdGV3YXkudjEuTXhWYWx1ZSL9AwoTQWN0", - "aXZlQWxhcm1TbmFwc2hvdBIcChRhbGFybV9mdWxsX3JlZmVyZW5jZRgBIAEo", - "CRIfChdzb3VyY2Vfb2JqZWN0X3JlZmVyZW5jZRgCIAEoCRIXCg9hbGFybV90", - "eXBlX25hbWUYAyABKAkSEAoIc2V2ZXJpdHkYBCABKAUSPAoYb3JpZ2luYWxf", - "cmFpc2VfdGltZXN0YW1wGAUgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVz", - "dGFtcBI/Cg1jdXJyZW50X3N0YXRlGAYgASgOMigubXhhY2Nlc3NfZ2F0ZXdh", - "eS52MS5BbGFybUNvbmRpdGlvblN0YXRlEhAKCGNhdGVnb3J5GAcgASgJEhMK", - "C2Rlc2NyaXB0aW9uGAggASgJEj0KGWxhc3RfdHJhbnNpdGlvbl90aW1lc3Rh", - "bXAYCSABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEhUKDW9wZXJh", - "dG9yX3VzZXIYCiABKAkSGAoQb3BlcmF0b3JfY29tbWVudBgLIAEoCRIzCg1j", - "dXJyZW50X3ZhbHVlGAwgASgLMhwubXhhY2Nlc3NfZ2F0ZXdheS52MS5NeFZh", - "bHVlEjEKC2xpbWl0X3ZhbHVlGA0gASgLMhwubXhhY2Nlc3NfZ2F0ZXdheS52", - "MS5NeFZhbHVlIpIBChdBY2tub3dsZWRnZUFsYXJtUmVxdWVzdBISCgpzZXNz", - "aW9uX2lkGAEgASgJEh0KFWNsaWVudF9jb3JyZWxhdGlvbl9pZBgCIAEoCRIc", - "ChRhbGFybV9mdWxsX3JlZmVyZW5jZRgDIAEoCRIPCgdjb21tZW50GAQgASgJ", - "EhUKDW9wZXJhdG9yX3VzZXIYBSABKAki8wEKFUFja25vd2xlZGdlQWxhcm1S", - "ZXBseRISCgpzZXNzaW9uX2lkGAEgASgJEhYKDmNvcnJlbGF0aW9uX2lkGAIg", - "ASgJEjwKD3Byb3RvY29sX3N0YXR1cxgDIAEoCzIjLm14YWNjZXNzX2dhdGV3", - "YXkudjEuUHJvdG9jb2xTdGF0dXMSFAoHaHJlc3VsdBgEIAEoBUgAiAEBEjIK", - "BnN0YXR1cxgFIAEoCzIiLm14YWNjZXNzX2dhdGV3YXkudjEuTXhTdGF0dXNQ", - "cm94eRIaChJkaWFnbm9zdGljX21lc3NhZ2UYBiABKAlCCgoIX2hyZXN1bHQi", - "agoYUXVlcnlBY3RpdmVBbGFybXNSZXF1ZXN0EhIKCnNlc3Npb25faWQYASAB", - "KAkSHQoVY2xpZW50X2NvcnJlbGF0aW9uX2lkGAIgASgJEhsKE2FsYXJtX2Zp", - "bHRlcl9wcmVmaXgYAyABKAki6wEKDU14U3RhdHVzUHJveHkSDwoHc3VjY2Vz", - "cxgBIAEoBRI3CghjYXRlZ29yeRgCIAEoDjIlLm14YWNjZXNzX2dhdGV3YXku", - "djEuTXhTdGF0dXNDYXRlZ29yeRI4CgtkZXRlY3RlZF9ieRgDIAEoDjIjLm14", - "YWNjZXNzX2dhdGV3YXkudjEuTXhTdGF0dXNTb3VyY2USDgoGZGV0YWlsGAQg", - "ASgFEhQKDHJhd19jYXRlZ29yeRgFIAEoBRIXCg9yYXdfZGV0ZWN0ZWRfYnkY", - "BiABKAUSFwoPZGlhZ25vc3RpY190ZXh0GAcgASgJIqcDCgdNeFZhbHVlEjIK", - "CWRhdGFfdHlwZRgBIAEoDjIfLm14YWNjZXNzX2dhdGV3YXkudjEuTXhEYXRh", - "VHlwZRIUCgx2YXJpYW50X3R5cGUYAiABKAkSDwoHaXNfbnVsbBgDIAEoCBIW", - "Cg5yYXdfZGlhZ25vc3RpYxgEIAEoCRIVCg1yYXdfZGF0YV90eXBlGAUgASgF", - "EhQKCmJvb2xfdmFsdWUYCiABKAhIABIVCgtpbnQzMl92YWx1ZRgLIAEoBUgA", - "EhUKC2ludDY0X3ZhbHVlGAwgASgDSAASFQoLZmxvYXRfdmFsdWUYDSABKAJI", - "ABIWCgxkb3VibGVfdmFsdWUYDiABKAFIABIWCgxzdHJpbmdfdmFsdWUYDyAB", - "KAlIABI1Cg90aW1lc3RhbXBfdmFsdWUYECABKAsyGi5nb29nbGUucHJvdG9i", - "dWYuVGltZXN0YW1wSAASMwoLYXJyYXlfdmFsdWUYESABKAsyHC5teGFjY2Vz", - "c19nYXRld2F5LnYxLk14QXJyYXlIABITCglyYXdfdmFsdWUYEiABKAxIAEIG", - "CgRraW5kIv4ECgdNeEFycmF5EjoKEWVsZW1lbnRfZGF0YV90eXBlGAEgASgO", - "Mh8ubXhhY2Nlc3NfZ2F0ZXdheS52MS5NeERhdGFUeXBlEhQKDHZhcmlhbnRf", - "dHlwZRgCIAEoCRISCgpkaW1lbnNpb25zGAMgAygNEhYKDnJhd19kaWFnbm9z", - "dGljGAQgASgJEh0KFXJhd19lbGVtZW50X2RhdGFfdHlwZRgFIAEoBRI1Cgti", - "b29sX3ZhbHVlcxgKIAEoCzIeLm14YWNjZXNzX2dhdGV3YXkudjEuQm9vbEFy", - "cmF5SAASNwoMaW50MzJfdmFsdWVzGAsgASgLMh8ubXhhY2Nlc3NfZ2F0ZXdh", - "eS52MS5JbnQzMkFycmF5SAASNwoMaW50NjRfdmFsdWVzGAwgASgLMh8ubXhh", - "Y2Nlc3NfZ2F0ZXdheS52MS5JbnQ2NEFycmF5SAASNwoMZmxvYXRfdmFsdWVz", - "GA0gASgLMh8ubXhhY2Nlc3NfZ2F0ZXdheS52MS5GbG9hdEFycmF5SAASOQoN", - "ZG91YmxlX3ZhbHVlcxgOIAEoCzIgLm14YWNjZXNzX2dhdGV3YXkudjEuRG91", - "YmxlQXJyYXlIABI5Cg1zdHJpbmdfdmFsdWVzGA8gASgLMiAubXhhY2Nlc3Nf", - "Z2F0ZXdheS52MS5TdHJpbmdBcnJheUgAEj8KEHRpbWVzdGFtcF92YWx1ZXMY", - "ECABKAsyIy5teGFjY2Vzc19nYXRld2F5LnYxLlRpbWVzdGFtcEFycmF5SAAS", - "MwoKcmF3X3ZhbHVlcxgRIAEoCzIdLm14YWNjZXNzX2dhdGV3YXkudjEuUmF3", - "QXJyYXlIAEIICgZ2YWx1ZXMiGwoJQm9vbEFycmF5Eg4KBnZhbHVlcxgBIAMo", - "CCIcCgpJbnQzMkFycmF5Eg4KBnZhbHVlcxgBIAMoBSIcCgpJbnQ2NEFycmF5", - "Eg4KBnZhbHVlcxgBIAMoAyIcCgpGbG9hdEFycmF5Eg4KBnZhbHVlcxgBIAMo", - "AiIdCgtEb3VibGVBcnJheRIOCgZ2YWx1ZXMYASADKAEiHQoLU3RyaW5nQXJy", - "YXkSDgoGdmFsdWVzGAEgAygJIjwKDlRpbWVzdGFtcEFycmF5EioKBnZhbHVl", - "cxgBIAMoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAiGgoIUmF3QXJy", - "YXkSDgoGdmFsdWVzGAEgAygMIlgKDlByb3RvY29sU3RhdHVzEjUKBGNvZGUY", - "ASABKA4yJy5teGFjY2Vzc19nYXRld2F5LnYxLlByb3RvY29sU3RhdHVzQ29k", - "ZRIPCgdtZXNzYWdlGAIgASgJKu4JCg1NeENvbW1hbmRLaW5kEh8KG01YX0NP", - "TU1BTkRfS0lORF9VTlNQRUNJRklFRBAAEhwKGE1YX0NPTU1BTkRfS0lORF9S", - "RUdJU1RFUhABEh4KGk1YX0NPTU1BTkRfS0lORF9VTlJFR0lTVEVSEAISHAoY", - "TVhfQ09NTUFORF9LSU5EX0FERF9JVEVNEAMSHQoZTVhfQ09NTUFORF9LSU5E", - "X0FERF9JVEVNMhAEEh8KG01YX0NPTU1BTkRfS0lORF9SRU1PVkVfSVRFTRAF", - "EhoKFk1YX0NPTU1BTkRfS0lORF9BRFZJU0UQBhIdChlNWF9DT01NQU5EX0tJ", - "TkRfVU5fQURWSVNFEAcSJgoiTVhfQ09NTUFORF9LSU5EX0FEVklTRV9TVVBF", - "UlZJU09SWRAIEiUKIU1YX0NPTU1BTkRfS0lORF9BRERfQlVGRkVSRURfSVRF", - "TRAJEjAKLE1YX0NPTU1BTkRfS0lORF9TRVRfQlVGRkVSRURfVVBEQVRFX0lO", - "VEVSVkFMEAoSGwoXTVhfQ09NTUFORF9LSU5EX1NVU1BFTkQQCxIcChhNWF9D", - "T01NQU5EX0tJTkRfQUNUSVZBVEUQDBIZChVNWF9DT01NQU5EX0tJTkRfV1JJ", - "VEUQDRIaChZNWF9DT01NQU5EX0tJTkRfV1JJVEUyEA4SIQodTVhfQ09NTUFO", - "RF9LSU5EX1dSSVRFX1NFQ1VSRUQQDxIiCh5NWF9DT01NQU5EX0tJTkRfV1JJ", - "VEVfU0VDVVJFRDIQEBIlCiFNWF9DT01NQU5EX0tJTkRfQVVUSEVOVElDQVRF", - "X1VTRVIQERIoCiRNWF9DT01NQU5EX0tJTkRfQVJDSEVTVFJBX1VTRVJfVE9f", - "SUQQEhIhCh1NWF9DT01NQU5EX0tJTkRfQUREX0lURU1fQlVMSxATEiQKIE1Y", - "X0NPTU1BTkRfS0lORF9BRFZJU0VfSVRFTV9CVUxLEBQSJAogTVhfQ09NTUFO", - "RF9LSU5EX1JFTU9WRV9JVEVNX0JVTEsQFRInCiNNWF9DT01NQU5EX0tJTkRf", - "VU5fQURWSVNFX0lURU1fQlVMSxAWEiIKHk1YX0NPTU1BTkRfS0lORF9TVUJT", - "Q1JJQkVfQlVMSxAXEiQKIE1YX0NPTU1BTkRfS0lORF9VTlNVQlNDUklCRV9C", - "VUxLEBgSJAogTVhfQ09NTUFORF9LSU5EX1NVQlNDUklCRV9BTEFSTVMQGRIm", - "CiJNWF9DT01NQU5EX0tJTkRfVU5TVUJTQ1JJQkVfQUxBUk1TEBoSJQohTVhf", - "Q09NTUFORF9LSU5EX0FDS05PV0xFREdFX0FMQVJNEBsSJwojTVhfQ09NTUFO", - "RF9LSU5EX1FVRVJZX0FDVElWRV9BTEFSTVMQHBItCilNWF9DT01NQU5EX0tJ", - "TkRfQUNLTk9XTEVER0VfQUxBUk1fQllfTkFNRRAdEhgKFE1YX0NPTU1BTkRf", - "S0lORF9QSU5HEGQSJQohTVhfQ09NTUFORF9LSU5EX0dFVF9TRVNTSU9OX1NU", - "QVRFEGUSIwofTVhfQ09NTUFORF9LSU5EX0dFVF9XT1JLRVJfSU5GTxBmEiAK", - "HE1YX0NPTU1BTkRfS0lORF9EUkFJTl9FVkVOVFMQZxIjCh9NWF9DT01NQU5E", - "X0tJTkRfU0hVVERPV05fV09SS0VSEGgq+QEKDU14RXZlbnRGYW1pbHkSHwob", - "TVhfRVZFTlRfRkFNSUxZX1VOU1BFQ0lGSUVEEAASIgoeTVhfRVZFTlRfRkFN", - "SUxZX09OX0RBVEFfQ0hBTkdFEAESJQohTVhfRVZFTlRfRkFNSUxZX09OX1dS", - "SVRFX0NPTVBMRVRFEAISJgoiTVhfRVZFTlRfRkFNSUxZX09QRVJBVElPTl9D", - "T01QTEVURRADEisKJ01YX0VWRU5UX0ZBTUlMWV9PTl9CVUZGRVJFRF9EQVRB", - "X0NIQU5HRRAEEicKI01YX0VWRU5UX0ZBTUlMWV9PTl9BTEFSTV9UUkFOU0lU", - "SU9OEAUqygEKE0FsYXJtVHJhbnNpdGlvbktpbmQSJQohQUxBUk1fVFJBTlNJ", - "VElPTl9LSU5EX1VOU1BFQ0lGSUVEEAASHwobQUxBUk1fVFJBTlNJVElPTl9L", - "SU5EX1JBSVNFEAESJQohQUxBUk1fVFJBTlNJVElPTl9LSU5EX0FDS05PV0xF", - "REdFEAISHwobQUxBUk1fVFJBTlNJVElPTl9LSU5EX0NMRUFSEAMSIwofQUxB", - "Uk1fVFJBTlNJVElPTl9LSU5EX1JFVFJJR0dFUhAEKqoBChNBbGFybUNvbmRp", - "dGlvblN0YXRlEiUKIUFMQVJNX0NPTkRJVElPTl9TVEFURV9VTlNQRUNJRklF", - "RBAAEiAKHEFMQVJNX0NPTkRJVElPTl9TVEFURV9BQ1RJVkUQARImCiJBTEFS", - "TV9DT05ESVRJT05fU1RBVEVfQUNUSVZFX0FDS0VEEAISIgoeQUxBUk1fQ09O", - "RElUSU9OX1NUQVRFX0lOQUNUSVZFEAMqpQMKEE14U3RhdHVzQ2F0ZWdvcnkS", - "IgoeTVhfU1RBVFVTX0NBVEVHT1JZX1VOU1BFQ0lGSUVEEAASHgoaTVhfU1RB", - "VFVTX0NBVEVHT1JZX1VOS05PV04QARIZChVNWF9TVEFUVVNfQ0FURUdPUllf", - "T0sQAhIeChpNWF9TVEFUVVNfQ0FURUdPUllfUEVORElORxADEh4KGk1YX1NU", - "QVRVU19DQVRFR09SWV9XQVJOSU5HEAQSKgomTVhfU1RBVFVTX0NBVEVHT1JZ", - "X0NPTU1VTklDQVRJT05fRVJST1IQBRIqCiZNWF9TVEFUVVNfQ0FURUdPUllf", - "Q09ORklHVVJBVElPTl9FUlJPUhAGEigKJE1YX1NUQVRVU19DQVRFR09SWV9P", - "UEVSQVRJT05BTF9FUlJPUhAHEiUKIU1YX1NUQVRVU19DQVRFR09SWV9TRUNV", - "UklUWV9FUlJPUhAIEiUKIU1YX1NUQVRVU19DQVRFR09SWV9TT0ZUV0FSRV9F", - "UlJPUhAJEiIKHk1YX1NUQVRVU19DQVRFR09SWV9PVEhFUl9FUlJPUhAKKsoC", - "Cg5NeFN0YXR1c1NvdXJjZRIgChxNWF9TVEFUVVNfU09VUkNFX1VOU1BFQ0lG", - "SUVEEAASHAoYTVhfU1RBVFVTX1NPVVJDRV9VTktOT1dOEAESIwofTVhfU1RB", - "VFVTX1NPVVJDRV9SRVFVRVNUSU5HX0xNWBACEiMKH01YX1NUQVRVU19TT1VS", - "Q0VfUkVTUE9ORElOR19MTVgQAxIjCh9NWF9TVEFUVVNfU09VUkNFX1JFUVVF", - "U1RJTkdfTk1YEAQSIwofTVhfU1RBVFVTX1NPVVJDRV9SRVNQT05ESU5HX05N", - "WBAFEjEKLU1YX1NUQVRVU19TT1VSQ0VfUkVRVUVTVElOR19BVVRPTUFUSU9O", - "X09CSkVDVBAGEjEKLU1YX1NUQVRVU19TT1VSQ0VfUkVTUE9ORElOR19BVVRP", - "TUFUSU9OX09CSkVDVBAHKt0ECgpNeERhdGFUeXBlEhwKGE1YX0RBVEFfVFlQ", - "RV9VTlNQRUNJRklFRBAAEhgKFE1YX0RBVEFfVFlQRV9VTktOT1dOEAESGAoU", - "TVhfREFUQV9UWVBFX05PX0RBVEEQAhIYChRNWF9EQVRBX1RZUEVfQk9PTEVB", - "ThADEhgKFE1YX0RBVEFfVFlQRV9JTlRFR0VSEAQSFgoSTVhfREFUQV9UWVBF", - "X0ZMT0FUEAUSFwoTTVhfREFUQV9UWVBFX0RPVUJMRRAGEhcKE01YX0RBVEFf", - "VFlQRV9TVFJJTkcQBxIVChFNWF9EQVRBX1RZUEVfVElNRRAIEh0KGU1YX0RB", - "VEFfVFlQRV9FTEFQU0VEX1RJTUUQCRIfChtNWF9EQVRBX1RZUEVfUkVGRVJF", - "TkNFX1RZUEUQChIcChhNWF9EQVRBX1RZUEVfU1RBVFVTX1RZUEUQCxIVChFN", - "WF9EQVRBX1RZUEVfRU5VTRAMEi0KKU1YX0RBVEFfVFlQRV9TRUNVUklUWV9D", - "TEFTU0lGSUNBVElPTl9FTlVNEA0SIgoeTVhfREFUQV9UWVBFX0RBVEFfUVVB", - "TElUWV9UWVBFEA4SHwobTVhfREFUQV9UWVBFX1FVQUxJRklFRF9FTlVNEA8S", - "IQodTVhfREFUQV9UWVBFX1FVQUxJRklFRF9TVFJVQ1QQEBIpCiVNWF9EQVRB", - "X1RZUEVfSU5URVJOQVRJT05BTElaRURfU1RSSU5HEBESGwoXTVhfREFUQV9U", - "WVBFX0JJR19TVFJJTkcQEhIUChBNWF9EQVRBX1RZUEVfRU5EEBMqowMKElBy", - "b3RvY29sU3RhdHVzQ29kZRIkCiBQUk9UT0NPTF9TVEFUVVNfQ09ERV9VTlNQ", - "RUNJRklFRBAAEhsKF1BST1RPQ09MX1NUQVRVU19DT0RFX09LEAESKAokUFJP", - "VE9DT0xfU1RBVFVTX0NPREVfSU5WQUxJRF9SRVFVRVNUEAISKgomUFJPVE9D", - "T0xfU1RBVFVTX0NPREVfU0VTU0lPTl9OT1RfRk9VTkQQAxIqCiZQUk9UT0NP", - "TF9TVEFUVVNfQ09ERV9TRVNTSU9OX05PVF9SRUFEWRAEEisKJ1BST1RPQ09M", - "X1NUQVRVU19DT0RFX1dPUktFUl9VTkFWQUlMQUJMRRAFEiAKHFBST1RPQ09M", - "X1NUQVRVU19DT0RFX1RJTUVPVVQQBhIhCh1QUk9UT0NPTF9TVEFUVVNfQ09E", - "RV9DQU5DRUxFRBAHEisKJ1BST1RPQ09MX1NUQVRVU19DT0RFX1BST1RPQ09M", - "X1ZJT0xBVElPThAIEikKJVBST1RPQ09MX1NUQVRVU19DT0RFX01YQUNDRVNT", - "X0ZBSUxVUkUQCSq/AgoMU2Vzc2lvblN0YXRlEh0KGVNFU1NJT05fU1RBVEVf", - "VU5TUEVDSUZJRUQQABIaChZTRVNTSU9OX1NUQVRFX0NSRUFUSU5HEAESIQod", - "U0VTU0lPTl9TVEFURV9TVEFSVElOR19XT1JLRVIQAhIiCh5TRVNTSU9OX1NU", - "QVRFX1dBSVRJTkdfRk9SX1BJUEUQAxIdChlTRVNTSU9OX1NUQVRFX0hBTkRT", - "SEFLSU5HEAQSJQohU0VTU0lPTl9TVEFURV9JTklUSUFMSVpJTkdfV09SS0VS", - "EAUSFwoTU0VTU0lPTl9TVEFURV9SRUFEWRAGEhkKFVNFU1NJT05fU1RBVEVf", - "Q0xPU0lORxAHEhgKFFNFU1NJT05fU1RBVEVfQ0xPU0VEEAgSGQoVU0VTU0lP", - "Tl9TVEFURV9GQVVMVEVEEAky4AQKD014QWNjZXNzR2F0ZXdheRJdCgtPcGVu", - "U2Vzc2lvbhInLm14YWNjZXNzX2dhdGV3YXkudjEuT3BlblNlc3Npb25SZXF1", - "ZXN0GiUubXhhY2Nlc3NfZ2F0ZXdheS52MS5PcGVuU2Vzc2lvblJlcGx5EmAK", - "DENsb3NlU2Vzc2lvbhIoLm14YWNjZXNzX2dhdGV3YXkudjEuQ2xvc2VTZXNz", - "aW9uUmVxdWVzdBomLm14YWNjZXNzX2dhdGV3YXkudjEuQ2xvc2VTZXNzaW9u", - "UmVwbHkSVAoGSW52b2tlEiUubXhhY2Nlc3NfZ2F0ZXdheS52MS5NeENvbW1h", - "bmRSZXF1ZXN0GiMubXhhY2Nlc3NfZ2F0ZXdheS52MS5NeENvbW1hbmRSZXBs", - "eRJYCgxTdHJlYW1FdmVudHMSKC5teGFjY2Vzc19nYXRld2F5LnYxLlN0cmVh", - "bUV2ZW50c1JlcXVlc3QaHC5teGFjY2Vzc19nYXRld2F5LnYxLk14RXZlbnQw", - "ARJsChBBY2tub3dsZWRnZUFsYXJtEiwubXhhY2Nlc3NfZ2F0ZXdheS52MS5B", - "Y2tub3dsZWRnZUFsYXJtUmVxdWVzdBoqLm14YWNjZXNzX2dhdGV3YXkudjEu", - "QWNrbm93bGVkZ2VBbGFybVJlcGx5Em4KEVF1ZXJ5QWN0aXZlQWxhcm1zEi0u", - "bXhhY2Nlc3NfZ2F0ZXdheS52MS5RdWVyeUFjdGl2ZUFsYXJtc1JlcXVlc3Qa", - "KC5teGFjY2Vzc19nYXRld2F5LnYxLkFjdGl2ZUFsYXJtU25hcHNob3QwAUIc", - "qgIZTXhHYXRld2F5LkNvbnRyYWN0cy5Qcm90b2IGcHJvdG8z")); + "YXJtQnlOYW1lQ29tbWFuZEgAEjsKCndyaXRlX2J1bGsYJyABKAsyJS5teGFj", + "Y2Vzc19nYXRld2F5LnYxLldyaXRlQnVsa0NvbW1hbmRIABI9Cgt3cml0ZTJf", + "YnVsaxgoIAEoCzImLm14YWNjZXNzX2dhdGV3YXkudjEuV3JpdGUyQnVsa0Nv", + "bW1hbmRIABJKChJ3cml0ZV9zZWN1cmVkX2J1bGsYKSABKAsyLC5teGFjY2Vz", + "c19nYXRld2F5LnYxLldyaXRlU2VjdXJlZEJ1bGtDb21tYW5kSAASTAoTd3Jp", + "dGVfc2VjdXJlZDJfYnVsaxgqIAEoCzItLm14YWNjZXNzX2dhdGV3YXkudjEu", + "V3JpdGVTZWN1cmVkMkJ1bGtDb21tYW5kSAASOQoJcmVhZF9idWxrGCsgASgL", + "MiQubXhhY2Nlc3NfZ2F0ZXdheS52MS5SZWFkQnVsa0NvbW1hbmRIABIwCgRw", + "aW5nGGQgASgLMiAubXhhY2Nlc3NfZ2F0ZXdheS52MS5QaW5nQ29tbWFuZEgA", + "EkgKEWdldF9zZXNzaW9uX3N0YXRlGGUgASgLMisubXhhY2Nlc3NfZ2F0ZXdh", + "eS52MS5HZXRTZXNzaW9uU3RhdGVDb21tYW5kSAASRAoPZ2V0X3dvcmtlcl9p", + "bmZvGGYgASgLMikubXhhY2Nlc3NfZ2F0ZXdheS52MS5HZXRXb3JrZXJJbmZv", + "Q29tbWFuZEgAEj8KDGRyYWluX2V2ZW50cxhnIAEoCzInLm14YWNjZXNzX2dh", + "dGV3YXkudjEuRHJhaW5FdmVudHNDb21tYW5kSAASRQoPc2h1dGRvd25fd29y", + "a2VyGGggASgLMioubXhhY2Nlc3NfZ2F0ZXdheS52MS5TaHV0ZG93bldvcmtl", + "ckNvbW1hbmRIAEIJCgdwYXlsb2FkIiYKD1JlZ2lzdGVyQ29tbWFuZBITCgtj", + "bGllbnRfbmFtZRgBIAEoCSIqChFVbnJlZ2lzdGVyQ29tbWFuZBIVCg1zZXJ2", + "ZXJfaGFuZGxlGAEgASgFIkAKDkFkZEl0ZW1Db21tYW5kEhUKDXNlcnZlcl9o", + "YW5kbGUYASABKAUSFwoPaXRlbV9kZWZpbml0aW9uGAIgASgJIlcKD0FkZEl0", + "ZW0yQ29tbWFuZBIVCg1zZXJ2ZXJfaGFuZGxlGAEgASgFEhcKD2l0ZW1fZGVm", + "aW5pdGlvbhgCIAEoCRIUCgxpdGVtX2NvbnRleHQYAyABKAkiPwoRUmVtb3Zl", + "SXRlbUNvbW1hbmQSFQoNc2VydmVyX2hhbmRsZRgBIAEoBRITCgtpdGVtX2hh", + "bmRsZRgCIAEoBSI7Cg1BZHZpc2VDb21tYW5kEhUKDXNlcnZlcl9oYW5kbGUY", + "ASABKAUSEwoLaXRlbV9oYW5kbGUYAiABKAUiPQoPVW5BZHZpc2VDb21tYW5k", + "EhUKDXNlcnZlcl9oYW5kbGUYASABKAUSEwoLaXRlbV9oYW5kbGUYAiABKAUi", + "RgoYQWR2aXNlU3VwZXJ2aXNvcnlDb21tYW5kEhUKDXNlcnZlcl9oYW5kbGUY", + "ASABKAUSEwoLaXRlbV9oYW5kbGUYAiABKAUiXgoWQWRkQnVmZmVyZWRJdGVt", + "Q29tbWFuZBIVCg1zZXJ2ZXJfaGFuZGxlGAEgASgFEhcKD2l0ZW1fZGVmaW5p", + "dGlvbhgCIAEoCRIUCgxpdGVtX2NvbnRleHQYAyABKAkiXwogU2V0QnVmZmVy", + "ZWRVcGRhdGVJbnRlcnZhbENvbW1hbmQSFQoNc2VydmVyX2hhbmRsZRgBIAEo", + "BRIkChx1cGRhdGVfaW50ZXJ2YWxfbWlsbGlzZWNvbmRzGAIgASgFIjwKDlN1", + "c3BlbmRDb21tYW5kEhUKDXNlcnZlcl9oYW5kbGUYASABKAUSEwoLaXRlbV9o", + "YW5kbGUYAiABKAUiPQoPQWN0aXZhdGVDb21tYW5kEhUKDXNlcnZlcl9oYW5k", + "bGUYASABKAUSEwoLaXRlbV9oYW5kbGUYAiABKAUieAoMV3JpdGVDb21tYW5k", + "EhUKDXNlcnZlcl9oYW5kbGUYASABKAUSEwoLaXRlbV9oYW5kbGUYAiABKAUS", + "KwoFdmFsdWUYAyABKAsyHC5teGFjY2Vzc19nYXRld2F5LnYxLk14VmFsdWUS", + "DwoHdXNlcl9pZBgEIAEoBSKwAQoNV3JpdGUyQ29tbWFuZBIVCg1zZXJ2ZXJf", + "aGFuZGxlGAEgASgFEhMKC2l0ZW1faGFuZGxlGAIgASgFEisKBXZhbHVlGAMg", + "ASgLMhwubXhhY2Nlc3NfZ2F0ZXdheS52MS5NeFZhbHVlEjUKD3RpbWVzdGFt", + "cF92YWx1ZRgEIAEoCzIcLm14YWNjZXNzX2dhdGV3YXkudjEuTXhWYWx1ZRIP", + "Cgd1c2VyX2lkGAUgASgFIqEBChNXcml0ZVNlY3VyZWRDb21tYW5kEhUKDXNl", + "cnZlcl9oYW5kbGUYASABKAUSEwoLaXRlbV9oYW5kbGUYAiABKAUSFwoPY3Vy", + "cmVudF91c2VyX2lkGAMgASgFEhgKEHZlcmlmaWVyX3VzZXJfaWQYBCABKAUS", + "KwoFdmFsdWUYBSABKAsyHC5teGFjY2Vzc19nYXRld2F5LnYxLk14VmFsdWUi", + "2QEKFFdyaXRlU2VjdXJlZDJDb21tYW5kEhUKDXNlcnZlcl9oYW5kbGUYASAB", + "KAUSEwoLaXRlbV9oYW5kbGUYAiABKAUSFwoPY3VycmVudF91c2VyX2lkGAMg", + "ASgFEhgKEHZlcmlmaWVyX3VzZXJfaWQYBCABKAUSKwoFdmFsdWUYBSABKAsy", + "HC5teGFjY2Vzc19nYXRld2F5LnYxLk14VmFsdWUSNQoPdGltZXN0YW1wX3Zh", + "bHVlGAYgASgLMhwubXhhY2Nlc3NfZ2F0ZXdheS52MS5NeFZhbHVlImMKF0F1", + "dGhlbnRpY2F0ZVVzZXJDb21tYW5kEhUKDXNlcnZlcl9oYW5kbGUYASABKAUS", + "EwoLdmVyaWZ5X3VzZXIYAiABKAkSHAoUdmVyaWZ5X3VzZXJfcGFzc3dvcmQY", + "AyABKAkiRwoYQXJjaGVzdHJBVXNlclRvSWRDb21tYW5kEhUKDXNlcnZlcl9o", + "YW5kbGUYASABKAUSFAoMdXNlcl9pZF9ndWlkGAIgASgJIkIKEkFkZEl0ZW1C", + "dWxrQ29tbWFuZBIVCg1zZXJ2ZXJfaGFuZGxlGAEgASgFEhUKDXRhZ19hZGRy", + "ZXNzZXMYAiADKAkiRAoVQWR2aXNlSXRlbUJ1bGtDb21tYW5kEhUKDXNlcnZl", + "cl9oYW5kbGUYASABKAUSFAoMaXRlbV9oYW5kbGVzGAIgAygFIkQKFVJlbW92", + "ZUl0ZW1CdWxrQ29tbWFuZBIVCg1zZXJ2ZXJfaGFuZGxlGAEgASgFEhQKDGl0", + "ZW1faGFuZGxlcxgCIAMoBSJGChdVbkFkdmlzZUl0ZW1CdWxrQ29tbWFuZBIV", + "Cg1zZXJ2ZXJfaGFuZGxlGAEgASgFEhQKDGl0ZW1faGFuZGxlcxgCIAMoBSJE", + "ChRTdWJzY3JpYmVCdWxrQ29tbWFuZBIVCg1zZXJ2ZXJfaGFuZGxlGAEgASgF", + "EhUKDXRhZ19hZGRyZXNzZXMYAiADKAkiOQoWU3Vic2NyaWJlQWxhcm1zQ29t", + "bWFuZBIfChdzdWJzY3JpcHRpb25fZXhwcmVzc2lvbhgBIAEoCSIaChhVbnN1", + "YnNjcmliZUFsYXJtc0NvbW1hbmQioQEKF0Fja25vd2xlZGdlQWxhcm1Db21t", + "YW5kEhIKCmFsYXJtX2d1aWQYASABKAkSDwoHY29tbWVudBgCIAEoCRIVCg1v", + "cGVyYXRvcl91c2VyGAMgASgJEhUKDW9wZXJhdG9yX25vZGUYBCABKAkSFwoP", + "b3BlcmF0b3JfZG9tYWluGAUgASgJEhoKEm9wZXJhdG9yX2Z1bGxfbmFtZRgG", + "IAEoCSI3ChhRdWVyeUFjdGl2ZUFsYXJtc0NvbW1hbmQSGwoTYWxhcm1fZmls", + "dGVyX3ByZWZpeBgBIAEoCSLSAQodQWNrbm93bGVkZ2VBbGFybUJ5TmFtZUNv", + "bW1hbmQSEgoKYWxhcm1fbmFtZRgBIAEoCRIVCg1wcm92aWRlcl9uYW1lGAIg", + "ASgJEhIKCmdyb3VwX25hbWUYAyABKAkSDwoHY29tbWVudBgEIAEoCRIVCg1v", + "cGVyYXRvcl91c2VyGAUgASgJEhUKDW9wZXJhdG9yX25vZGUYBiABKAkSFwoP", + "b3BlcmF0b3JfZG9tYWluGAcgASgJEhoKEm9wZXJhdG9yX2Z1bGxfbmFtZRgI", + "IAEoCSJFChZVbnN1YnNjcmliZUJ1bGtDb21tYW5kEhUKDXNlcnZlcl9oYW5k", + "bGUYASABKAUSFAoMaXRlbV9oYW5kbGVzGAIgAygFIl8KEFdyaXRlQnVsa0Nv", + "bW1hbmQSFQoNc2VydmVyX2hhbmRsZRgBIAEoBRI0CgdlbnRyaWVzGAIgAygL", + "MiMubXhhY2Nlc3NfZ2F0ZXdheS52MS5Xcml0ZUJ1bGtFbnRyeSJjCg5Xcml0", + "ZUJ1bGtFbnRyeRITCgtpdGVtX2hhbmRsZRgBIAEoBRIrCgV2YWx1ZRgCIAEo", + "CzIcLm14YWNjZXNzX2dhdGV3YXkudjEuTXhWYWx1ZRIPCgd1c2VyX2lkGAMg", + "ASgFImEKEVdyaXRlMkJ1bGtDb21tYW5kEhUKDXNlcnZlcl9oYW5kbGUYASAB", + "KAUSNQoHZW50cmllcxgCIAMoCzIkLm14YWNjZXNzX2dhdGV3YXkudjEuV3Jp", + "dGUyQnVsa0VudHJ5IpsBCg9Xcml0ZTJCdWxrRW50cnkSEwoLaXRlbV9oYW5k", + "bGUYASABKAUSKwoFdmFsdWUYAiABKAsyHC5teGFjY2Vzc19nYXRld2F5LnYx", + "Lk14VmFsdWUSNQoPdGltZXN0YW1wX3ZhbHVlGAMgASgLMhwubXhhY2Nlc3Nf", + "Z2F0ZXdheS52MS5NeFZhbHVlEg8KB3VzZXJfaWQYBCABKAUibQoXV3JpdGVT", + "ZWN1cmVkQnVsa0NvbW1hbmQSFQoNc2VydmVyX2hhbmRsZRgBIAEoBRI7Cgdl", + "bnRyaWVzGAIgAygLMioubXhhY2Nlc3NfZ2F0ZXdheS52MS5Xcml0ZVNlY3Vy", + "ZWRCdWxrRW50cnkijAEKFVdyaXRlU2VjdXJlZEJ1bGtFbnRyeRITCgtpdGVt", + "X2hhbmRsZRgBIAEoBRIXCg9jdXJyZW50X3VzZXJfaWQYAiABKAUSGAoQdmVy", + "aWZpZXJfdXNlcl9pZBgDIAEoBRIrCgV2YWx1ZRgEIAEoCzIcLm14YWNjZXNz", + "X2dhdGV3YXkudjEuTXhWYWx1ZSJvChhXcml0ZVNlY3VyZWQyQnVsa0NvbW1h", + "bmQSFQoNc2VydmVyX2hhbmRsZRgBIAEoBRI8CgdlbnRyaWVzGAIgAygLMisu", + "bXhhY2Nlc3NfZ2F0ZXdheS52MS5Xcml0ZVNlY3VyZWQyQnVsa0VudHJ5IsQB", + "ChZXcml0ZVNlY3VyZWQyQnVsa0VudHJ5EhMKC2l0ZW1faGFuZGxlGAEgASgF", + "EhcKD2N1cnJlbnRfdXNlcl9pZBgCIAEoBRIYChB2ZXJpZmllcl91c2VyX2lk", + "GAMgASgFEisKBXZhbHVlGAQgASgLMhwubXhhY2Nlc3NfZ2F0ZXdheS52MS5N", + "eFZhbHVlEjUKD3RpbWVzdGFtcF92YWx1ZRgFIAEoCzIcLm14YWNjZXNzX2dh", + "dGV3YXkudjEuTXhWYWx1ZSJTCg9SZWFkQnVsa0NvbW1hbmQSFQoNc2VydmVy", + "X2hhbmRsZRgBIAEoBRIVCg10YWdfYWRkcmVzc2VzGAIgAygJEhIKCnRpbWVv", + "dXRfbXMYAyABKA0iHgoLUGluZ0NvbW1hbmQSDwoHbWVzc2FnZRgBIAEoCSIY", + "ChZHZXRTZXNzaW9uU3RhdGVDb21tYW5kIhYKFEdldFdvcmtlckluZm9Db21t", + "YW5kIigKEkRyYWluRXZlbnRzQ29tbWFuZBISCgptYXhfZXZlbnRzGAEgASgN", + "IkgKFVNodXRkb3duV29ya2VyQ29tbWFuZBIvCgxncmFjZV9wZXJpb2QYASAB", + "KAsyGS5nb29nbGUucHJvdG9idWYuRHVyYXRpb24ihg8KDk14Q29tbWFuZFJl", + "cGx5EhIKCnNlc3Npb25faWQYASABKAkSFgoOY29ycmVsYXRpb25faWQYAiAB", + "KAkSMAoEa2luZBgDIAEoDjIiLm14YWNjZXNzX2dhdGV3YXkudjEuTXhDb21t", + "YW5kS2luZBI8Cg9wcm90b2NvbF9zdGF0dXMYBCABKAsyIy5teGFjY2Vzc19n", + "YXRld2F5LnYxLlByb3RvY29sU3RhdHVzEhQKB2hyZXN1bHQYBSABKAVIAYgB", + "ARIyCgxyZXR1cm5fdmFsdWUYBiABKAsyHC5teGFjY2Vzc19nYXRld2F5LnYx", + "Lk14VmFsdWUSNAoIc3RhdHVzZXMYByADKAsyIi5teGFjY2Vzc19nYXRld2F5", + "LnYxLk14U3RhdHVzUHJveHkSGgoSZGlhZ25vc3RpY19tZXNzYWdlGAggASgJ", + "EjYKCHJlZ2lzdGVyGBQgASgLMiIubXhhY2Nlc3NfZ2F0ZXdheS52MS5SZWdp", + "c3RlclJlcGx5SAASNQoIYWRkX2l0ZW0YFSABKAsyIS5teGFjY2Vzc19nYXRl", + "d2F5LnYxLkFkZEl0ZW1SZXBseUgAEjcKCWFkZF9pdGVtMhgWIAEoCzIiLm14", + "YWNjZXNzX2dhdGV3YXkudjEuQWRkSXRlbTJSZXBseUgAEkYKEWFkZF9idWZm", + "ZXJlZF9pdGVtGBcgASgLMikubXhhY2Nlc3NfZ2F0ZXdheS52MS5BZGRCdWZm", + "ZXJlZEl0ZW1SZXBseUgAEjQKB3N1c3BlbmQYGCABKAsyIS5teGFjY2Vzc19n", + "YXRld2F5LnYxLlN1c3BlbmRSZXBseUgAEjYKCGFjdGl2YXRlGBkgASgLMiIu", + "bXhhY2Nlc3NfZ2F0ZXdheS52MS5BY3RpdmF0ZVJlcGx5SAASRwoRYXV0aGVu", + "dGljYXRlX3VzZXIYGiABKAsyKi5teGFjY2Vzc19nYXRld2F5LnYxLkF1dGhl", + "bnRpY2F0ZVVzZXJSZXBseUgAEksKFGFyY2hlc3RyYV91c2VyX3RvX2lkGBsg", + "ASgLMisubXhhY2Nlc3NfZ2F0ZXdheS52MS5BcmNoZXN0ckFVc2VyVG9JZFJl", + "cGx5SAASQAoNYWRkX2l0ZW1fYnVsaxgcIAEoCzInLm14YWNjZXNzX2dhdGV3", + "YXkudjEuQnVsa1N1YnNjcmliZVJlcGx5SAASQwoQYWR2aXNlX2l0ZW1fYnVs", + "axgdIAEoCzInLm14YWNjZXNzX2dhdGV3YXkudjEuQnVsa1N1YnNjcmliZVJl", + "cGx5SAASQwoQcmVtb3ZlX2l0ZW1fYnVsaxgeIAEoCzInLm14YWNjZXNzX2dh", + "dGV3YXkudjEuQnVsa1N1YnNjcmliZVJlcGx5SAASRgoTdW5fYWR2aXNlX2l0", + "ZW1fYnVsaxgfIAEoCzInLm14YWNjZXNzX2dhdGV3YXkudjEuQnVsa1N1YnNj", + "cmliZVJlcGx5SAASQQoOc3Vic2NyaWJlX2J1bGsYICABKAsyJy5teGFjY2Vz", + "c19nYXRld2F5LnYxLkJ1bGtTdWJzY3JpYmVSZXBseUgAEkMKEHVuc3Vic2Ny", + "aWJlX2J1bGsYISABKAsyJy5teGFjY2Vzc19nYXRld2F5LnYxLkJ1bGtTdWJz", + "Y3JpYmVSZXBseUgAEk4KEWFja25vd2xlZGdlX2FsYXJtGCIgASgLMjEubXhh", + "Y2Nlc3NfZ2F0ZXdheS52MS5BY2tub3dsZWRnZUFsYXJtUmVwbHlQYXlsb2Fk", + "SAASUQoTcXVlcnlfYWN0aXZlX2FsYXJtcxgjIAEoCzIyLm14YWNjZXNzX2dh", + "dGV3YXkudjEuUXVlcnlBY3RpdmVBbGFybXNSZXBseVBheWxvYWRIABI5Cgp3", + "cml0ZV9idWxrGCQgASgLMiMubXhhY2Nlc3NfZ2F0ZXdheS52MS5CdWxrV3Jp", + "dGVSZXBseUgAEjoKC3dyaXRlMl9idWxrGCUgASgLMiMubXhhY2Nlc3NfZ2F0", + "ZXdheS52MS5CdWxrV3JpdGVSZXBseUgAEkEKEndyaXRlX3NlY3VyZWRfYnVs", + "axgmIAEoCzIjLm14YWNjZXNzX2dhdGV3YXkudjEuQnVsa1dyaXRlUmVwbHlI", + "ABJCChN3cml0ZV9zZWN1cmVkMl9idWxrGCcgASgLMiMubXhhY2Nlc3NfZ2F0", + "ZXdheS52MS5CdWxrV3JpdGVSZXBseUgAEjcKCXJlYWRfYnVsaxgoIAEoCzIi", + "Lm14YWNjZXNzX2dhdGV3YXkudjEuQnVsa1JlYWRSZXBseUgAEj8KDXNlc3Np", + "b25fc3RhdGUYZCABKAsyJi5teGFjY2Vzc19nYXRld2F5LnYxLlNlc3Npb25T", + "dGF0ZVJlcGx5SAASOwoLd29ya2VyX2luZm8YZSABKAsyJC5teGFjY2Vzc19n", + "YXRld2F5LnYxLldvcmtlckluZm9SZXBseUgAEj0KDGRyYWluX2V2ZW50cxhm", + "IAEoCzIlLm14YWNjZXNzX2dhdGV3YXkudjEuRHJhaW5FdmVudHNSZXBseUgA", + "QgkKB3BheWxvYWRCCgoIX2hyZXN1bHQiJgoNUmVnaXN0ZXJSZXBseRIVCg1z", + "ZXJ2ZXJfaGFuZGxlGAEgASgFIiMKDEFkZEl0ZW1SZXBseRITCgtpdGVtX2hh", + "bmRsZRgBIAEoBSIkCg1BZGRJdGVtMlJlcGx5EhMKC2l0ZW1faGFuZGxlGAEg", + "ASgFIisKFEFkZEJ1ZmZlcmVkSXRlbVJlcGx5EhMKC2l0ZW1faGFuZGxlGAEg", + "ASgFIkIKDFN1c3BlbmRSZXBseRIyCgZzdGF0dXMYASABKAsyIi5teGFjY2Vz", + "c19nYXRld2F5LnYxLk14U3RhdHVzUHJveHkiQwoNQWN0aXZhdGVSZXBseRIy", + "CgZzdGF0dXMYASABKAsyIi5teGFjY2Vzc19nYXRld2F5LnYxLk14U3RhdHVz", + "UHJveHkiKAoVQXV0aGVudGljYXRlVXNlclJlcGx5Eg8KB3VzZXJfaWQYASAB", + "KAUiKQoWQXJjaGVzdHJBVXNlclRvSWRSZXBseRIPCgd1c2VyX2lkGAEgASgF", + "IoEBCg9TdWJzY3JpYmVSZXN1bHQSFQoNc2VydmVyX2hhbmRsZRgBIAEoBRIT", + "Cgt0YWdfYWRkcmVzcxgCIAEoCRITCgtpdGVtX2hhbmRsZRgDIAEoBRIWCg53", + "YXNfc3VjY2Vzc2Z1bBgEIAEoCBIVCg1lcnJvcl9tZXNzYWdlGAUgASgJIksK", + "EkJ1bGtTdWJzY3JpYmVSZXBseRI1CgdyZXN1bHRzGAEgAygLMiQubXhhY2Nl", + "c3NfZ2F0ZXdheS52MS5TdWJzY3JpYmVSZXN1bHQixAEKD0J1bGtXcml0ZVJl", + "c3VsdBIVCg1zZXJ2ZXJfaGFuZGxlGAEgASgFEhMKC2l0ZW1faGFuZGxlGAIg", + "ASgFEhYKDndhc19zdWNjZXNzZnVsGAMgASgIEhQKB2hyZXN1bHQYBCABKAVI", + "AIgBARI0CghzdGF0dXNlcxgFIAMoCzIiLm14YWNjZXNzX2dhdGV3YXkudjEu", + "TXhTdGF0dXNQcm94eRIVCg1lcnJvcl9tZXNzYWdlGAYgASgJQgoKCF9ocmVz", + "dWx0IkcKDkJ1bGtXcml0ZVJlcGx5EjUKB3Jlc3VsdHMYASADKAsyJC5teGFj", + "Y2Vzc19nYXRld2F5LnYxLkJ1bGtXcml0ZVJlc3VsdCK+AgoOQnVsa1JlYWRS", + "ZXN1bHQSFQoNc2VydmVyX2hhbmRsZRgBIAEoBRITCgt0YWdfYWRkcmVzcxgC", + "IAEoCRITCgtpdGVtX2hhbmRsZRgDIAEoBRIWCg53YXNfc3VjY2Vzc2Z1bBgE", + "IAEoCBISCgp3YXNfY2FjaGVkGAUgASgIEisKBXZhbHVlGAYgASgLMhwubXhh", + "Y2Nlc3NfZ2F0ZXdheS52MS5NeFZhbHVlEg8KB3F1YWxpdHkYByABKAUSNAoQ", + "c291cmNlX3RpbWVzdGFtcBgIIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1l", + "c3RhbXASNAoIc3RhdHVzZXMYCSADKAsyIi5teGFjY2Vzc19nYXRld2F5LnYx", + "Lk14U3RhdHVzUHJveHkSFQoNZXJyb3JfbWVzc2FnZRgKIAEoCSJFCg1CdWxr", + "UmVhZFJlcGx5EjQKB3Jlc3VsdHMYASADKAsyIy5teGFjY2Vzc19nYXRld2F5", + "LnYxLkJ1bGtSZWFkUmVzdWx0IkUKEVNlc3Npb25TdGF0ZVJlcGx5EjAKBXN0", + "YXRlGAEgASgOMiEubXhhY2Nlc3NfZ2F0ZXdheS52MS5TZXNzaW9uU3RhdGUi", + "dQoPV29ya2VySW5mb1JlcGx5EhkKEXdvcmtlcl9wcm9jZXNzX2lkGAEgASgF", + "EhYKDndvcmtlcl92ZXJzaW9uGAIgASgJEhcKD214YWNjZXNzX3Byb2dpZBgD", + "IAEoCRIWCg5teGFjY2Vzc19jbHNpZBgEIAEoCSJAChBEcmFpbkV2ZW50c1Jl", + "cGx5EiwKBmV2ZW50cxgBIAMoCzIcLm14YWNjZXNzX2dhdGV3YXkudjEuTXhF", + "dmVudCI1ChxBY2tub3dsZWRnZUFsYXJtUmVwbHlQYXlsb2FkEhUKDW5hdGl2", + "ZV9zdGF0dXMYASABKAUiXAodUXVlcnlBY3RpdmVBbGFybXNSZXBseVBheWxv", + "YWQSOwoJc25hcHNob3RzGAEgAygLMigubXhhY2Nlc3NfZ2F0ZXdheS52MS5B", + "Y3RpdmVBbGFybVNuYXBzaG90IucGCgdNeEV2ZW50EjIKBmZhbWlseRgBIAEo", + "DjIiLm14YWNjZXNzX2dhdGV3YXkudjEuTXhFdmVudEZhbWlseRISCgpzZXNz", + "aW9uX2lkGAIgASgJEhUKDXNlcnZlcl9oYW5kbGUYAyABKAUSEwoLaXRlbV9o", + "YW5kbGUYBCABKAUSKwoFdmFsdWUYBSABKAsyHC5teGFjY2Vzc19nYXRld2F5", + "LnYxLk14VmFsdWUSDwoHcXVhbGl0eRgGIAEoBRI0ChBzb3VyY2VfdGltZXN0", + "YW1wGAcgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBI0CghzdGF0", + "dXNlcxgIIAMoCzIiLm14YWNjZXNzX2dhdGV3YXkudjEuTXhTdGF0dXNQcm94", + "eRIXCg93b3JrZXJfc2VxdWVuY2UYCSABKAQSNAoQd29ya2VyX3RpbWVzdGFt", + "cBgKIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASPQoZZ2F0ZXdh", + "eV9yZWNlaXZlX3RpbWVzdGFtcBgLIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5U", + "aW1lc3RhbXASFAoHaHJlc3VsdBgMIAEoBUgBiAEBEhIKCnJhd19zdGF0dXMY", + "DSABKAkSQAoOb25fZGF0YV9jaGFuZ2UYFCABKAsyJi5teGFjY2Vzc19nYXRl", + "d2F5LnYxLk9uRGF0YUNoYW5nZUV2ZW50SAASRgoRb25fd3JpdGVfY29tcGxl", + "dGUYFSABKAsyKS5teGFjY2Vzc19nYXRld2F5LnYxLk9uV3JpdGVDb21wbGV0", + "ZUV2ZW50SAASSQoSb3BlcmF0aW9uX2NvbXBsZXRlGBYgASgLMisubXhhY2Nl", + "c3NfZ2F0ZXdheS52MS5PcGVyYXRpb25Db21wbGV0ZUV2ZW50SAASUQoXb25f", + "YnVmZmVyZWRfZGF0YV9jaGFuZ2UYFyABKAsyLi5teGFjY2Vzc19nYXRld2F5", + "LnYxLk9uQnVmZmVyZWREYXRhQ2hhbmdlRXZlbnRIABJKChNvbl9hbGFybV90", + "cmFuc2l0aW9uGBggASgLMisubXhhY2Nlc3NfZ2F0ZXdheS52MS5PbkFsYXJt", + "VHJhbnNpdGlvbkV2ZW50SABCBgoEYm9keUIKCghfaHJlc3VsdCITChFPbkRh", + "dGFDaGFuZ2VFdmVudCIWChRPbldyaXRlQ29tcGxldGVFdmVudCIYChZPcGVy", + "YXRpb25Db21wbGV0ZUV2ZW50ItQBChlPbkJ1ZmZlcmVkRGF0YUNoYW5nZUV2", + "ZW50EjIKCWRhdGFfdHlwZRgBIAEoDjIfLm14YWNjZXNzX2dhdGV3YXkudjEu", + "TXhEYXRhVHlwZRI0Cg5xdWFsaXR5X3ZhbHVlcxgCIAEoCzIcLm14YWNjZXNz", + "X2dhdGV3YXkudjEuTXhBcnJheRI2ChB0aW1lc3RhbXBfdmFsdWVzGAMgASgL", + "MhwubXhhY2Nlc3NfZ2F0ZXdheS52MS5NeEFycmF5EhUKDXJhd19kYXRhX3R5", + "cGUYBCABKAUi/QMKFk9uQWxhcm1UcmFuc2l0aW9uRXZlbnQSHAoUYWxhcm1f", + "ZnVsbF9yZWZlcmVuY2UYASABKAkSHwoXc291cmNlX29iamVjdF9yZWZlcmVu", + "Y2UYAiABKAkSFwoPYWxhcm1fdHlwZV9uYW1lGAMgASgJEkEKD3RyYW5zaXRp", + "b25fa2luZBgEIAEoDjIoLm14YWNjZXNzX2dhdGV3YXkudjEuQWxhcm1UcmFu", + "c2l0aW9uS2luZBIQCghzZXZlcml0eRgFIAEoBRI8ChhvcmlnaW5hbF9yYWlz", + "ZV90aW1lc3RhbXAYBiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1w", + "EjgKFHRyYW5zaXRpb25fdGltZXN0YW1wGAcgASgLMhouZ29vZ2xlLnByb3Rv", + "YnVmLlRpbWVzdGFtcBIVCg1vcGVyYXRvcl91c2VyGAggASgJEhgKEG9wZXJh", + "dG9yX2NvbW1lbnQYCSABKAkSEAoIY2F0ZWdvcnkYCiABKAkSEwoLZGVzY3Jp", + "cHRpb24YCyABKAkSMwoNY3VycmVudF92YWx1ZRgMIAEoCzIcLm14YWNjZXNz", + "X2dhdGV3YXkudjEuTXhWYWx1ZRIxCgtsaW1pdF92YWx1ZRgNIAEoCzIcLm14", + "YWNjZXNzX2dhdGV3YXkudjEuTXhWYWx1ZSL9AwoTQWN0aXZlQWxhcm1TbmFw", + "c2hvdBIcChRhbGFybV9mdWxsX3JlZmVyZW5jZRgBIAEoCRIfChdzb3VyY2Vf", + "b2JqZWN0X3JlZmVyZW5jZRgCIAEoCRIXCg9hbGFybV90eXBlX25hbWUYAyAB", + "KAkSEAoIc2V2ZXJpdHkYBCABKAUSPAoYb3JpZ2luYWxfcmFpc2VfdGltZXN0", + "YW1wGAUgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBI/Cg1jdXJy", + "ZW50X3N0YXRlGAYgASgOMigubXhhY2Nlc3NfZ2F0ZXdheS52MS5BbGFybUNv", + "bmRpdGlvblN0YXRlEhAKCGNhdGVnb3J5GAcgASgJEhMKC2Rlc2NyaXB0aW9u", + "GAggASgJEj0KGWxhc3RfdHJhbnNpdGlvbl90aW1lc3RhbXAYCSABKAsyGi5n", + "b29nbGUucHJvdG9idWYuVGltZXN0YW1wEhUKDW9wZXJhdG9yX3VzZXIYCiAB", + "KAkSGAoQb3BlcmF0b3JfY29tbWVudBgLIAEoCRIzCg1jdXJyZW50X3ZhbHVl", + "GAwgASgLMhwubXhhY2Nlc3NfZ2F0ZXdheS52MS5NeFZhbHVlEjEKC2xpbWl0", + "X3ZhbHVlGA0gASgLMhwubXhhY2Nlc3NfZ2F0ZXdheS52MS5NeFZhbHVlIpAB", + "ChdBY2tub3dsZWRnZUFsYXJtUmVxdWVzdBIdChVjbGllbnRfY29ycmVsYXRp", + "b25faWQYAiABKAkSHAoUYWxhcm1fZnVsbF9yZWZlcmVuY2UYAyABKAkSDwoH", + "Y29tbWVudBgEIAEoCRIVCg1vcGVyYXRvcl91c2VyGAUgASgJSgQIARACUgpz", + "ZXNzaW9uX2lkIvEBChVBY2tub3dsZWRnZUFsYXJtUmVwbHkSFgoOY29ycmVs", + "YXRpb25faWQYAiABKAkSPAoPcHJvdG9jb2xfc3RhdHVzGAMgASgLMiMubXhh", + "Y2Nlc3NfZ2F0ZXdheS52MS5Qcm90b2NvbFN0YXR1cxIUCgdocmVzdWx0GAQg", + "ASgFSACIAQESMgoGc3RhdHVzGAUgASgLMiIubXhhY2Nlc3NfZ2F0ZXdheS52", + "MS5NeFN0YXR1c1Byb3h5EhoKEmRpYWdub3N0aWNfbWVzc2FnZRgGIAEoCUIK", + "CghfaHJlc3VsdEoECAEQAlIKc2Vzc2lvbl9pZCJRChNTdHJlYW1BbGFybXNS", + "ZXF1ZXN0Eh0KFWNsaWVudF9jb3JyZWxhdGlvbl9pZBgBIAEoCRIbChNhbGFy", + "bV9maWx0ZXJfcHJlZml4GAIgASgJIr8BChBBbGFybUZlZWRNZXNzYWdlEkAK", + "DGFjdGl2ZV9hbGFybRgBIAEoCzIoLm14YWNjZXNzX2dhdGV3YXkudjEuQWN0", + "aXZlQWxhcm1TbmFwc2hvdEgAEhsKEXNuYXBzaG90X2NvbXBsZXRlGAIgASgI", + "SAASQQoKdHJhbnNpdGlvbhgDIAEoCzIrLm14YWNjZXNzX2dhdGV3YXkudjEu", + "T25BbGFybVRyYW5zaXRpb25FdmVudEgAQgkKB3BheWxvYWQi6wEKDU14U3Rh", + "dHVzUHJveHkSDwoHc3VjY2VzcxgBIAEoBRI3CghjYXRlZ29yeRgCIAEoDjIl", + "Lm14YWNjZXNzX2dhdGV3YXkudjEuTXhTdGF0dXNDYXRlZ29yeRI4CgtkZXRl", + "Y3RlZF9ieRgDIAEoDjIjLm14YWNjZXNzX2dhdGV3YXkudjEuTXhTdGF0dXNT", + "b3VyY2USDgoGZGV0YWlsGAQgASgFEhQKDHJhd19jYXRlZ29yeRgFIAEoBRIX", + "Cg9yYXdfZGV0ZWN0ZWRfYnkYBiABKAUSFwoPZGlhZ25vc3RpY190ZXh0GAcg", + "ASgJIqcDCgdNeFZhbHVlEjIKCWRhdGFfdHlwZRgBIAEoDjIfLm14YWNjZXNz", + "X2dhdGV3YXkudjEuTXhEYXRhVHlwZRIUCgx2YXJpYW50X3R5cGUYAiABKAkS", + "DwoHaXNfbnVsbBgDIAEoCBIWCg5yYXdfZGlhZ25vc3RpYxgEIAEoCRIVCg1y", + "YXdfZGF0YV90eXBlGAUgASgFEhQKCmJvb2xfdmFsdWUYCiABKAhIABIVCgtp", + "bnQzMl92YWx1ZRgLIAEoBUgAEhUKC2ludDY0X3ZhbHVlGAwgASgDSAASFQoL", + "ZmxvYXRfdmFsdWUYDSABKAJIABIWCgxkb3VibGVfdmFsdWUYDiABKAFIABIW", + "CgxzdHJpbmdfdmFsdWUYDyABKAlIABI1Cg90aW1lc3RhbXBfdmFsdWUYECAB", + "KAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wSAASMwoLYXJyYXlfdmFs", + "dWUYESABKAsyHC5teGFjY2Vzc19nYXRld2F5LnYxLk14QXJyYXlIABITCgly", + "YXdfdmFsdWUYEiABKAxIAEIGCgRraW5kIv4ECgdNeEFycmF5EjoKEWVsZW1l", + "bnRfZGF0YV90eXBlGAEgASgOMh8ubXhhY2Nlc3NfZ2F0ZXdheS52MS5NeERh", + "dGFUeXBlEhQKDHZhcmlhbnRfdHlwZRgCIAEoCRISCgpkaW1lbnNpb25zGAMg", + "AygNEhYKDnJhd19kaWFnbm9zdGljGAQgASgJEh0KFXJhd19lbGVtZW50X2Rh", + "dGFfdHlwZRgFIAEoBRI1Cgtib29sX3ZhbHVlcxgKIAEoCzIeLm14YWNjZXNz", + "X2dhdGV3YXkudjEuQm9vbEFycmF5SAASNwoMaW50MzJfdmFsdWVzGAsgASgL", + "Mh8ubXhhY2Nlc3NfZ2F0ZXdheS52MS5JbnQzMkFycmF5SAASNwoMaW50NjRf", + "dmFsdWVzGAwgASgLMh8ubXhhY2Nlc3NfZ2F0ZXdheS52MS5JbnQ2NEFycmF5", + "SAASNwoMZmxvYXRfdmFsdWVzGA0gASgLMh8ubXhhY2Nlc3NfZ2F0ZXdheS52", + "MS5GbG9hdEFycmF5SAASOQoNZG91YmxlX3ZhbHVlcxgOIAEoCzIgLm14YWNj", + "ZXNzX2dhdGV3YXkudjEuRG91YmxlQXJyYXlIABI5Cg1zdHJpbmdfdmFsdWVz", + "GA8gASgLMiAubXhhY2Nlc3NfZ2F0ZXdheS52MS5TdHJpbmdBcnJheUgAEj8K", + "EHRpbWVzdGFtcF92YWx1ZXMYECABKAsyIy5teGFjY2Vzc19nYXRld2F5LnYx", + "LlRpbWVzdGFtcEFycmF5SAASMwoKcmF3X3ZhbHVlcxgRIAEoCzIdLm14YWNj", + "ZXNzX2dhdGV3YXkudjEuUmF3QXJyYXlIAEIICgZ2YWx1ZXMiGwoJQm9vbEFy", + "cmF5Eg4KBnZhbHVlcxgBIAMoCCIcCgpJbnQzMkFycmF5Eg4KBnZhbHVlcxgB", + "IAMoBSIcCgpJbnQ2NEFycmF5Eg4KBnZhbHVlcxgBIAMoAyIcCgpGbG9hdEFy", + "cmF5Eg4KBnZhbHVlcxgBIAMoAiIdCgtEb3VibGVBcnJheRIOCgZ2YWx1ZXMY", + "ASADKAEiHQoLU3RyaW5nQXJyYXkSDgoGdmFsdWVzGAEgAygJIjwKDlRpbWVz", + "dGFtcEFycmF5EioKBnZhbHVlcxgBIAMoCzIaLmdvb2dsZS5wcm90b2J1Zi5U", + "aW1lc3RhbXAiGgoIUmF3QXJyYXkSDgoGdmFsdWVzGAEgAygMIlgKDlByb3Rv", + "Y29sU3RhdHVzEjUKBGNvZGUYASABKA4yJy5teGFjY2Vzc19nYXRld2F5LnYx", + "LlByb3RvY29sU3RhdHVzQ29kZRIPCgdtZXNzYWdlGAIgASgJKp8LCg1NeENv", + "bW1hbmRLaW5kEh8KG01YX0NPTU1BTkRfS0lORF9VTlNQRUNJRklFRBAAEhwK", + "GE1YX0NPTU1BTkRfS0lORF9SRUdJU1RFUhABEh4KGk1YX0NPTU1BTkRfS0lO", + "RF9VTlJFR0lTVEVSEAISHAoYTVhfQ09NTUFORF9LSU5EX0FERF9JVEVNEAMS", + "HQoZTVhfQ09NTUFORF9LSU5EX0FERF9JVEVNMhAEEh8KG01YX0NPTU1BTkRf", + "S0lORF9SRU1PVkVfSVRFTRAFEhoKFk1YX0NPTU1BTkRfS0lORF9BRFZJU0UQ", + "BhIdChlNWF9DT01NQU5EX0tJTkRfVU5fQURWSVNFEAcSJgoiTVhfQ09NTUFO", + "RF9LSU5EX0FEVklTRV9TVVBFUlZJU09SWRAIEiUKIU1YX0NPTU1BTkRfS0lO", + "RF9BRERfQlVGRkVSRURfSVRFTRAJEjAKLE1YX0NPTU1BTkRfS0lORF9TRVRf", + "QlVGRkVSRURfVVBEQVRFX0lOVEVSVkFMEAoSGwoXTVhfQ09NTUFORF9LSU5E", + "X1NVU1BFTkQQCxIcChhNWF9DT01NQU5EX0tJTkRfQUNUSVZBVEUQDBIZChVN", + "WF9DT01NQU5EX0tJTkRfV1JJVEUQDRIaChZNWF9DT01NQU5EX0tJTkRfV1JJ", + "VEUyEA4SIQodTVhfQ09NTUFORF9LSU5EX1dSSVRFX1NFQ1VSRUQQDxIiCh5N", + "WF9DT01NQU5EX0tJTkRfV1JJVEVfU0VDVVJFRDIQEBIlCiFNWF9DT01NQU5E", + "X0tJTkRfQVVUSEVOVElDQVRFX1VTRVIQERIoCiRNWF9DT01NQU5EX0tJTkRf", + "QVJDSEVTVFJBX1VTRVJfVE9fSUQQEhIhCh1NWF9DT01NQU5EX0tJTkRfQURE", + "X0lURU1fQlVMSxATEiQKIE1YX0NPTU1BTkRfS0lORF9BRFZJU0VfSVRFTV9C", + "VUxLEBQSJAogTVhfQ09NTUFORF9LSU5EX1JFTU9WRV9JVEVNX0JVTEsQFRIn", + "CiNNWF9DT01NQU5EX0tJTkRfVU5fQURWSVNFX0lURU1fQlVMSxAWEiIKHk1Y", + "X0NPTU1BTkRfS0lORF9TVUJTQ1JJQkVfQlVMSxAXEiQKIE1YX0NPTU1BTkRf", + "S0lORF9VTlNVQlNDUklCRV9CVUxLEBgSJAogTVhfQ09NTUFORF9LSU5EX1NV", + "QlNDUklCRV9BTEFSTVMQGRImCiJNWF9DT01NQU5EX0tJTkRfVU5TVUJTQ1JJ", + "QkVfQUxBUk1TEBoSJQohTVhfQ09NTUFORF9LSU5EX0FDS05PV0xFREdFX0FM", + "QVJNEBsSJwojTVhfQ09NTUFORF9LSU5EX1FVRVJZX0FDVElWRV9BTEFSTVMQ", + "HBItCilNWF9DT01NQU5EX0tJTkRfQUNLTk9XTEVER0VfQUxBUk1fQllfTkFN", + "RRAdEh4KGk1YX0NPTU1BTkRfS0lORF9XUklURV9CVUxLEB4SHwobTVhfQ09N", + "TUFORF9LSU5EX1dSSVRFMl9CVUxLEB8SJgoiTVhfQ09NTUFORF9LSU5EX1dS", + "SVRFX1NFQ1VSRURfQlVMSxAgEicKI01YX0NPTU1BTkRfS0lORF9XUklURV9T", + "RUNVUkVEMl9CVUxLECESHQoZTVhfQ09NTUFORF9LSU5EX1JFQURfQlVMSxAi", + "EhgKFE1YX0NPTU1BTkRfS0lORF9QSU5HEGQSJQohTVhfQ09NTUFORF9LSU5E", + "X0dFVF9TRVNTSU9OX1NUQVRFEGUSIwofTVhfQ09NTUFORF9LSU5EX0dFVF9X", + "T1JLRVJfSU5GTxBmEiAKHE1YX0NPTU1BTkRfS0lORF9EUkFJTl9FVkVOVFMQ", + "ZxIjCh9NWF9DT01NQU5EX0tJTkRfU0hVVERPV05fV09SS0VSEGgq+QEKDU14", + "RXZlbnRGYW1pbHkSHwobTVhfRVZFTlRfRkFNSUxZX1VOU1BFQ0lGSUVEEAAS", + "IgoeTVhfRVZFTlRfRkFNSUxZX09OX0RBVEFfQ0hBTkdFEAESJQohTVhfRVZF", + "TlRfRkFNSUxZX09OX1dSSVRFX0NPTVBMRVRFEAISJgoiTVhfRVZFTlRfRkFN", + "SUxZX09QRVJBVElPTl9DT01QTEVURRADEisKJ01YX0VWRU5UX0ZBTUlMWV9P", + "Tl9CVUZGRVJFRF9EQVRBX0NIQU5HRRAEEicKI01YX0VWRU5UX0ZBTUlMWV9P", + "Tl9BTEFSTV9UUkFOU0lUSU9OEAUqygEKE0FsYXJtVHJhbnNpdGlvbktpbmQS", + "JQohQUxBUk1fVFJBTlNJVElPTl9LSU5EX1VOU1BFQ0lGSUVEEAASHwobQUxB", + "Uk1fVFJBTlNJVElPTl9LSU5EX1JBSVNFEAESJQohQUxBUk1fVFJBTlNJVElP", + "Tl9LSU5EX0FDS05PV0xFREdFEAISHwobQUxBUk1fVFJBTlNJVElPTl9LSU5E", + "X0NMRUFSEAMSIwofQUxBUk1fVFJBTlNJVElPTl9LSU5EX1JFVFJJR0dFUhAE", + "KqoBChNBbGFybUNvbmRpdGlvblN0YXRlEiUKIUFMQVJNX0NPTkRJVElPTl9T", + "VEFURV9VTlNQRUNJRklFRBAAEiAKHEFMQVJNX0NPTkRJVElPTl9TVEFURV9B", + "Q1RJVkUQARImCiJBTEFSTV9DT05ESVRJT05fU1RBVEVfQUNUSVZFX0FDS0VE", + "EAISIgoeQUxBUk1fQ09ORElUSU9OX1NUQVRFX0lOQUNUSVZFEAMqpQMKEE14", + "U3RhdHVzQ2F0ZWdvcnkSIgoeTVhfU1RBVFVTX0NBVEVHT1JZX1VOU1BFQ0lG", + "SUVEEAASHgoaTVhfU1RBVFVTX0NBVEVHT1JZX1VOS05PV04QARIZChVNWF9T", + "VEFUVVNfQ0FURUdPUllfT0sQAhIeChpNWF9TVEFUVVNfQ0FURUdPUllfUEVO", + "RElORxADEh4KGk1YX1NUQVRVU19DQVRFR09SWV9XQVJOSU5HEAQSKgomTVhf", + "U1RBVFVTX0NBVEVHT1JZX0NPTU1VTklDQVRJT05fRVJST1IQBRIqCiZNWF9T", + "VEFUVVNfQ0FURUdPUllfQ09ORklHVVJBVElPTl9FUlJPUhAGEigKJE1YX1NU", + "QVRVU19DQVRFR09SWV9PUEVSQVRJT05BTF9FUlJPUhAHEiUKIU1YX1NUQVRV", + "U19DQVRFR09SWV9TRUNVUklUWV9FUlJPUhAIEiUKIU1YX1NUQVRVU19DQVRF", + "R09SWV9TT0ZUV0FSRV9FUlJPUhAJEiIKHk1YX1NUQVRVU19DQVRFR09SWV9P", + "VEhFUl9FUlJPUhAKKsoCCg5NeFN0YXR1c1NvdXJjZRIgChxNWF9TVEFUVVNf", + "U09VUkNFX1VOU1BFQ0lGSUVEEAASHAoYTVhfU1RBVFVTX1NPVVJDRV9VTktO", + "T1dOEAESIwofTVhfU1RBVFVTX1NPVVJDRV9SRVFVRVNUSU5HX0xNWBACEiMK", + "H01YX1NUQVRVU19TT1VSQ0VfUkVTUE9ORElOR19MTVgQAxIjCh9NWF9TVEFU", + "VVNfU09VUkNFX1JFUVVFU1RJTkdfTk1YEAQSIwofTVhfU1RBVFVTX1NPVVJD", + "RV9SRVNQT05ESU5HX05NWBAFEjEKLU1YX1NUQVRVU19TT1VSQ0VfUkVRVUVT", + "VElOR19BVVRPTUFUSU9OX09CSkVDVBAGEjEKLU1YX1NUQVRVU19TT1VSQ0Vf", + "UkVTUE9ORElOR19BVVRPTUFUSU9OX09CSkVDVBAHKt0ECgpNeERhdGFUeXBl", + "EhwKGE1YX0RBVEFfVFlQRV9VTlNQRUNJRklFRBAAEhgKFE1YX0RBVEFfVFlQ", + "RV9VTktOT1dOEAESGAoUTVhfREFUQV9UWVBFX05PX0RBVEEQAhIYChRNWF9E", + "QVRBX1RZUEVfQk9PTEVBThADEhgKFE1YX0RBVEFfVFlQRV9JTlRFR0VSEAQS", + "FgoSTVhfREFUQV9UWVBFX0ZMT0FUEAUSFwoTTVhfREFUQV9UWVBFX0RPVUJM", + "RRAGEhcKE01YX0RBVEFfVFlQRV9TVFJJTkcQBxIVChFNWF9EQVRBX1RZUEVf", + "VElNRRAIEh0KGU1YX0RBVEFfVFlQRV9FTEFQU0VEX1RJTUUQCRIfChtNWF9E", + "QVRBX1RZUEVfUkVGRVJFTkNFX1RZUEUQChIcChhNWF9EQVRBX1RZUEVfU1RB", + "VFVTX1RZUEUQCxIVChFNWF9EQVRBX1RZUEVfRU5VTRAMEi0KKU1YX0RBVEFf", + "VFlQRV9TRUNVUklUWV9DTEFTU0lGSUNBVElPTl9FTlVNEA0SIgoeTVhfREFU", + "QV9UWVBFX0RBVEFfUVVBTElUWV9UWVBFEA4SHwobTVhfREFUQV9UWVBFX1FV", + "QUxJRklFRF9FTlVNEA8SIQodTVhfREFUQV9UWVBFX1FVQUxJRklFRF9TVFJV", + "Q1QQEBIpCiVNWF9EQVRBX1RZUEVfSU5URVJOQVRJT05BTElaRURfU1RSSU5H", + "EBESGwoXTVhfREFUQV9UWVBFX0JJR19TVFJJTkcQEhIUChBNWF9EQVRBX1RZ", + "UEVfRU5EEBMqowMKElByb3RvY29sU3RhdHVzQ29kZRIkCiBQUk9UT0NPTF9T", + "VEFUVVNfQ09ERV9VTlNQRUNJRklFRBAAEhsKF1BST1RPQ09MX1NUQVRVU19D", + "T0RFX09LEAESKAokUFJPVE9DT0xfU1RBVFVTX0NPREVfSU5WQUxJRF9SRVFV", + "RVNUEAISKgomUFJPVE9DT0xfU1RBVFVTX0NPREVfU0VTU0lPTl9OT1RfRk9V", + "TkQQAxIqCiZQUk9UT0NPTF9TVEFUVVNfQ09ERV9TRVNTSU9OX05PVF9SRUFE", + "WRAEEisKJ1BST1RPQ09MX1NUQVRVU19DT0RFX1dPUktFUl9VTkFWQUlMQUJM", + "RRAFEiAKHFBST1RPQ09MX1NUQVRVU19DT0RFX1RJTUVPVVQQBhIhCh1QUk9U", + "T0NPTF9TVEFUVVNfQ09ERV9DQU5DRUxFRBAHEisKJ1BST1RPQ09MX1NUQVRV", + "U19DT0RFX1BST1RPQ09MX1ZJT0xBVElPThAIEikKJVBST1RPQ09MX1NUQVRV", + "U19DT0RFX01YQUNDRVNTX0ZBSUxVUkUQCSq/AgoMU2Vzc2lvblN0YXRlEh0K", + "GVNFU1NJT05fU1RBVEVfVU5TUEVDSUZJRUQQABIaChZTRVNTSU9OX1NUQVRF", + "X0NSRUFUSU5HEAESIQodU0VTU0lPTl9TVEFURV9TVEFSVElOR19XT1JLRVIQ", + "AhIiCh5TRVNTSU9OX1NUQVRFX1dBSVRJTkdfRk9SX1BJUEUQAxIdChlTRVNT", + "SU9OX1NUQVRFX0hBTkRTSEFLSU5HEAQSJQohU0VTU0lPTl9TVEFURV9JTklU", + "SUFMSVpJTkdfV09SS0VSEAUSFwoTU0VTU0lPTl9TVEFURV9SRUFEWRAGEhkK", + "FVNFU1NJT05fU1RBVEVfQ0xPU0lORxAHEhgKFFNFU1NJT05fU1RBVEVfQ0xP", + "U0VEEAgSGQoVU0VTU0lPTl9TVEFURV9GQVVMVEVEEAky0wQKD014QWNjZXNz", + "R2F0ZXdheRJdCgtPcGVuU2Vzc2lvbhInLm14YWNjZXNzX2dhdGV3YXkudjEu", + "T3BlblNlc3Npb25SZXF1ZXN0GiUubXhhY2Nlc3NfZ2F0ZXdheS52MS5PcGVu", + "U2Vzc2lvblJlcGx5EmAKDENsb3NlU2Vzc2lvbhIoLm14YWNjZXNzX2dhdGV3", + "YXkudjEuQ2xvc2VTZXNzaW9uUmVxdWVzdBomLm14YWNjZXNzX2dhdGV3YXku", + "djEuQ2xvc2VTZXNzaW9uUmVwbHkSVAoGSW52b2tlEiUubXhhY2Nlc3NfZ2F0", + "ZXdheS52MS5NeENvbW1hbmRSZXF1ZXN0GiMubXhhY2Nlc3NfZ2F0ZXdheS52", + "MS5NeENvbW1hbmRSZXBseRJYCgxTdHJlYW1FdmVudHMSKC5teGFjY2Vzc19n", + "YXRld2F5LnYxLlN0cmVhbUV2ZW50c1JlcXVlc3QaHC5teGFjY2Vzc19nYXRl", + "d2F5LnYxLk14RXZlbnQwARJsChBBY2tub3dsZWRnZUFsYXJtEiwubXhhY2Nl", + "c3NfZ2F0ZXdheS52MS5BY2tub3dsZWRnZUFsYXJtUmVxdWVzdBoqLm14YWNj", + "ZXNzX2dhdGV3YXkudjEuQWNrbm93bGVkZ2VBbGFybVJlcGx5EmEKDFN0cmVh", + "bUFsYXJtcxIoLm14YWNjZXNzX2dhdGV3YXkudjEuU3RyZWFtQWxhcm1zUmVx", + "dWVzdBolLm14YWNjZXNzX2dhdGV3YXkudjEuQWxhcm1GZWVkTWVzc2FnZTAB", + "QiaqAiNaQi5NT00uV1cuTXhHYXRld2F5LkNvbnRyYWN0cy5Qcm90b2IGcHJv", + "dG8z")); descriptor = pbr::FileDescriptor.FromGeneratedCode(descriptorData, new pbr::FileDescriptor[] { global::Google.Protobuf.WellKnownTypes.DurationReflection.Descriptor, global::Google.Protobuf.WellKnownTypes.TimestampReflection.Descriptor, }, - new pbr::GeneratedClrTypeInfo(new[] {typeof(global::MxGateway.Contracts.Proto.MxCommandKind), typeof(global::MxGateway.Contracts.Proto.MxEventFamily), typeof(global::MxGateway.Contracts.Proto.AlarmTransitionKind), typeof(global::MxGateway.Contracts.Proto.AlarmConditionState), typeof(global::MxGateway.Contracts.Proto.MxStatusCategory), typeof(global::MxGateway.Contracts.Proto.MxStatusSource), typeof(global::MxGateway.Contracts.Proto.MxDataType), typeof(global::MxGateway.Contracts.Proto.ProtocolStatusCode), typeof(global::MxGateway.Contracts.Proto.SessionState), }, null, new pbr::GeneratedClrTypeInfo[] { - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.OpenSessionRequest), global::MxGateway.Contracts.Proto.OpenSessionRequest.Parser, new[]{ "RequestedBackend", "ClientSessionName", "ClientCorrelationId", "CommandTimeout" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.OpenSessionReply), global::MxGateway.Contracts.Proto.OpenSessionReply.Parser, new[]{ "SessionId", "BackendName", "WorkerProcessId", "WorkerProtocolVersion", "Capabilities", "DefaultCommandTimeout", "ProtocolStatus", "GatewayProtocolVersion" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.CloseSessionRequest), global::MxGateway.Contracts.Proto.CloseSessionRequest.Parser, new[]{ "SessionId", "ClientCorrelationId" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.CloseSessionReply), global::MxGateway.Contracts.Proto.CloseSessionReply.Parser, new[]{ "SessionId", "FinalState", "ProtocolStatus" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.StreamEventsRequest), global::MxGateway.Contracts.Proto.StreamEventsRequest.Parser, new[]{ "SessionId", "AfterWorkerSequence" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.MxCommandRequest), global::MxGateway.Contracts.Proto.MxCommandRequest.Parser, new[]{ "SessionId", "ClientCorrelationId", "Command" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.MxCommand), global::MxGateway.Contracts.Proto.MxCommand.Parser, new[]{ "Kind", "Register", "Unregister", "AddItem", "AddItem2", "RemoveItem", "Advise", "UnAdvise", "AdviseSupervisory", "AddBufferedItem", "SetBufferedUpdateInterval", "Suspend", "Activate", "Write", "Write2", "WriteSecured", "WriteSecured2", "AuthenticateUser", "ArchestraUserToId", "AddItemBulk", "AdviseItemBulk", "RemoveItemBulk", "UnAdviseItemBulk", "SubscribeBulk", "UnsubscribeBulk", "SubscribeAlarms", "UnsubscribeAlarms", "AcknowledgeAlarmCommand", "QueryActiveAlarmsCommand", "AcknowledgeAlarmByNameCommand", "Ping", "GetSessionState", "GetWorkerInfo", "DrainEvents", "ShutdownWorker" }, new[]{ "Payload" }, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.RegisterCommand), global::MxGateway.Contracts.Proto.RegisterCommand.Parser, new[]{ "ClientName" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.UnregisterCommand), global::MxGateway.Contracts.Proto.UnregisterCommand.Parser, new[]{ "ServerHandle" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.AddItemCommand), global::MxGateway.Contracts.Proto.AddItemCommand.Parser, new[]{ "ServerHandle", "ItemDefinition" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.AddItem2Command), global::MxGateway.Contracts.Proto.AddItem2Command.Parser, new[]{ "ServerHandle", "ItemDefinition", "ItemContext" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.RemoveItemCommand), global::MxGateway.Contracts.Proto.RemoveItemCommand.Parser, new[]{ "ServerHandle", "ItemHandle" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.AdviseCommand), global::MxGateway.Contracts.Proto.AdviseCommand.Parser, new[]{ "ServerHandle", "ItemHandle" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.UnAdviseCommand), global::MxGateway.Contracts.Proto.UnAdviseCommand.Parser, new[]{ "ServerHandle", "ItemHandle" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.AdviseSupervisoryCommand), global::MxGateway.Contracts.Proto.AdviseSupervisoryCommand.Parser, new[]{ "ServerHandle", "ItemHandle" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.AddBufferedItemCommand), global::MxGateway.Contracts.Proto.AddBufferedItemCommand.Parser, new[]{ "ServerHandle", "ItemDefinition", "ItemContext" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.SetBufferedUpdateIntervalCommand), global::MxGateway.Contracts.Proto.SetBufferedUpdateIntervalCommand.Parser, new[]{ "ServerHandle", "UpdateIntervalMilliseconds" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.SuspendCommand), global::MxGateway.Contracts.Proto.SuspendCommand.Parser, new[]{ "ServerHandle", "ItemHandle" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.ActivateCommand), global::MxGateway.Contracts.Proto.ActivateCommand.Parser, new[]{ "ServerHandle", "ItemHandle" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.WriteCommand), global::MxGateway.Contracts.Proto.WriteCommand.Parser, new[]{ "ServerHandle", "ItemHandle", "Value", "UserId" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.Write2Command), global::MxGateway.Contracts.Proto.Write2Command.Parser, new[]{ "ServerHandle", "ItemHandle", "Value", "TimestampValue", "UserId" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.WriteSecuredCommand), global::MxGateway.Contracts.Proto.WriteSecuredCommand.Parser, new[]{ "ServerHandle", "ItemHandle", "CurrentUserId", "VerifierUserId", "Value" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.WriteSecured2Command), global::MxGateway.Contracts.Proto.WriteSecured2Command.Parser, new[]{ "ServerHandle", "ItemHandle", "CurrentUserId", "VerifierUserId", "Value", "TimestampValue" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.AuthenticateUserCommand), global::MxGateway.Contracts.Proto.AuthenticateUserCommand.Parser, new[]{ "ServerHandle", "VerifyUser", "VerifyUserPassword" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.ArchestrAUserToIdCommand), global::MxGateway.Contracts.Proto.ArchestrAUserToIdCommand.Parser, new[]{ "ServerHandle", "UserIdGuid" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.AddItemBulkCommand), global::MxGateway.Contracts.Proto.AddItemBulkCommand.Parser, new[]{ "ServerHandle", "TagAddresses" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.AdviseItemBulkCommand), global::MxGateway.Contracts.Proto.AdviseItemBulkCommand.Parser, new[]{ "ServerHandle", "ItemHandles" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.RemoveItemBulkCommand), global::MxGateway.Contracts.Proto.RemoveItemBulkCommand.Parser, new[]{ "ServerHandle", "ItemHandles" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.UnAdviseItemBulkCommand), global::MxGateway.Contracts.Proto.UnAdviseItemBulkCommand.Parser, new[]{ "ServerHandle", "ItemHandles" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.SubscribeBulkCommand), global::MxGateway.Contracts.Proto.SubscribeBulkCommand.Parser, new[]{ "ServerHandle", "TagAddresses" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.SubscribeAlarmsCommand), global::MxGateway.Contracts.Proto.SubscribeAlarmsCommand.Parser, new[]{ "SubscriptionExpression" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.UnsubscribeAlarmsCommand), global::MxGateway.Contracts.Proto.UnsubscribeAlarmsCommand.Parser, null, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.AcknowledgeAlarmCommand), global::MxGateway.Contracts.Proto.AcknowledgeAlarmCommand.Parser, new[]{ "AlarmGuid", "Comment", "OperatorUser", "OperatorNode", "OperatorDomain", "OperatorFullName" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.QueryActiveAlarmsCommand), global::MxGateway.Contracts.Proto.QueryActiveAlarmsCommand.Parser, new[]{ "AlarmFilterPrefix" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.AcknowledgeAlarmByNameCommand), global::MxGateway.Contracts.Proto.AcknowledgeAlarmByNameCommand.Parser, new[]{ "AlarmName", "ProviderName", "GroupName", "Comment", "OperatorUser", "OperatorNode", "OperatorDomain", "OperatorFullName" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.UnsubscribeBulkCommand), global::MxGateway.Contracts.Proto.UnsubscribeBulkCommand.Parser, new[]{ "ServerHandle", "ItemHandles" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.PingCommand), global::MxGateway.Contracts.Proto.PingCommand.Parser, new[]{ "Message" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.GetSessionStateCommand), global::MxGateway.Contracts.Proto.GetSessionStateCommand.Parser, null, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.GetWorkerInfoCommand), global::MxGateway.Contracts.Proto.GetWorkerInfoCommand.Parser, null, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.DrainEventsCommand), global::MxGateway.Contracts.Proto.DrainEventsCommand.Parser, new[]{ "MaxEvents" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.ShutdownWorkerCommand), global::MxGateway.Contracts.Proto.ShutdownWorkerCommand.Parser, new[]{ "GracePeriod" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.MxCommandReply), global::MxGateway.Contracts.Proto.MxCommandReply.Parser, new[]{ "SessionId", "CorrelationId", "Kind", "ProtocolStatus", "Hresult", "ReturnValue", "Statuses", "DiagnosticMessage", "Register", "AddItem", "AddItem2", "AddBufferedItem", "Suspend", "Activate", "AuthenticateUser", "ArchestraUserToId", "AddItemBulk", "AdviseItemBulk", "RemoveItemBulk", "UnAdviseItemBulk", "SubscribeBulk", "UnsubscribeBulk", "AcknowledgeAlarm", "QueryActiveAlarms", "SessionState", "WorkerInfo", "DrainEvents" }, new[]{ "Payload", "Hresult" }, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.RegisterReply), global::MxGateway.Contracts.Proto.RegisterReply.Parser, new[]{ "ServerHandle" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.AddItemReply), global::MxGateway.Contracts.Proto.AddItemReply.Parser, new[]{ "ItemHandle" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.AddItem2Reply), global::MxGateway.Contracts.Proto.AddItem2Reply.Parser, new[]{ "ItemHandle" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.AddBufferedItemReply), global::MxGateway.Contracts.Proto.AddBufferedItemReply.Parser, new[]{ "ItemHandle" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.SuspendReply), global::MxGateway.Contracts.Proto.SuspendReply.Parser, new[]{ "Status" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.ActivateReply), global::MxGateway.Contracts.Proto.ActivateReply.Parser, new[]{ "Status" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.AuthenticateUserReply), global::MxGateway.Contracts.Proto.AuthenticateUserReply.Parser, new[]{ "UserId" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.ArchestrAUserToIdReply), global::MxGateway.Contracts.Proto.ArchestrAUserToIdReply.Parser, new[]{ "UserId" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.SubscribeResult), global::MxGateway.Contracts.Proto.SubscribeResult.Parser, new[]{ "ServerHandle", "TagAddress", "ItemHandle", "WasSuccessful", "ErrorMessage" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.BulkSubscribeReply), global::MxGateway.Contracts.Proto.BulkSubscribeReply.Parser, new[]{ "Results" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.SessionStateReply), global::MxGateway.Contracts.Proto.SessionStateReply.Parser, new[]{ "State" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.WorkerInfoReply), global::MxGateway.Contracts.Proto.WorkerInfoReply.Parser, new[]{ "WorkerProcessId", "WorkerVersion", "MxaccessProgid", "MxaccessClsid" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.DrainEventsReply), global::MxGateway.Contracts.Proto.DrainEventsReply.Parser, new[]{ "Events" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.AcknowledgeAlarmReplyPayload), global::MxGateway.Contracts.Proto.AcknowledgeAlarmReplyPayload.Parser, new[]{ "NativeStatus" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.QueryActiveAlarmsReplyPayload), global::MxGateway.Contracts.Proto.QueryActiveAlarmsReplyPayload.Parser, new[]{ "Snapshots" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.MxEvent), global::MxGateway.Contracts.Proto.MxEvent.Parser, new[]{ "Family", "SessionId", "ServerHandle", "ItemHandle", "Value", "Quality", "SourceTimestamp", "Statuses", "WorkerSequence", "WorkerTimestamp", "GatewayReceiveTimestamp", "Hresult", "RawStatus", "OnDataChange", "OnWriteComplete", "OperationComplete", "OnBufferedDataChange", "OnAlarmTransition" }, new[]{ "Body", "Hresult" }, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.OnDataChangeEvent), global::MxGateway.Contracts.Proto.OnDataChangeEvent.Parser, null, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.OnWriteCompleteEvent), global::MxGateway.Contracts.Proto.OnWriteCompleteEvent.Parser, null, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.OperationCompleteEvent), global::MxGateway.Contracts.Proto.OperationCompleteEvent.Parser, null, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.OnBufferedDataChangeEvent), global::MxGateway.Contracts.Proto.OnBufferedDataChangeEvent.Parser, new[]{ "DataType", "QualityValues", "TimestampValues", "RawDataType" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.OnAlarmTransitionEvent), global::MxGateway.Contracts.Proto.OnAlarmTransitionEvent.Parser, new[]{ "AlarmFullReference", "SourceObjectReference", "AlarmTypeName", "TransitionKind", "Severity", "OriginalRaiseTimestamp", "TransitionTimestamp", "OperatorUser", "OperatorComment", "Category", "Description", "CurrentValue", "LimitValue" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.ActiveAlarmSnapshot), global::MxGateway.Contracts.Proto.ActiveAlarmSnapshot.Parser, new[]{ "AlarmFullReference", "SourceObjectReference", "AlarmTypeName", "Severity", "OriginalRaiseTimestamp", "CurrentState", "Category", "Description", "LastTransitionTimestamp", "OperatorUser", "OperatorComment", "CurrentValue", "LimitValue" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.AcknowledgeAlarmRequest), global::MxGateway.Contracts.Proto.AcknowledgeAlarmRequest.Parser, new[]{ "SessionId", "ClientCorrelationId", "AlarmFullReference", "Comment", "OperatorUser" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.AcknowledgeAlarmReply), global::MxGateway.Contracts.Proto.AcknowledgeAlarmReply.Parser, new[]{ "SessionId", "CorrelationId", "ProtocolStatus", "Hresult", "Status", "DiagnosticMessage" }, new[]{ "Hresult" }, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.QueryActiveAlarmsRequest), global::MxGateway.Contracts.Proto.QueryActiveAlarmsRequest.Parser, new[]{ "SessionId", "ClientCorrelationId", "AlarmFilterPrefix" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.MxStatusProxy), global::MxGateway.Contracts.Proto.MxStatusProxy.Parser, new[]{ "Success", "Category", "DetectedBy", "Detail", "RawCategory", "RawDetectedBy", "DiagnosticText" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.MxValue), global::MxGateway.Contracts.Proto.MxValue.Parser, new[]{ "DataType", "VariantType", "IsNull", "RawDiagnostic", "RawDataType", "BoolValue", "Int32Value", "Int64Value", "FloatValue", "DoubleValue", "StringValue", "TimestampValue", "ArrayValue", "RawValue" }, new[]{ "Kind" }, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.MxArray), global::MxGateway.Contracts.Proto.MxArray.Parser, new[]{ "ElementDataType", "VariantType", "Dimensions", "RawDiagnostic", "RawElementDataType", "BoolValues", "Int32Values", "Int64Values", "FloatValues", "DoubleValues", "StringValues", "TimestampValues", "RawValues" }, new[]{ "Values" }, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.BoolArray), global::MxGateway.Contracts.Proto.BoolArray.Parser, new[]{ "Values" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.Int32Array), global::MxGateway.Contracts.Proto.Int32Array.Parser, new[]{ "Values" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.Int64Array), global::MxGateway.Contracts.Proto.Int64Array.Parser, new[]{ "Values" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.FloatArray), global::MxGateway.Contracts.Proto.FloatArray.Parser, new[]{ "Values" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.DoubleArray), global::MxGateway.Contracts.Proto.DoubleArray.Parser, new[]{ "Values" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.StringArray), global::MxGateway.Contracts.Proto.StringArray.Parser, new[]{ "Values" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.TimestampArray), global::MxGateway.Contracts.Proto.TimestampArray.Parser, new[]{ "Values" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.RawArray), global::MxGateway.Contracts.Proto.RawArray.Parser, new[]{ "Values" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.ProtocolStatus), global::MxGateway.Contracts.Proto.ProtocolStatus.Parser, new[]{ "Code", "Message" }, null, null, null, null) + new pbr::GeneratedClrTypeInfo(new[] {typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxCommandKind), typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxEventFamily), typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmTransitionKind), typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmConditionState), typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusCategory), typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusSource), typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxDataType), typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.ProtocolStatusCode), typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.SessionState), }, null, new pbr::GeneratedClrTypeInfo[] { + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.OpenSessionRequest), global::ZB.MOM.WW.MxGateway.Contracts.Proto.OpenSessionRequest.Parser, new[]{ "RequestedBackend", "ClientSessionName", "ClientCorrelationId", "CommandTimeout" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.OpenSessionReply), global::ZB.MOM.WW.MxGateway.Contracts.Proto.OpenSessionReply.Parser, new[]{ "SessionId", "BackendName", "WorkerProcessId", "WorkerProtocolVersion", "Capabilities", "DefaultCommandTimeout", "ProtocolStatus", "GatewayProtocolVersion" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.CloseSessionRequest), global::ZB.MOM.WW.MxGateway.Contracts.Proto.CloseSessionRequest.Parser, new[]{ "SessionId", "ClientCorrelationId" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.CloseSessionReply), global::ZB.MOM.WW.MxGateway.Contracts.Proto.CloseSessionReply.Parser, new[]{ "SessionId", "FinalState", "ProtocolStatus" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.StreamEventsRequest), global::ZB.MOM.WW.MxGateway.Contracts.Proto.StreamEventsRequest.Parser, new[]{ "SessionId", "AfterWorkerSequence" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxCommandRequest), global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxCommandRequest.Parser, new[]{ "SessionId", "ClientCorrelationId", "Command" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxCommand), global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxCommand.Parser, new[]{ "Kind", "Register", "Unregister", "AddItem", "AddItem2", "RemoveItem", "Advise", "UnAdvise", "AdviseSupervisory", "AddBufferedItem", "SetBufferedUpdateInterval", "Suspend", "Activate", "Write", "Write2", "WriteSecured", "WriteSecured2", "AuthenticateUser", "ArchestraUserToId", "AddItemBulk", "AdviseItemBulk", "RemoveItemBulk", "UnAdviseItemBulk", "SubscribeBulk", "UnsubscribeBulk", "SubscribeAlarms", "UnsubscribeAlarms", "AcknowledgeAlarmCommand", "QueryActiveAlarmsCommand", "AcknowledgeAlarmByNameCommand", "WriteBulk", "Write2Bulk", "WriteSecuredBulk", "WriteSecured2Bulk", "ReadBulk", "Ping", "GetSessionState", "GetWorkerInfo", "DrainEvents", "ShutdownWorker" }, new[]{ "Payload" }, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.RegisterCommand), global::ZB.MOM.WW.MxGateway.Contracts.Proto.RegisterCommand.Parser, new[]{ "ClientName" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.UnregisterCommand), global::ZB.MOM.WW.MxGateway.Contracts.Proto.UnregisterCommand.Parser, new[]{ "ServerHandle" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.AddItemCommand), global::ZB.MOM.WW.MxGateway.Contracts.Proto.AddItemCommand.Parser, new[]{ "ServerHandle", "ItemDefinition" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.AddItem2Command), global::ZB.MOM.WW.MxGateway.Contracts.Proto.AddItem2Command.Parser, new[]{ "ServerHandle", "ItemDefinition", "ItemContext" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.RemoveItemCommand), global::ZB.MOM.WW.MxGateway.Contracts.Proto.RemoveItemCommand.Parser, new[]{ "ServerHandle", "ItemHandle" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.AdviseCommand), global::ZB.MOM.WW.MxGateway.Contracts.Proto.AdviseCommand.Parser, new[]{ "ServerHandle", "ItemHandle" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.UnAdviseCommand), global::ZB.MOM.WW.MxGateway.Contracts.Proto.UnAdviseCommand.Parser, new[]{ "ServerHandle", "ItemHandle" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.AdviseSupervisoryCommand), global::ZB.MOM.WW.MxGateway.Contracts.Proto.AdviseSupervisoryCommand.Parser, new[]{ "ServerHandle", "ItemHandle" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.AddBufferedItemCommand), global::ZB.MOM.WW.MxGateway.Contracts.Proto.AddBufferedItemCommand.Parser, new[]{ "ServerHandle", "ItemDefinition", "ItemContext" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.SetBufferedUpdateIntervalCommand), global::ZB.MOM.WW.MxGateway.Contracts.Proto.SetBufferedUpdateIntervalCommand.Parser, new[]{ "ServerHandle", "UpdateIntervalMilliseconds" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.SuspendCommand), global::ZB.MOM.WW.MxGateway.Contracts.Proto.SuspendCommand.Parser, new[]{ "ServerHandle", "ItemHandle" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.ActivateCommand), global::ZB.MOM.WW.MxGateway.Contracts.Proto.ActivateCommand.Parser, new[]{ "ServerHandle", "ItemHandle" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.WriteCommand), global::ZB.MOM.WW.MxGateway.Contracts.Proto.WriteCommand.Parser, new[]{ "ServerHandle", "ItemHandle", "Value", "UserId" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.Write2Command), global::ZB.MOM.WW.MxGateway.Contracts.Proto.Write2Command.Parser, new[]{ "ServerHandle", "ItemHandle", "Value", "TimestampValue", "UserId" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.WriteSecuredCommand), global::ZB.MOM.WW.MxGateway.Contracts.Proto.WriteSecuredCommand.Parser, new[]{ "ServerHandle", "ItemHandle", "CurrentUserId", "VerifierUserId", "Value" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.WriteSecured2Command), global::ZB.MOM.WW.MxGateway.Contracts.Proto.WriteSecured2Command.Parser, new[]{ "ServerHandle", "ItemHandle", "CurrentUserId", "VerifierUserId", "Value", "TimestampValue" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.AuthenticateUserCommand), global::ZB.MOM.WW.MxGateway.Contracts.Proto.AuthenticateUserCommand.Parser, new[]{ "ServerHandle", "VerifyUser", "VerifyUserPassword" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.ArchestrAUserToIdCommand), global::ZB.MOM.WW.MxGateway.Contracts.Proto.ArchestrAUserToIdCommand.Parser, new[]{ "ServerHandle", "UserIdGuid" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.AddItemBulkCommand), global::ZB.MOM.WW.MxGateway.Contracts.Proto.AddItemBulkCommand.Parser, new[]{ "ServerHandle", "TagAddresses" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.AdviseItemBulkCommand), global::ZB.MOM.WW.MxGateway.Contracts.Proto.AdviseItemBulkCommand.Parser, new[]{ "ServerHandle", "ItemHandles" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.RemoveItemBulkCommand), global::ZB.MOM.WW.MxGateway.Contracts.Proto.RemoveItemBulkCommand.Parser, new[]{ "ServerHandle", "ItemHandles" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.UnAdviseItemBulkCommand), global::ZB.MOM.WW.MxGateway.Contracts.Proto.UnAdviseItemBulkCommand.Parser, new[]{ "ServerHandle", "ItemHandles" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.SubscribeBulkCommand), global::ZB.MOM.WW.MxGateway.Contracts.Proto.SubscribeBulkCommand.Parser, new[]{ "ServerHandle", "TagAddresses" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.SubscribeAlarmsCommand), global::ZB.MOM.WW.MxGateway.Contracts.Proto.SubscribeAlarmsCommand.Parser, new[]{ "SubscriptionExpression" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.UnsubscribeAlarmsCommand), global::ZB.MOM.WW.MxGateway.Contracts.Proto.UnsubscribeAlarmsCommand.Parser, null, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmCommand), global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmCommand.Parser, new[]{ "AlarmGuid", "Comment", "OperatorUser", "OperatorNode", "OperatorDomain", "OperatorFullName" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.QueryActiveAlarmsCommand), global::ZB.MOM.WW.MxGateway.Contracts.Proto.QueryActiveAlarmsCommand.Parser, new[]{ "AlarmFilterPrefix" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmByNameCommand), global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmByNameCommand.Parser, new[]{ "AlarmName", "ProviderName", "GroupName", "Comment", "OperatorUser", "OperatorNode", "OperatorDomain", "OperatorFullName" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.UnsubscribeBulkCommand), global::ZB.MOM.WW.MxGateway.Contracts.Proto.UnsubscribeBulkCommand.Parser, new[]{ "ServerHandle", "ItemHandles" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.WriteBulkCommand), global::ZB.MOM.WW.MxGateway.Contracts.Proto.WriteBulkCommand.Parser, new[]{ "ServerHandle", "Entries" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.WriteBulkEntry), global::ZB.MOM.WW.MxGateway.Contracts.Proto.WriteBulkEntry.Parser, new[]{ "ItemHandle", "Value", "UserId" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.Write2BulkCommand), global::ZB.MOM.WW.MxGateway.Contracts.Proto.Write2BulkCommand.Parser, new[]{ "ServerHandle", "Entries" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.Write2BulkEntry), global::ZB.MOM.WW.MxGateway.Contracts.Proto.Write2BulkEntry.Parser, new[]{ "ItemHandle", "Value", "TimestampValue", "UserId" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.WriteSecuredBulkCommand), global::ZB.MOM.WW.MxGateway.Contracts.Proto.WriteSecuredBulkCommand.Parser, new[]{ "ServerHandle", "Entries" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.WriteSecuredBulkEntry), global::ZB.MOM.WW.MxGateway.Contracts.Proto.WriteSecuredBulkEntry.Parser, new[]{ "ItemHandle", "CurrentUserId", "VerifierUserId", "Value" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.WriteSecured2BulkCommand), global::ZB.MOM.WW.MxGateway.Contracts.Proto.WriteSecured2BulkCommand.Parser, new[]{ "ServerHandle", "Entries" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.WriteSecured2BulkEntry), global::ZB.MOM.WW.MxGateway.Contracts.Proto.WriteSecured2BulkEntry.Parser, new[]{ "ItemHandle", "CurrentUserId", "VerifierUserId", "Value", "TimestampValue" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.ReadBulkCommand), global::ZB.MOM.WW.MxGateway.Contracts.Proto.ReadBulkCommand.Parser, new[]{ "ServerHandle", "TagAddresses", "TimeoutMs" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.PingCommand), global::ZB.MOM.WW.MxGateway.Contracts.Proto.PingCommand.Parser, new[]{ "Message" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.GetSessionStateCommand), global::ZB.MOM.WW.MxGateway.Contracts.Proto.GetSessionStateCommand.Parser, null, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.GetWorkerInfoCommand), global::ZB.MOM.WW.MxGateway.Contracts.Proto.GetWorkerInfoCommand.Parser, null, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.DrainEventsCommand), global::ZB.MOM.WW.MxGateway.Contracts.Proto.DrainEventsCommand.Parser, new[]{ "MaxEvents" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.ShutdownWorkerCommand), global::ZB.MOM.WW.MxGateway.Contracts.Proto.ShutdownWorkerCommand.Parser, new[]{ "GracePeriod" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxCommandReply), global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxCommandReply.Parser, new[]{ "SessionId", "CorrelationId", "Kind", "ProtocolStatus", "Hresult", "ReturnValue", "Statuses", "DiagnosticMessage", "Register", "AddItem", "AddItem2", "AddBufferedItem", "Suspend", "Activate", "AuthenticateUser", "ArchestraUserToId", "AddItemBulk", "AdviseItemBulk", "RemoveItemBulk", "UnAdviseItemBulk", "SubscribeBulk", "UnsubscribeBulk", "AcknowledgeAlarm", "QueryActiveAlarms", "WriteBulk", "Write2Bulk", "WriteSecuredBulk", "WriteSecured2Bulk", "ReadBulk", "SessionState", "WorkerInfo", "DrainEvents" }, new[]{ "Payload", "Hresult" }, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.RegisterReply), global::ZB.MOM.WW.MxGateway.Contracts.Proto.RegisterReply.Parser, new[]{ "ServerHandle" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.AddItemReply), global::ZB.MOM.WW.MxGateway.Contracts.Proto.AddItemReply.Parser, new[]{ "ItemHandle" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.AddItem2Reply), global::ZB.MOM.WW.MxGateway.Contracts.Proto.AddItem2Reply.Parser, new[]{ "ItemHandle" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.AddBufferedItemReply), global::ZB.MOM.WW.MxGateway.Contracts.Proto.AddBufferedItemReply.Parser, new[]{ "ItemHandle" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.SuspendReply), global::ZB.MOM.WW.MxGateway.Contracts.Proto.SuspendReply.Parser, new[]{ "Status" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.ActivateReply), global::ZB.MOM.WW.MxGateway.Contracts.Proto.ActivateReply.Parser, new[]{ "Status" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.AuthenticateUserReply), global::ZB.MOM.WW.MxGateway.Contracts.Proto.AuthenticateUserReply.Parser, new[]{ "UserId" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.ArchestrAUserToIdReply), global::ZB.MOM.WW.MxGateway.Contracts.Proto.ArchestrAUserToIdReply.Parser, new[]{ "UserId" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.SubscribeResult), global::ZB.MOM.WW.MxGateway.Contracts.Proto.SubscribeResult.Parser, new[]{ "ServerHandle", "TagAddress", "ItemHandle", "WasSuccessful", "ErrorMessage" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkSubscribeReply), global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkSubscribeReply.Parser, new[]{ "Results" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkWriteResult), global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkWriteResult.Parser, new[]{ "ServerHandle", "ItemHandle", "WasSuccessful", "Hresult", "Statuses", "ErrorMessage" }, new[]{ "Hresult" }, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkWriteReply), global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkWriteReply.Parser, new[]{ "Results" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkReadResult), global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkReadResult.Parser, new[]{ "ServerHandle", "TagAddress", "ItemHandle", "WasSuccessful", "WasCached", "Value", "Quality", "SourceTimestamp", "Statuses", "ErrorMessage" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkReadReply), global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkReadReply.Parser, new[]{ "Results" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.SessionStateReply), global::ZB.MOM.WW.MxGateway.Contracts.Proto.SessionStateReply.Parser, new[]{ "State" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerInfoReply), global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerInfoReply.Parser, new[]{ "WorkerProcessId", "WorkerVersion", "MxaccessProgid", "MxaccessClsid" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.DrainEventsReply), global::ZB.MOM.WW.MxGateway.Contracts.Proto.DrainEventsReply.Parser, new[]{ "Events" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmReplyPayload), global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmReplyPayload.Parser, new[]{ "NativeStatus" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.QueryActiveAlarmsReplyPayload), global::ZB.MOM.WW.MxGateway.Contracts.Proto.QueryActiveAlarmsReplyPayload.Parser, new[]{ "Snapshots" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxEvent), global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxEvent.Parser, new[]{ "Family", "SessionId", "ServerHandle", "ItemHandle", "Value", "Quality", "SourceTimestamp", "Statuses", "WorkerSequence", "WorkerTimestamp", "GatewayReceiveTimestamp", "Hresult", "RawStatus", "OnDataChange", "OnWriteComplete", "OperationComplete", "OnBufferedDataChange", "OnAlarmTransition" }, new[]{ "Body", "Hresult" }, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnDataChangeEvent), global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnDataChangeEvent.Parser, null, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnWriteCompleteEvent), global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnWriteCompleteEvent.Parser, null, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.OperationCompleteEvent), global::ZB.MOM.WW.MxGateway.Contracts.Proto.OperationCompleteEvent.Parser, null, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnBufferedDataChangeEvent), global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnBufferedDataChangeEvent.Parser, new[]{ "DataType", "QualityValues", "TimestampValues", "RawDataType" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnAlarmTransitionEvent), global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnAlarmTransitionEvent.Parser, new[]{ "AlarmFullReference", "SourceObjectReference", "AlarmTypeName", "TransitionKind", "Severity", "OriginalRaiseTimestamp", "TransitionTimestamp", "OperatorUser", "OperatorComment", "Category", "Description", "CurrentValue", "LimitValue" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.ActiveAlarmSnapshot), global::ZB.MOM.WW.MxGateway.Contracts.Proto.ActiveAlarmSnapshot.Parser, new[]{ "AlarmFullReference", "SourceObjectReference", "AlarmTypeName", "Severity", "OriginalRaiseTimestamp", "CurrentState", "Category", "Description", "LastTransitionTimestamp", "OperatorUser", "OperatorComment", "CurrentValue", "LimitValue" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmRequest), global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmRequest.Parser, new[]{ "ClientCorrelationId", "AlarmFullReference", "Comment", "OperatorUser" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmReply), global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmReply.Parser, new[]{ "CorrelationId", "ProtocolStatus", "Hresult", "Status", "DiagnosticMessage" }, new[]{ "Hresult" }, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.StreamAlarmsRequest), global::ZB.MOM.WW.MxGateway.Contracts.Proto.StreamAlarmsRequest.Parser, new[]{ "ClientCorrelationId", "AlarmFilterPrefix" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmFeedMessage), global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmFeedMessage.Parser, new[]{ "ActiveAlarm", "SnapshotComplete", "Transition" }, new[]{ "Payload" }, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusProxy), global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusProxy.Parser, new[]{ "Success", "Category", "DetectedBy", "Detail", "RawCategory", "RawDetectedBy", "DiagnosticText" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue), global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue.Parser, new[]{ "DataType", "VariantType", "IsNull", "RawDiagnostic", "RawDataType", "BoolValue", "Int32Value", "Int64Value", "FloatValue", "DoubleValue", "StringValue", "TimestampValue", "ArrayValue", "RawValue" }, new[]{ "Kind" }, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxArray), global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxArray.Parser, new[]{ "ElementDataType", "VariantType", "Dimensions", "RawDiagnostic", "RawElementDataType", "BoolValues", "Int32Values", "Int64Values", "FloatValues", "DoubleValues", "StringValues", "TimestampValues", "RawValues" }, new[]{ "Values" }, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.BoolArray), global::ZB.MOM.WW.MxGateway.Contracts.Proto.BoolArray.Parser, new[]{ "Values" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.Int32Array), global::ZB.MOM.WW.MxGateway.Contracts.Proto.Int32Array.Parser, new[]{ "Values" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.Int64Array), global::ZB.MOM.WW.MxGateway.Contracts.Proto.Int64Array.Parser, new[]{ "Values" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.FloatArray), global::ZB.MOM.WW.MxGateway.Contracts.Proto.FloatArray.Parser, new[]{ "Values" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.DoubleArray), global::ZB.MOM.WW.MxGateway.Contracts.Proto.DoubleArray.Parser, new[]{ "Values" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.StringArray), global::ZB.MOM.WW.MxGateway.Contracts.Proto.StringArray.Parser, new[]{ "Values" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.TimestampArray), global::ZB.MOM.WW.MxGateway.Contracts.Proto.TimestampArray.Parser, new[]{ "Values" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.RawArray), global::ZB.MOM.WW.MxGateway.Contracts.Proto.RawArray.Parser, new[]{ "Values" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.ProtocolStatus), global::ZB.MOM.WW.MxGateway.Contracts.Proto.ProtocolStatus.Parser, new[]{ "Code", "Message" }, null, null, null, null) })); } #endregion @@ -540,6 +616,11 @@ namespace MxGateway.Contracts.Proto { [pbr::OriginalName("MX_COMMAND_KIND_ACKNOWLEDGE_ALARM")] AcknowledgeAlarm = 27, [pbr::OriginalName("MX_COMMAND_KIND_QUERY_ACTIVE_ALARMS")] QueryActiveAlarms = 28, [pbr::OriginalName("MX_COMMAND_KIND_ACKNOWLEDGE_ALARM_BY_NAME")] AcknowledgeAlarmByName = 29, + [pbr::OriginalName("MX_COMMAND_KIND_WRITE_BULK")] WriteBulk = 30, + [pbr::OriginalName("MX_COMMAND_KIND_WRITE2_BULK")] Write2Bulk = 31, + [pbr::OriginalName("MX_COMMAND_KIND_WRITE_SECURED_BULK")] WriteSecuredBulk = 32, + [pbr::OriginalName("MX_COMMAND_KIND_WRITE_SECURED2_BULK")] WriteSecured2Bulk = 33, + [pbr::OriginalName("MX_COMMAND_KIND_READ_BULK")] ReadBulk = 34, [pbr::OriginalName("MX_COMMAND_KIND_PING")] Ping = 100, [pbr::OriginalName("MX_COMMAND_KIND_GET_SESSION_STATE")] GetSessionState = 101, [pbr::OriginalName("MX_COMMAND_KIND_GET_WORKER_INFO")] GetWorkerInfo = 102, @@ -663,7 +744,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[0]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[0]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -981,7 +1062,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[1]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[1]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -1091,10 +1172,10 @@ namespace MxGateway.Contracts.Proto { /// Field number for the "protocol_status" field. public const int ProtocolStatusFieldNumber = 7; - private global::MxGateway.Contracts.Proto.ProtocolStatus protocolStatus_; + private global::ZB.MOM.WW.MxGateway.Contracts.Proto.ProtocolStatus protocolStatus_; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.ProtocolStatus ProtocolStatus { + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.ProtocolStatus ProtocolStatus { get { return protocolStatus_; } set { protocolStatus_ = value; @@ -1307,7 +1388,7 @@ namespace MxGateway.Contracts.Proto { } if (other.protocolStatus_ != null) { if (protocolStatus_ == null) { - ProtocolStatus = new global::MxGateway.Contracts.Proto.ProtocolStatus(); + ProtocolStatus = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.ProtocolStatus(); } ProtocolStatus.MergeFrom(other.ProtocolStatus); } @@ -1362,7 +1443,7 @@ namespace MxGateway.Contracts.Proto { } case 58: { if (protocolStatus_ == null) { - ProtocolStatus = new global::MxGateway.Contracts.Proto.ProtocolStatus(); + ProtocolStatus = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.ProtocolStatus(); } input.ReadMessage(ProtocolStatus); break; @@ -1419,7 +1500,7 @@ namespace MxGateway.Contracts.Proto { } case 58: { if (protocolStatus_ == null) { - ProtocolStatus = new global::MxGateway.Contracts.Proto.ProtocolStatus(); + ProtocolStatus = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.ProtocolStatus(); } input.ReadMessage(ProtocolStatus); break; @@ -1450,7 +1531,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[2]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[2]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -1685,7 +1766,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[3]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[3]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -1731,10 +1812,10 @@ namespace MxGateway.Contracts.Proto { /// Field number for the "final_state" field. public const int FinalStateFieldNumber = 2; - private global::MxGateway.Contracts.Proto.SessionState finalState_ = global::MxGateway.Contracts.Proto.SessionState.Unspecified; + private global::ZB.MOM.WW.MxGateway.Contracts.Proto.SessionState finalState_ = global::ZB.MOM.WW.MxGateway.Contracts.Proto.SessionState.Unspecified; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.SessionState FinalState { + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.SessionState FinalState { get { return finalState_; } set { finalState_ = value; @@ -1743,10 +1824,10 @@ namespace MxGateway.Contracts.Proto { /// Field number for the "protocol_status" field. public const int ProtocolStatusFieldNumber = 3; - private global::MxGateway.Contracts.Proto.ProtocolStatus protocolStatus_; + private global::ZB.MOM.WW.MxGateway.Contracts.Proto.ProtocolStatus protocolStatus_; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.ProtocolStatus ProtocolStatus { + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.ProtocolStatus ProtocolStatus { get { return protocolStatus_; } set { protocolStatus_ = value; @@ -1779,7 +1860,7 @@ namespace MxGateway.Contracts.Proto { public override int GetHashCode() { int hash = 1; if (SessionId.Length != 0) hash ^= SessionId.GetHashCode(); - if (FinalState != global::MxGateway.Contracts.Proto.SessionState.Unspecified) hash ^= FinalState.GetHashCode(); + if (FinalState != global::ZB.MOM.WW.MxGateway.Contracts.Proto.SessionState.Unspecified) hash ^= FinalState.GetHashCode(); if (protocolStatus_ != null) hash ^= ProtocolStatus.GetHashCode(); if (_unknownFields != null) { hash ^= _unknownFields.GetHashCode(); @@ -1803,7 +1884,7 @@ namespace MxGateway.Contracts.Proto { output.WriteRawTag(10); output.WriteString(SessionId); } - if (FinalState != global::MxGateway.Contracts.Proto.SessionState.Unspecified) { + if (FinalState != global::ZB.MOM.WW.MxGateway.Contracts.Proto.SessionState.Unspecified) { output.WriteRawTag(16); output.WriteEnum((int) FinalState); } @@ -1825,7 +1906,7 @@ namespace MxGateway.Contracts.Proto { output.WriteRawTag(10); output.WriteString(SessionId); } - if (FinalState != global::MxGateway.Contracts.Proto.SessionState.Unspecified) { + if (FinalState != global::ZB.MOM.WW.MxGateway.Contracts.Proto.SessionState.Unspecified) { output.WriteRawTag(16); output.WriteEnum((int) FinalState); } @@ -1846,7 +1927,7 @@ namespace MxGateway.Contracts.Proto { if (SessionId.Length != 0) { size += 1 + pb::CodedOutputStream.ComputeStringSize(SessionId); } - if (FinalState != global::MxGateway.Contracts.Proto.SessionState.Unspecified) { + if (FinalState != global::ZB.MOM.WW.MxGateway.Contracts.Proto.SessionState.Unspecified) { size += 1 + pb::CodedOutputStream.ComputeEnumSize((int) FinalState); } if (protocolStatus_ != null) { @@ -1867,12 +1948,12 @@ namespace MxGateway.Contracts.Proto { if (other.SessionId.Length != 0) { SessionId = other.SessionId; } - if (other.FinalState != global::MxGateway.Contracts.Proto.SessionState.Unspecified) { + if (other.FinalState != global::ZB.MOM.WW.MxGateway.Contracts.Proto.SessionState.Unspecified) { FinalState = other.FinalState; } if (other.protocolStatus_ != null) { if (protocolStatus_ == null) { - ProtocolStatus = new global::MxGateway.Contracts.Proto.ProtocolStatus(); + ProtocolStatus = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.ProtocolStatus(); } ProtocolStatus.MergeFrom(other.ProtocolStatus); } @@ -1900,12 +1981,12 @@ namespace MxGateway.Contracts.Proto { break; } case 16: { - FinalState = (global::MxGateway.Contracts.Proto.SessionState) input.ReadEnum(); + FinalState = (global::ZB.MOM.WW.MxGateway.Contracts.Proto.SessionState) input.ReadEnum(); break; } case 26: { if (protocolStatus_ == null) { - ProtocolStatus = new global::MxGateway.Contracts.Proto.ProtocolStatus(); + ProtocolStatus = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.ProtocolStatus(); } input.ReadMessage(ProtocolStatus); break; @@ -1934,12 +2015,12 @@ namespace MxGateway.Contracts.Proto { break; } case 16: { - FinalState = (global::MxGateway.Contracts.Proto.SessionState) input.ReadEnum(); + FinalState = (global::ZB.MOM.WW.MxGateway.Contracts.Proto.SessionState) input.ReadEnum(); break; } case 26: { if (protocolStatus_ == null) { - ProtocolStatus = new global::MxGateway.Contracts.Proto.ProtocolStatus(); + ProtocolStatus = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.ProtocolStatus(); } input.ReadMessage(ProtocolStatus); break; @@ -1966,7 +2047,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[4]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[4]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -2201,7 +2282,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[5]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[5]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -2259,10 +2340,10 @@ namespace MxGateway.Contracts.Proto { /// Field number for the "command" field. public const int CommandFieldNumber = 3; - private global::MxGateway.Contracts.Proto.MxCommand command_; + private global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxCommand command_; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.MxCommand Command { + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxCommand Command { get { return command_; } set { command_ = value; @@ -2388,7 +2469,7 @@ namespace MxGateway.Contracts.Proto { } if (other.command_ != null) { if (command_ == null) { - Command = new global::MxGateway.Contracts.Proto.MxCommand(); + Command = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxCommand(); } Command.MergeFrom(other.Command); } @@ -2421,7 +2502,7 @@ namespace MxGateway.Contracts.Proto { } case 26: { if (command_ == null) { - Command = new global::MxGateway.Contracts.Proto.MxCommand(); + Command = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxCommand(); } input.ReadMessage(Command); break; @@ -2455,7 +2536,7 @@ namespace MxGateway.Contracts.Proto { } case 26: { if (command_ == null) { - Command = new global::MxGateway.Contracts.Proto.MxCommand(); + Command = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxCommand(); } input.ReadMessage(Command); break; @@ -2482,7 +2563,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[6]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[6]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -2591,6 +2672,21 @@ namespace MxGateway.Contracts.Proto { case PayloadOneofCase.AcknowledgeAlarmByNameCommand: AcknowledgeAlarmByNameCommand = other.AcknowledgeAlarmByNameCommand.Clone(); break; + case PayloadOneofCase.WriteBulk: + WriteBulk = other.WriteBulk.Clone(); + break; + case PayloadOneofCase.Write2Bulk: + Write2Bulk = other.Write2Bulk.Clone(); + break; + case PayloadOneofCase.WriteSecuredBulk: + WriteSecuredBulk = other.WriteSecuredBulk.Clone(); + break; + case PayloadOneofCase.WriteSecured2Bulk: + WriteSecured2Bulk = other.WriteSecured2Bulk.Clone(); + break; + case PayloadOneofCase.ReadBulk: + ReadBulk = other.ReadBulk.Clone(); + break; case PayloadOneofCase.Ping: Ping = other.Ping.Clone(); break; @@ -2619,10 +2715,10 @@ namespace MxGateway.Contracts.Proto { /// Field number for the "kind" field. public const int KindFieldNumber = 1; - private global::MxGateway.Contracts.Proto.MxCommandKind kind_ = global::MxGateway.Contracts.Proto.MxCommandKind.Unspecified; + private global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxCommandKind kind_ = global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxCommandKind.Unspecified; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.MxCommandKind Kind { + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxCommandKind Kind { get { return kind_; } set { kind_ = value; @@ -2633,8 +2729,8 @@ namespace MxGateway.Contracts.Proto { public const int RegisterFieldNumber = 10; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.RegisterCommand Register { - get { return payloadCase_ == PayloadOneofCase.Register ? (global::MxGateway.Contracts.Proto.RegisterCommand) payload_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.RegisterCommand Register { + get { return payloadCase_ == PayloadOneofCase.Register ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.RegisterCommand) payload_ : null; } set { payload_ = value; payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.Register; @@ -2645,8 +2741,8 @@ namespace MxGateway.Contracts.Proto { public const int UnregisterFieldNumber = 11; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.UnregisterCommand Unregister { - get { return payloadCase_ == PayloadOneofCase.Unregister ? (global::MxGateway.Contracts.Proto.UnregisterCommand) payload_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.UnregisterCommand Unregister { + get { return payloadCase_ == PayloadOneofCase.Unregister ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.UnregisterCommand) payload_ : null; } set { payload_ = value; payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.Unregister; @@ -2657,8 +2753,8 @@ namespace MxGateway.Contracts.Proto { public const int AddItemFieldNumber = 12; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.AddItemCommand AddItem { - get { return payloadCase_ == PayloadOneofCase.AddItem ? (global::MxGateway.Contracts.Proto.AddItemCommand) payload_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.AddItemCommand AddItem { + get { return payloadCase_ == PayloadOneofCase.AddItem ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.AddItemCommand) payload_ : null; } set { payload_ = value; payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.AddItem; @@ -2669,8 +2765,8 @@ namespace MxGateway.Contracts.Proto { public const int AddItem2FieldNumber = 13; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.AddItem2Command AddItem2 { - get { return payloadCase_ == PayloadOneofCase.AddItem2 ? (global::MxGateway.Contracts.Proto.AddItem2Command) payload_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.AddItem2Command AddItem2 { + get { return payloadCase_ == PayloadOneofCase.AddItem2 ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.AddItem2Command) payload_ : null; } set { payload_ = value; payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.AddItem2; @@ -2681,8 +2777,8 @@ namespace MxGateway.Contracts.Proto { public const int RemoveItemFieldNumber = 14; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.RemoveItemCommand RemoveItem { - get { return payloadCase_ == PayloadOneofCase.RemoveItem ? (global::MxGateway.Contracts.Proto.RemoveItemCommand) payload_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.RemoveItemCommand RemoveItem { + get { return payloadCase_ == PayloadOneofCase.RemoveItem ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.RemoveItemCommand) payload_ : null; } set { payload_ = value; payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.RemoveItem; @@ -2693,8 +2789,8 @@ namespace MxGateway.Contracts.Proto { public const int AdviseFieldNumber = 15; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.AdviseCommand Advise { - get { return payloadCase_ == PayloadOneofCase.Advise ? (global::MxGateway.Contracts.Proto.AdviseCommand) payload_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.AdviseCommand Advise { + get { return payloadCase_ == PayloadOneofCase.Advise ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.AdviseCommand) payload_ : null; } set { payload_ = value; payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.Advise; @@ -2705,8 +2801,8 @@ namespace MxGateway.Contracts.Proto { public const int UnAdviseFieldNumber = 16; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.UnAdviseCommand UnAdvise { - get { return payloadCase_ == PayloadOneofCase.UnAdvise ? (global::MxGateway.Contracts.Proto.UnAdviseCommand) payload_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.UnAdviseCommand UnAdvise { + get { return payloadCase_ == PayloadOneofCase.UnAdvise ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.UnAdviseCommand) payload_ : null; } set { payload_ = value; payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.UnAdvise; @@ -2717,8 +2813,8 @@ namespace MxGateway.Contracts.Proto { public const int AdviseSupervisoryFieldNumber = 17; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.AdviseSupervisoryCommand AdviseSupervisory { - get { return payloadCase_ == PayloadOneofCase.AdviseSupervisory ? (global::MxGateway.Contracts.Proto.AdviseSupervisoryCommand) payload_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.AdviseSupervisoryCommand AdviseSupervisory { + get { return payloadCase_ == PayloadOneofCase.AdviseSupervisory ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.AdviseSupervisoryCommand) payload_ : null; } set { payload_ = value; payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.AdviseSupervisory; @@ -2729,8 +2825,8 @@ namespace MxGateway.Contracts.Proto { public const int AddBufferedItemFieldNumber = 18; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.AddBufferedItemCommand AddBufferedItem { - get { return payloadCase_ == PayloadOneofCase.AddBufferedItem ? (global::MxGateway.Contracts.Proto.AddBufferedItemCommand) payload_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.AddBufferedItemCommand AddBufferedItem { + get { return payloadCase_ == PayloadOneofCase.AddBufferedItem ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.AddBufferedItemCommand) payload_ : null; } set { payload_ = value; payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.AddBufferedItem; @@ -2741,8 +2837,8 @@ namespace MxGateway.Contracts.Proto { public const int SetBufferedUpdateIntervalFieldNumber = 19; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.SetBufferedUpdateIntervalCommand SetBufferedUpdateInterval { - get { return payloadCase_ == PayloadOneofCase.SetBufferedUpdateInterval ? (global::MxGateway.Contracts.Proto.SetBufferedUpdateIntervalCommand) payload_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.SetBufferedUpdateIntervalCommand SetBufferedUpdateInterval { + get { return payloadCase_ == PayloadOneofCase.SetBufferedUpdateInterval ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.SetBufferedUpdateIntervalCommand) payload_ : null; } set { payload_ = value; payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.SetBufferedUpdateInterval; @@ -2753,8 +2849,8 @@ namespace MxGateway.Contracts.Proto { public const int SuspendFieldNumber = 20; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.SuspendCommand Suspend { - get { return payloadCase_ == PayloadOneofCase.Suspend ? (global::MxGateway.Contracts.Proto.SuspendCommand) payload_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.SuspendCommand Suspend { + get { return payloadCase_ == PayloadOneofCase.Suspend ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.SuspendCommand) payload_ : null; } set { payload_ = value; payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.Suspend; @@ -2765,8 +2861,8 @@ namespace MxGateway.Contracts.Proto { public const int ActivateFieldNumber = 21; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.ActivateCommand Activate { - get { return payloadCase_ == PayloadOneofCase.Activate ? (global::MxGateway.Contracts.Proto.ActivateCommand) payload_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.ActivateCommand Activate { + get { return payloadCase_ == PayloadOneofCase.Activate ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.ActivateCommand) payload_ : null; } set { payload_ = value; payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.Activate; @@ -2777,8 +2873,8 @@ namespace MxGateway.Contracts.Proto { public const int WriteFieldNumber = 22; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.WriteCommand Write { - get { return payloadCase_ == PayloadOneofCase.Write ? (global::MxGateway.Contracts.Proto.WriteCommand) payload_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.WriteCommand Write { + get { return payloadCase_ == PayloadOneofCase.Write ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.WriteCommand) payload_ : null; } set { payload_ = value; payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.Write; @@ -2789,8 +2885,8 @@ namespace MxGateway.Contracts.Proto { public const int Write2FieldNumber = 23; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.Write2Command Write2 { - get { return payloadCase_ == PayloadOneofCase.Write2 ? (global::MxGateway.Contracts.Proto.Write2Command) payload_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.Write2Command Write2 { + get { return payloadCase_ == PayloadOneofCase.Write2 ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.Write2Command) payload_ : null; } set { payload_ = value; payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.Write2; @@ -2801,8 +2897,8 @@ namespace MxGateway.Contracts.Proto { public const int WriteSecuredFieldNumber = 24; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.WriteSecuredCommand WriteSecured { - get { return payloadCase_ == PayloadOneofCase.WriteSecured ? (global::MxGateway.Contracts.Proto.WriteSecuredCommand) payload_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.WriteSecuredCommand WriteSecured { + get { return payloadCase_ == PayloadOneofCase.WriteSecured ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.WriteSecuredCommand) payload_ : null; } set { payload_ = value; payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.WriteSecured; @@ -2813,8 +2909,8 @@ namespace MxGateway.Contracts.Proto { public const int WriteSecured2FieldNumber = 25; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.WriteSecured2Command WriteSecured2 { - get { return payloadCase_ == PayloadOneofCase.WriteSecured2 ? (global::MxGateway.Contracts.Proto.WriteSecured2Command) payload_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.WriteSecured2Command WriteSecured2 { + get { return payloadCase_ == PayloadOneofCase.WriteSecured2 ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.WriteSecured2Command) payload_ : null; } set { payload_ = value; payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.WriteSecured2; @@ -2825,8 +2921,8 @@ namespace MxGateway.Contracts.Proto { public const int AuthenticateUserFieldNumber = 26; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.AuthenticateUserCommand AuthenticateUser { - get { return payloadCase_ == PayloadOneofCase.AuthenticateUser ? (global::MxGateway.Contracts.Proto.AuthenticateUserCommand) payload_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.AuthenticateUserCommand AuthenticateUser { + get { return payloadCase_ == PayloadOneofCase.AuthenticateUser ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.AuthenticateUserCommand) payload_ : null; } set { payload_ = value; payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.AuthenticateUser; @@ -2837,8 +2933,8 @@ namespace MxGateway.Contracts.Proto { public const int ArchestraUserToIdFieldNumber = 27; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.ArchestrAUserToIdCommand ArchestraUserToId { - get { return payloadCase_ == PayloadOneofCase.ArchestraUserToId ? (global::MxGateway.Contracts.Proto.ArchestrAUserToIdCommand) payload_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.ArchestrAUserToIdCommand ArchestraUserToId { + get { return payloadCase_ == PayloadOneofCase.ArchestraUserToId ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.ArchestrAUserToIdCommand) payload_ : null; } set { payload_ = value; payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.ArchestraUserToId; @@ -2849,8 +2945,8 @@ namespace MxGateway.Contracts.Proto { public const int AddItemBulkFieldNumber = 28; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.AddItemBulkCommand AddItemBulk { - get { return payloadCase_ == PayloadOneofCase.AddItemBulk ? (global::MxGateway.Contracts.Proto.AddItemBulkCommand) payload_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.AddItemBulkCommand AddItemBulk { + get { return payloadCase_ == PayloadOneofCase.AddItemBulk ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.AddItemBulkCommand) payload_ : null; } set { payload_ = value; payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.AddItemBulk; @@ -2861,8 +2957,8 @@ namespace MxGateway.Contracts.Proto { public const int AdviseItemBulkFieldNumber = 29; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.AdviseItemBulkCommand AdviseItemBulk { - get { return payloadCase_ == PayloadOneofCase.AdviseItemBulk ? (global::MxGateway.Contracts.Proto.AdviseItemBulkCommand) payload_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.AdviseItemBulkCommand AdviseItemBulk { + get { return payloadCase_ == PayloadOneofCase.AdviseItemBulk ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.AdviseItemBulkCommand) payload_ : null; } set { payload_ = value; payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.AdviseItemBulk; @@ -2873,8 +2969,8 @@ namespace MxGateway.Contracts.Proto { public const int RemoveItemBulkFieldNumber = 30; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.RemoveItemBulkCommand RemoveItemBulk { - get { return payloadCase_ == PayloadOneofCase.RemoveItemBulk ? (global::MxGateway.Contracts.Proto.RemoveItemBulkCommand) payload_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.RemoveItemBulkCommand RemoveItemBulk { + get { return payloadCase_ == PayloadOneofCase.RemoveItemBulk ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.RemoveItemBulkCommand) payload_ : null; } set { payload_ = value; payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.RemoveItemBulk; @@ -2885,8 +2981,8 @@ namespace MxGateway.Contracts.Proto { public const int UnAdviseItemBulkFieldNumber = 31; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.UnAdviseItemBulkCommand UnAdviseItemBulk { - get { return payloadCase_ == PayloadOneofCase.UnAdviseItemBulk ? (global::MxGateway.Contracts.Proto.UnAdviseItemBulkCommand) payload_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.UnAdviseItemBulkCommand UnAdviseItemBulk { + get { return payloadCase_ == PayloadOneofCase.UnAdviseItemBulk ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.UnAdviseItemBulkCommand) payload_ : null; } set { payload_ = value; payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.UnAdviseItemBulk; @@ -2897,8 +2993,8 @@ namespace MxGateway.Contracts.Proto { public const int SubscribeBulkFieldNumber = 32; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.SubscribeBulkCommand SubscribeBulk { - get { return payloadCase_ == PayloadOneofCase.SubscribeBulk ? (global::MxGateway.Contracts.Proto.SubscribeBulkCommand) payload_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.SubscribeBulkCommand SubscribeBulk { + get { return payloadCase_ == PayloadOneofCase.SubscribeBulk ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.SubscribeBulkCommand) payload_ : null; } set { payload_ = value; payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.SubscribeBulk; @@ -2909,8 +3005,8 @@ namespace MxGateway.Contracts.Proto { public const int UnsubscribeBulkFieldNumber = 33; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.UnsubscribeBulkCommand UnsubscribeBulk { - get { return payloadCase_ == PayloadOneofCase.UnsubscribeBulk ? (global::MxGateway.Contracts.Proto.UnsubscribeBulkCommand) payload_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.UnsubscribeBulkCommand UnsubscribeBulk { + get { return payloadCase_ == PayloadOneofCase.UnsubscribeBulk ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.UnsubscribeBulkCommand) payload_ : null; } set { payload_ = value; payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.UnsubscribeBulk; @@ -2921,8 +3017,8 @@ namespace MxGateway.Contracts.Proto { public const int SubscribeAlarmsFieldNumber = 34; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.SubscribeAlarmsCommand SubscribeAlarms { - get { return payloadCase_ == PayloadOneofCase.SubscribeAlarms ? (global::MxGateway.Contracts.Proto.SubscribeAlarmsCommand) payload_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.SubscribeAlarmsCommand SubscribeAlarms { + get { return payloadCase_ == PayloadOneofCase.SubscribeAlarms ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.SubscribeAlarmsCommand) payload_ : null; } set { payload_ = value; payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.SubscribeAlarms; @@ -2933,8 +3029,8 @@ namespace MxGateway.Contracts.Proto { public const int UnsubscribeAlarmsFieldNumber = 35; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.UnsubscribeAlarmsCommand UnsubscribeAlarms { - get { return payloadCase_ == PayloadOneofCase.UnsubscribeAlarms ? (global::MxGateway.Contracts.Proto.UnsubscribeAlarmsCommand) payload_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.UnsubscribeAlarmsCommand UnsubscribeAlarms { + get { return payloadCase_ == PayloadOneofCase.UnsubscribeAlarms ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.UnsubscribeAlarmsCommand) payload_ : null; } set { payload_ = value; payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.UnsubscribeAlarms; @@ -2945,8 +3041,8 @@ namespace MxGateway.Contracts.Proto { public const int AcknowledgeAlarmCommandFieldNumber = 36; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.AcknowledgeAlarmCommand AcknowledgeAlarmCommand { - get { return payloadCase_ == PayloadOneofCase.AcknowledgeAlarmCommand ? (global::MxGateway.Contracts.Proto.AcknowledgeAlarmCommand) payload_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmCommand AcknowledgeAlarmCommand { + get { return payloadCase_ == PayloadOneofCase.AcknowledgeAlarmCommand ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmCommand) payload_ : null; } set { payload_ = value; payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.AcknowledgeAlarmCommand; @@ -2957,8 +3053,8 @@ namespace MxGateway.Contracts.Proto { public const int QueryActiveAlarmsCommandFieldNumber = 37; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.QueryActiveAlarmsCommand QueryActiveAlarmsCommand { - get { return payloadCase_ == PayloadOneofCase.QueryActiveAlarmsCommand ? (global::MxGateway.Contracts.Proto.QueryActiveAlarmsCommand) payload_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.QueryActiveAlarmsCommand QueryActiveAlarmsCommand { + get { return payloadCase_ == PayloadOneofCase.QueryActiveAlarmsCommand ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.QueryActiveAlarmsCommand) payload_ : null; } set { payload_ = value; payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.QueryActiveAlarmsCommand; @@ -2969,20 +3065,80 @@ namespace MxGateway.Contracts.Proto { public const int AcknowledgeAlarmByNameCommandFieldNumber = 38; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.AcknowledgeAlarmByNameCommand AcknowledgeAlarmByNameCommand { - get { return payloadCase_ == PayloadOneofCase.AcknowledgeAlarmByNameCommand ? (global::MxGateway.Contracts.Proto.AcknowledgeAlarmByNameCommand) payload_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmByNameCommand AcknowledgeAlarmByNameCommand { + get { return payloadCase_ == PayloadOneofCase.AcknowledgeAlarmByNameCommand ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmByNameCommand) payload_ : null; } set { payload_ = value; payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.AcknowledgeAlarmByNameCommand; } } + /// Field number for the "write_bulk" field. + public const int WriteBulkFieldNumber = 39; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.WriteBulkCommand WriteBulk { + get { return payloadCase_ == PayloadOneofCase.WriteBulk ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.WriteBulkCommand) payload_ : null; } + set { + payload_ = value; + payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.WriteBulk; + } + } + + /// Field number for the "write2_bulk" field. + public const int Write2BulkFieldNumber = 40; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.Write2BulkCommand Write2Bulk { + get { return payloadCase_ == PayloadOneofCase.Write2Bulk ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.Write2BulkCommand) payload_ : null; } + set { + payload_ = value; + payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.Write2Bulk; + } + } + + /// Field number for the "write_secured_bulk" field. + public const int WriteSecuredBulkFieldNumber = 41; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.WriteSecuredBulkCommand WriteSecuredBulk { + get { return payloadCase_ == PayloadOneofCase.WriteSecuredBulk ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.WriteSecuredBulkCommand) payload_ : null; } + set { + payload_ = value; + payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.WriteSecuredBulk; + } + } + + /// Field number for the "write_secured2_bulk" field. + public const int WriteSecured2BulkFieldNumber = 42; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.WriteSecured2BulkCommand WriteSecured2Bulk { + get { return payloadCase_ == PayloadOneofCase.WriteSecured2Bulk ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.WriteSecured2BulkCommand) payload_ : null; } + set { + payload_ = value; + payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.WriteSecured2Bulk; + } + } + + /// Field number for the "read_bulk" field. + public const int ReadBulkFieldNumber = 43; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.ReadBulkCommand ReadBulk { + get { return payloadCase_ == PayloadOneofCase.ReadBulk ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.ReadBulkCommand) payload_ : null; } + set { + payload_ = value; + payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.ReadBulk; + } + } + /// Field number for the "ping" field. public const int PingFieldNumber = 100; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.PingCommand Ping { - get { return payloadCase_ == PayloadOneofCase.Ping ? (global::MxGateway.Contracts.Proto.PingCommand) payload_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.PingCommand Ping { + get { return payloadCase_ == PayloadOneofCase.Ping ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.PingCommand) payload_ : null; } set { payload_ = value; payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.Ping; @@ -2993,8 +3149,8 @@ namespace MxGateway.Contracts.Proto { public const int GetSessionStateFieldNumber = 101; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.GetSessionStateCommand GetSessionState { - get { return payloadCase_ == PayloadOneofCase.GetSessionState ? (global::MxGateway.Contracts.Proto.GetSessionStateCommand) payload_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.GetSessionStateCommand GetSessionState { + get { return payloadCase_ == PayloadOneofCase.GetSessionState ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.GetSessionStateCommand) payload_ : null; } set { payload_ = value; payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.GetSessionState; @@ -3005,8 +3161,8 @@ namespace MxGateway.Contracts.Proto { public const int GetWorkerInfoFieldNumber = 102; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.GetWorkerInfoCommand GetWorkerInfo { - get { return payloadCase_ == PayloadOneofCase.GetWorkerInfo ? (global::MxGateway.Contracts.Proto.GetWorkerInfoCommand) payload_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.GetWorkerInfoCommand GetWorkerInfo { + get { return payloadCase_ == PayloadOneofCase.GetWorkerInfo ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.GetWorkerInfoCommand) payload_ : null; } set { payload_ = value; payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.GetWorkerInfo; @@ -3017,8 +3173,8 @@ namespace MxGateway.Contracts.Proto { public const int DrainEventsFieldNumber = 103; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.DrainEventsCommand DrainEvents { - get { return payloadCase_ == PayloadOneofCase.DrainEvents ? (global::MxGateway.Contracts.Proto.DrainEventsCommand) payload_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.DrainEventsCommand DrainEvents { + get { return payloadCase_ == PayloadOneofCase.DrainEvents ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.DrainEventsCommand) payload_ : null; } set { payload_ = value; payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.DrainEvents; @@ -3029,8 +3185,8 @@ namespace MxGateway.Contracts.Proto { public const int ShutdownWorkerFieldNumber = 104; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.ShutdownWorkerCommand ShutdownWorker { - get { return payloadCase_ == PayloadOneofCase.ShutdownWorker ? (global::MxGateway.Contracts.Proto.ShutdownWorkerCommand) payload_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.ShutdownWorkerCommand ShutdownWorker { + get { return payloadCase_ == PayloadOneofCase.ShutdownWorker ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.ShutdownWorkerCommand) payload_ : null; } set { payload_ = value; payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.ShutdownWorker; @@ -3070,6 +3226,11 @@ namespace MxGateway.Contracts.Proto { AcknowledgeAlarmCommand = 36, QueryActiveAlarmsCommand = 37, AcknowledgeAlarmByNameCommand = 38, + WriteBulk = 39, + Write2Bulk = 40, + WriteSecuredBulk = 41, + WriteSecured2Bulk = 42, + ReadBulk = 43, Ping = 100, GetSessionState = 101, GetWorkerInfo = 102, @@ -3135,6 +3296,11 @@ namespace MxGateway.Contracts.Proto { if (!object.Equals(AcknowledgeAlarmCommand, other.AcknowledgeAlarmCommand)) return false; if (!object.Equals(QueryActiveAlarmsCommand, other.QueryActiveAlarmsCommand)) return false; if (!object.Equals(AcknowledgeAlarmByNameCommand, other.AcknowledgeAlarmByNameCommand)) return false; + if (!object.Equals(WriteBulk, other.WriteBulk)) return false; + if (!object.Equals(Write2Bulk, other.Write2Bulk)) return false; + if (!object.Equals(WriteSecuredBulk, other.WriteSecuredBulk)) return false; + if (!object.Equals(WriteSecured2Bulk, other.WriteSecured2Bulk)) return false; + if (!object.Equals(ReadBulk, other.ReadBulk)) return false; if (!object.Equals(Ping, other.Ping)) return false; if (!object.Equals(GetSessionState, other.GetSessionState)) return false; if (!object.Equals(GetWorkerInfo, other.GetWorkerInfo)) return false; @@ -3148,7 +3314,7 @@ namespace MxGateway.Contracts.Proto { [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public override int GetHashCode() { int hash = 1; - if (Kind != global::MxGateway.Contracts.Proto.MxCommandKind.Unspecified) hash ^= Kind.GetHashCode(); + if (Kind != global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxCommandKind.Unspecified) hash ^= Kind.GetHashCode(); if (payloadCase_ == PayloadOneofCase.Register) hash ^= Register.GetHashCode(); if (payloadCase_ == PayloadOneofCase.Unregister) hash ^= Unregister.GetHashCode(); if (payloadCase_ == PayloadOneofCase.AddItem) hash ^= AddItem.GetHashCode(); @@ -3178,6 +3344,11 @@ namespace MxGateway.Contracts.Proto { if (payloadCase_ == PayloadOneofCase.AcknowledgeAlarmCommand) hash ^= AcknowledgeAlarmCommand.GetHashCode(); if (payloadCase_ == PayloadOneofCase.QueryActiveAlarmsCommand) hash ^= QueryActiveAlarmsCommand.GetHashCode(); if (payloadCase_ == PayloadOneofCase.AcknowledgeAlarmByNameCommand) hash ^= AcknowledgeAlarmByNameCommand.GetHashCode(); + if (payloadCase_ == PayloadOneofCase.WriteBulk) hash ^= WriteBulk.GetHashCode(); + if (payloadCase_ == PayloadOneofCase.Write2Bulk) hash ^= Write2Bulk.GetHashCode(); + if (payloadCase_ == PayloadOneofCase.WriteSecuredBulk) hash ^= WriteSecuredBulk.GetHashCode(); + if (payloadCase_ == PayloadOneofCase.WriteSecured2Bulk) hash ^= WriteSecured2Bulk.GetHashCode(); + if (payloadCase_ == PayloadOneofCase.ReadBulk) hash ^= ReadBulk.GetHashCode(); if (payloadCase_ == PayloadOneofCase.Ping) hash ^= Ping.GetHashCode(); if (payloadCase_ == PayloadOneofCase.GetSessionState) hash ^= GetSessionState.GetHashCode(); if (payloadCase_ == PayloadOneofCase.GetWorkerInfo) hash ^= GetWorkerInfo.GetHashCode(); @@ -3202,7 +3373,7 @@ namespace MxGateway.Contracts.Proto { #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE output.WriteRawMessage(this); #else - if (Kind != global::MxGateway.Contracts.Proto.MxCommandKind.Unspecified) { + if (Kind != global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxCommandKind.Unspecified) { output.WriteRawTag(8); output.WriteEnum((int) Kind); } @@ -3322,6 +3493,26 @@ namespace MxGateway.Contracts.Proto { output.WriteRawTag(178, 2); output.WriteMessage(AcknowledgeAlarmByNameCommand); } + if (payloadCase_ == PayloadOneofCase.WriteBulk) { + output.WriteRawTag(186, 2); + output.WriteMessage(WriteBulk); + } + if (payloadCase_ == PayloadOneofCase.Write2Bulk) { + output.WriteRawTag(194, 2); + output.WriteMessage(Write2Bulk); + } + if (payloadCase_ == PayloadOneofCase.WriteSecuredBulk) { + output.WriteRawTag(202, 2); + output.WriteMessage(WriteSecuredBulk); + } + if (payloadCase_ == PayloadOneofCase.WriteSecured2Bulk) { + output.WriteRawTag(210, 2); + output.WriteMessage(WriteSecured2Bulk); + } + if (payloadCase_ == PayloadOneofCase.ReadBulk) { + output.WriteRawTag(218, 2); + output.WriteMessage(ReadBulk); + } if (payloadCase_ == PayloadOneofCase.Ping) { output.WriteRawTag(162, 6); output.WriteMessage(Ping); @@ -3352,7 +3543,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { - if (Kind != global::MxGateway.Contracts.Proto.MxCommandKind.Unspecified) { + if (Kind != global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxCommandKind.Unspecified) { output.WriteRawTag(8); output.WriteEnum((int) Kind); } @@ -3472,6 +3663,26 @@ namespace MxGateway.Contracts.Proto { output.WriteRawTag(178, 2); output.WriteMessage(AcknowledgeAlarmByNameCommand); } + if (payloadCase_ == PayloadOneofCase.WriteBulk) { + output.WriteRawTag(186, 2); + output.WriteMessage(WriteBulk); + } + if (payloadCase_ == PayloadOneofCase.Write2Bulk) { + output.WriteRawTag(194, 2); + output.WriteMessage(Write2Bulk); + } + if (payloadCase_ == PayloadOneofCase.WriteSecuredBulk) { + output.WriteRawTag(202, 2); + output.WriteMessage(WriteSecuredBulk); + } + if (payloadCase_ == PayloadOneofCase.WriteSecured2Bulk) { + output.WriteRawTag(210, 2); + output.WriteMessage(WriteSecured2Bulk); + } + if (payloadCase_ == PayloadOneofCase.ReadBulk) { + output.WriteRawTag(218, 2); + output.WriteMessage(ReadBulk); + } if (payloadCase_ == PayloadOneofCase.Ping) { output.WriteRawTag(162, 6); output.WriteMessage(Ping); @@ -3502,7 +3713,7 @@ namespace MxGateway.Contracts.Proto { [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public int CalculateSize() { int size = 0; - if (Kind != global::MxGateway.Contracts.Proto.MxCommandKind.Unspecified) { + if (Kind != global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxCommandKind.Unspecified) { size += 1 + pb::CodedOutputStream.ComputeEnumSize((int) Kind); } if (payloadCase_ == PayloadOneofCase.Register) { @@ -3592,6 +3803,21 @@ namespace MxGateway.Contracts.Proto { if (payloadCase_ == PayloadOneofCase.AcknowledgeAlarmByNameCommand) { size += 2 + pb::CodedOutputStream.ComputeMessageSize(AcknowledgeAlarmByNameCommand); } + if (payloadCase_ == PayloadOneofCase.WriteBulk) { + size += 2 + pb::CodedOutputStream.ComputeMessageSize(WriteBulk); + } + if (payloadCase_ == PayloadOneofCase.Write2Bulk) { + size += 2 + pb::CodedOutputStream.ComputeMessageSize(Write2Bulk); + } + if (payloadCase_ == PayloadOneofCase.WriteSecuredBulk) { + size += 2 + pb::CodedOutputStream.ComputeMessageSize(WriteSecuredBulk); + } + if (payloadCase_ == PayloadOneofCase.WriteSecured2Bulk) { + size += 2 + pb::CodedOutputStream.ComputeMessageSize(WriteSecured2Bulk); + } + if (payloadCase_ == PayloadOneofCase.ReadBulk) { + size += 2 + pb::CodedOutputStream.ComputeMessageSize(ReadBulk); + } if (payloadCase_ == PayloadOneofCase.Ping) { size += 2 + pb::CodedOutputStream.ComputeMessageSize(Ping); } @@ -3619,211 +3845,241 @@ namespace MxGateway.Contracts.Proto { if (other == null) { return; } - if (other.Kind != global::MxGateway.Contracts.Proto.MxCommandKind.Unspecified) { + if (other.Kind != global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxCommandKind.Unspecified) { Kind = other.Kind; } switch (other.PayloadCase) { case PayloadOneofCase.Register: if (Register == null) { - Register = new global::MxGateway.Contracts.Proto.RegisterCommand(); + Register = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.RegisterCommand(); } Register.MergeFrom(other.Register); break; case PayloadOneofCase.Unregister: if (Unregister == null) { - Unregister = new global::MxGateway.Contracts.Proto.UnregisterCommand(); + Unregister = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.UnregisterCommand(); } Unregister.MergeFrom(other.Unregister); break; case PayloadOneofCase.AddItem: if (AddItem == null) { - AddItem = new global::MxGateway.Contracts.Proto.AddItemCommand(); + AddItem = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.AddItemCommand(); } AddItem.MergeFrom(other.AddItem); break; case PayloadOneofCase.AddItem2: if (AddItem2 == null) { - AddItem2 = new global::MxGateway.Contracts.Proto.AddItem2Command(); + AddItem2 = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.AddItem2Command(); } AddItem2.MergeFrom(other.AddItem2); break; case PayloadOneofCase.RemoveItem: if (RemoveItem == null) { - RemoveItem = new global::MxGateway.Contracts.Proto.RemoveItemCommand(); + RemoveItem = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.RemoveItemCommand(); } RemoveItem.MergeFrom(other.RemoveItem); break; case PayloadOneofCase.Advise: if (Advise == null) { - Advise = new global::MxGateway.Contracts.Proto.AdviseCommand(); + Advise = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.AdviseCommand(); } Advise.MergeFrom(other.Advise); break; case PayloadOneofCase.UnAdvise: if (UnAdvise == null) { - UnAdvise = new global::MxGateway.Contracts.Proto.UnAdviseCommand(); + UnAdvise = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.UnAdviseCommand(); } UnAdvise.MergeFrom(other.UnAdvise); break; case PayloadOneofCase.AdviseSupervisory: if (AdviseSupervisory == null) { - AdviseSupervisory = new global::MxGateway.Contracts.Proto.AdviseSupervisoryCommand(); + AdviseSupervisory = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.AdviseSupervisoryCommand(); } AdviseSupervisory.MergeFrom(other.AdviseSupervisory); break; case PayloadOneofCase.AddBufferedItem: if (AddBufferedItem == null) { - AddBufferedItem = new global::MxGateway.Contracts.Proto.AddBufferedItemCommand(); + AddBufferedItem = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.AddBufferedItemCommand(); } AddBufferedItem.MergeFrom(other.AddBufferedItem); break; case PayloadOneofCase.SetBufferedUpdateInterval: if (SetBufferedUpdateInterval == null) { - SetBufferedUpdateInterval = new global::MxGateway.Contracts.Proto.SetBufferedUpdateIntervalCommand(); + SetBufferedUpdateInterval = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.SetBufferedUpdateIntervalCommand(); } SetBufferedUpdateInterval.MergeFrom(other.SetBufferedUpdateInterval); break; case PayloadOneofCase.Suspend: if (Suspend == null) { - Suspend = new global::MxGateway.Contracts.Proto.SuspendCommand(); + Suspend = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.SuspendCommand(); } Suspend.MergeFrom(other.Suspend); break; case PayloadOneofCase.Activate: if (Activate == null) { - Activate = new global::MxGateway.Contracts.Proto.ActivateCommand(); + Activate = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.ActivateCommand(); } Activate.MergeFrom(other.Activate); break; case PayloadOneofCase.Write: if (Write == null) { - Write = new global::MxGateway.Contracts.Proto.WriteCommand(); + Write = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.WriteCommand(); } Write.MergeFrom(other.Write); break; case PayloadOneofCase.Write2: if (Write2 == null) { - Write2 = new global::MxGateway.Contracts.Proto.Write2Command(); + Write2 = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.Write2Command(); } Write2.MergeFrom(other.Write2); break; case PayloadOneofCase.WriteSecured: if (WriteSecured == null) { - WriteSecured = new global::MxGateway.Contracts.Proto.WriteSecuredCommand(); + WriteSecured = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.WriteSecuredCommand(); } WriteSecured.MergeFrom(other.WriteSecured); break; case PayloadOneofCase.WriteSecured2: if (WriteSecured2 == null) { - WriteSecured2 = new global::MxGateway.Contracts.Proto.WriteSecured2Command(); + WriteSecured2 = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.WriteSecured2Command(); } WriteSecured2.MergeFrom(other.WriteSecured2); break; case PayloadOneofCase.AuthenticateUser: if (AuthenticateUser == null) { - AuthenticateUser = new global::MxGateway.Contracts.Proto.AuthenticateUserCommand(); + AuthenticateUser = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.AuthenticateUserCommand(); } AuthenticateUser.MergeFrom(other.AuthenticateUser); break; case PayloadOneofCase.ArchestraUserToId: if (ArchestraUserToId == null) { - ArchestraUserToId = new global::MxGateway.Contracts.Proto.ArchestrAUserToIdCommand(); + ArchestraUserToId = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.ArchestrAUserToIdCommand(); } ArchestraUserToId.MergeFrom(other.ArchestraUserToId); break; case PayloadOneofCase.AddItemBulk: if (AddItemBulk == null) { - AddItemBulk = new global::MxGateway.Contracts.Proto.AddItemBulkCommand(); + AddItemBulk = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.AddItemBulkCommand(); } AddItemBulk.MergeFrom(other.AddItemBulk); break; case PayloadOneofCase.AdviseItemBulk: if (AdviseItemBulk == null) { - AdviseItemBulk = new global::MxGateway.Contracts.Proto.AdviseItemBulkCommand(); + AdviseItemBulk = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.AdviseItemBulkCommand(); } AdviseItemBulk.MergeFrom(other.AdviseItemBulk); break; case PayloadOneofCase.RemoveItemBulk: if (RemoveItemBulk == null) { - RemoveItemBulk = new global::MxGateway.Contracts.Proto.RemoveItemBulkCommand(); + RemoveItemBulk = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.RemoveItemBulkCommand(); } RemoveItemBulk.MergeFrom(other.RemoveItemBulk); break; case PayloadOneofCase.UnAdviseItemBulk: if (UnAdviseItemBulk == null) { - UnAdviseItemBulk = new global::MxGateway.Contracts.Proto.UnAdviseItemBulkCommand(); + UnAdviseItemBulk = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.UnAdviseItemBulkCommand(); } UnAdviseItemBulk.MergeFrom(other.UnAdviseItemBulk); break; case PayloadOneofCase.SubscribeBulk: if (SubscribeBulk == null) { - SubscribeBulk = new global::MxGateway.Contracts.Proto.SubscribeBulkCommand(); + SubscribeBulk = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.SubscribeBulkCommand(); } SubscribeBulk.MergeFrom(other.SubscribeBulk); break; case PayloadOneofCase.UnsubscribeBulk: if (UnsubscribeBulk == null) { - UnsubscribeBulk = new global::MxGateway.Contracts.Proto.UnsubscribeBulkCommand(); + UnsubscribeBulk = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.UnsubscribeBulkCommand(); } UnsubscribeBulk.MergeFrom(other.UnsubscribeBulk); break; case PayloadOneofCase.SubscribeAlarms: if (SubscribeAlarms == null) { - SubscribeAlarms = new global::MxGateway.Contracts.Proto.SubscribeAlarmsCommand(); + SubscribeAlarms = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.SubscribeAlarmsCommand(); } SubscribeAlarms.MergeFrom(other.SubscribeAlarms); break; case PayloadOneofCase.UnsubscribeAlarms: if (UnsubscribeAlarms == null) { - UnsubscribeAlarms = new global::MxGateway.Contracts.Proto.UnsubscribeAlarmsCommand(); + UnsubscribeAlarms = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.UnsubscribeAlarmsCommand(); } UnsubscribeAlarms.MergeFrom(other.UnsubscribeAlarms); break; case PayloadOneofCase.AcknowledgeAlarmCommand: if (AcknowledgeAlarmCommand == null) { - AcknowledgeAlarmCommand = new global::MxGateway.Contracts.Proto.AcknowledgeAlarmCommand(); + AcknowledgeAlarmCommand = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmCommand(); } AcknowledgeAlarmCommand.MergeFrom(other.AcknowledgeAlarmCommand); break; case PayloadOneofCase.QueryActiveAlarmsCommand: if (QueryActiveAlarmsCommand == null) { - QueryActiveAlarmsCommand = new global::MxGateway.Contracts.Proto.QueryActiveAlarmsCommand(); + QueryActiveAlarmsCommand = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.QueryActiveAlarmsCommand(); } QueryActiveAlarmsCommand.MergeFrom(other.QueryActiveAlarmsCommand); break; case PayloadOneofCase.AcknowledgeAlarmByNameCommand: if (AcknowledgeAlarmByNameCommand == null) { - AcknowledgeAlarmByNameCommand = new global::MxGateway.Contracts.Proto.AcknowledgeAlarmByNameCommand(); + AcknowledgeAlarmByNameCommand = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmByNameCommand(); } AcknowledgeAlarmByNameCommand.MergeFrom(other.AcknowledgeAlarmByNameCommand); break; + case PayloadOneofCase.WriteBulk: + if (WriteBulk == null) { + WriteBulk = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.WriteBulkCommand(); + } + WriteBulk.MergeFrom(other.WriteBulk); + break; + case PayloadOneofCase.Write2Bulk: + if (Write2Bulk == null) { + Write2Bulk = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.Write2BulkCommand(); + } + Write2Bulk.MergeFrom(other.Write2Bulk); + break; + case PayloadOneofCase.WriteSecuredBulk: + if (WriteSecuredBulk == null) { + WriteSecuredBulk = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.WriteSecuredBulkCommand(); + } + WriteSecuredBulk.MergeFrom(other.WriteSecuredBulk); + break; + case PayloadOneofCase.WriteSecured2Bulk: + if (WriteSecured2Bulk == null) { + WriteSecured2Bulk = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.WriteSecured2BulkCommand(); + } + WriteSecured2Bulk.MergeFrom(other.WriteSecured2Bulk); + break; + case PayloadOneofCase.ReadBulk: + if (ReadBulk == null) { + ReadBulk = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.ReadBulkCommand(); + } + ReadBulk.MergeFrom(other.ReadBulk); + break; case PayloadOneofCase.Ping: if (Ping == null) { - Ping = new global::MxGateway.Contracts.Proto.PingCommand(); + Ping = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.PingCommand(); } Ping.MergeFrom(other.Ping); break; case PayloadOneofCase.GetSessionState: if (GetSessionState == null) { - GetSessionState = new global::MxGateway.Contracts.Proto.GetSessionStateCommand(); + GetSessionState = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.GetSessionStateCommand(); } GetSessionState.MergeFrom(other.GetSessionState); break; case PayloadOneofCase.GetWorkerInfo: if (GetWorkerInfo == null) { - GetWorkerInfo = new global::MxGateway.Contracts.Proto.GetWorkerInfoCommand(); + GetWorkerInfo = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.GetWorkerInfoCommand(); } GetWorkerInfo.MergeFrom(other.GetWorkerInfo); break; case PayloadOneofCase.DrainEvents: if (DrainEvents == null) { - DrainEvents = new global::MxGateway.Contracts.Proto.DrainEventsCommand(); + DrainEvents = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.DrainEventsCommand(); } DrainEvents.MergeFrom(other.DrainEvents); break; case PayloadOneofCase.ShutdownWorker: if (ShutdownWorker == null) { - ShutdownWorker = new global::MxGateway.Contracts.Proto.ShutdownWorkerCommand(); + ShutdownWorker = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.ShutdownWorkerCommand(); } ShutdownWorker.MergeFrom(other.ShutdownWorker); break; @@ -3849,11 +4105,11 @@ namespace MxGateway.Contracts.Proto { _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); break; case 8: { - Kind = (global::MxGateway.Contracts.Proto.MxCommandKind) input.ReadEnum(); + Kind = (global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxCommandKind) input.ReadEnum(); break; } case 82: { - global::MxGateway.Contracts.Proto.RegisterCommand subBuilder = new global::MxGateway.Contracts.Proto.RegisterCommand(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.RegisterCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.RegisterCommand(); if (payloadCase_ == PayloadOneofCase.Register) { subBuilder.MergeFrom(Register); } @@ -3862,7 +4118,7 @@ namespace MxGateway.Contracts.Proto { break; } case 90: { - global::MxGateway.Contracts.Proto.UnregisterCommand subBuilder = new global::MxGateway.Contracts.Proto.UnregisterCommand(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.UnregisterCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.UnregisterCommand(); if (payloadCase_ == PayloadOneofCase.Unregister) { subBuilder.MergeFrom(Unregister); } @@ -3871,7 +4127,7 @@ namespace MxGateway.Contracts.Proto { break; } case 98: { - global::MxGateway.Contracts.Proto.AddItemCommand subBuilder = new global::MxGateway.Contracts.Proto.AddItemCommand(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.AddItemCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.AddItemCommand(); if (payloadCase_ == PayloadOneofCase.AddItem) { subBuilder.MergeFrom(AddItem); } @@ -3880,7 +4136,7 @@ namespace MxGateway.Contracts.Proto { break; } case 106: { - global::MxGateway.Contracts.Proto.AddItem2Command subBuilder = new global::MxGateway.Contracts.Proto.AddItem2Command(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.AddItem2Command subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.AddItem2Command(); if (payloadCase_ == PayloadOneofCase.AddItem2) { subBuilder.MergeFrom(AddItem2); } @@ -3889,7 +4145,7 @@ namespace MxGateway.Contracts.Proto { break; } case 114: { - global::MxGateway.Contracts.Proto.RemoveItemCommand subBuilder = new global::MxGateway.Contracts.Proto.RemoveItemCommand(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.RemoveItemCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.RemoveItemCommand(); if (payloadCase_ == PayloadOneofCase.RemoveItem) { subBuilder.MergeFrom(RemoveItem); } @@ -3898,7 +4154,7 @@ namespace MxGateway.Contracts.Proto { break; } case 122: { - global::MxGateway.Contracts.Proto.AdviseCommand subBuilder = new global::MxGateway.Contracts.Proto.AdviseCommand(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.AdviseCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.AdviseCommand(); if (payloadCase_ == PayloadOneofCase.Advise) { subBuilder.MergeFrom(Advise); } @@ -3907,7 +4163,7 @@ namespace MxGateway.Contracts.Proto { break; } case 130: { - global::MxGateway.Contracts.Proto.UnAdviseCommand subBuilder = new global::MxGateway.Contracts.Proto.UnAdviseCommand(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.UnAdviseCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.UnAdviseCommand(); if (payloadCase_ == PayloadOneofCase.UnAdvise) { subBuilder.MergeFrom(UnAdvise); } @@ -3916,7 +4172,7 @@ namespace MxGateway.Contracts.Proto { break; } case 138: { - global::MxGateway.Contracts.Proto.AdviseSupervisoryCommand subBuilder = new global::MxGateway.Contracts.Proto.AdviseSupervisoryCommand(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.AdviseSupervisoryCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.AdviseSupervisoryCommand(); if (payloadCase_ == PayloadOneofCase.AdviseSupervisory) { subBuilder.MergeFrom(AdviseSupervisory); } @@ -3925,7 +4181,7 @@ namespace MxGateway.Contracts.Proto { break; } case 146: { - global::MxGateway.Contracts.Proto.AddBufferedItemCommand subBuilder = new global::MxGateway.Contracts.Proto.AddBufferedItemCommand(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.AddBufferedItemCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.AddBufferedItemCommand(); if (payloadCase_ == PayloadOneofCase.AddBufferedItem) { subBuilder.MergeFrom(AddBufferedItem); } @@ -3934,7 +4190,7 @@ namespace MxGateway.Contracts.Proto { break; } case 154: { - global::MxGateway.Contracts.Proto.SetBufferedUpdateIntervalCommand subBuilder = new global::MxGateway.Contracts.Proto.SetBufferedUpdateIntervalCommand(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.SetBufferedUpdateIntervalCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.SetBufferedUpdateIntervalCommand(); if (payloadCase_ == PayloadOneofCase.SetBufferedUpdateInterval) { subBuilder.MergeFrom(SetBufferedUpdateInterval); } @@ -3943,7 +4199,7 @@ namespace MxGateway.Contracts.Proto { break; } case 162: { - global::MxGateway.Contracts.Proto.SuspendCommand subBuilder = new global::MxGateway.Contracts.Proto.SuspendCommand(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.SuspendCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.SuspendCommand(); if (payloadCase_ == PayloadOneofCase.Suspend) { subBuilder.MergeFrom(Suspend); } @@ -3952,7 +4208,7 @@ namespace MxGateway.Contracts.Proto { break; } case 170: { - global::MxGateway.Contracts.Proto.ActivateCommand subBuilder = new global::MxGateway.Contracts.Proto.ActivateCommand(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.ActivateCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.ActivateCommand(); if (payloadCase_ == PayloadOneofCase.Activate) { subBuilder.MergeFrom(Activate); } @@ -3961,7 +4217,7 @@ namespace MxGateway.Contracts.Proto { break; } case 178: { - global::MxGateway.Contracts.Proto.WriteCommand subBuilder = new global::MxGateway.Contracts.Proto.WriteCommand(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.WriteCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.WriteCommand(); if (payloadCase_ == PayloadOneofCase.Write) { subBuilder.MergeFrom(Write); } @@ -3970,7 +4226,7 @@ namespace MxGateway.Contracts.Proto { break; } case 186: { - global::MxGateway.Contracts.Proto.Write2Command subBuilder = new global::MxGateway.Contracts.Proto.Write2Command(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.Write2Command subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.Write2Command(); if (payloadCase_ == PayloadOneofCase.Write2) { subBuilder.MergeFrom(Write2); } @@ -3979,7 +4235,7 @@ namespace MxGateway.Contracts.Proto { break; } case 194: { - global::MxGateway.Contracts.Proto.WriteSecuredCommand subBuilder = new global::MxGateway.Contracts.Proto.WriteSecuredCommand(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.WriteSecuredCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.WriteSecuredCommand(); if (payloadCase_ == PayloadOneofCase.WriteSecured) { subBuilder.MergeFrom(WriteSecured); } @@ -3988,7 +4244,7 @@ namespace MxGateway.Contracts.Proto { break; } case 202: { - global::MxGateway.Contracts.Proto.WriteSecured2Command subBuilder = new global::MxGateway.Contracts.Proto.WriteSecured2Command(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.WriteSecured2Command subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.WriteSecured2Command(); if (payloadCase_ == PayloadOneofCase.WriteSecured2) { subBuilder.MergeFrom(WriteSecured2); } @@ -3997,7 +4253,7 @@ namespace MxGateway.Contracts.Proto { break; } case 210: { - global::MxGateway.Contracts.Proto.AuthenticateUserCommand subBuilder = new global::MxGateway.Contracts.Proto.AuthenticateUserCommand(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.AuthenticateUserCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.AuthenticateUserCommand(); if (payloadCase_ == PayloadOneofCase.AuthenticateUser) { subBuilder.MergeFrom(AuthenticateUser); } @@ -4006,7 +4262,7 @@ namespace MxGateway.Contracts.Proto { break; } case 218: { - global::MxGateway.Contracts.Proto.ArchestrAUserToIdCommand subBuilder = new global::MxGateway.Contracts.Proto.ArchestrAUserToIdCommand(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.ArchestrAUserToIdCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.ArchestrAUserToIdCommand(); if (payloadCase_ == PayloadOneofCase.ArchestraUserToId) { subBuilder.MergeFrom(ArchestraUserToId); } @@ -4015,7 +4271,7 @@ namespace MxGateway.Contracts.Proto { break; } case 226: { - global::MxGateway.Contracts.Proto.AddItemBulkCommand subBuilder = new global::MxGateway.Contracts.Proto.AddItemBulkCommand(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.AddItemBulkCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.AddItemBulkCommand(); if (payloadCase_ == PayloadOneofCase.AddItemBulk) { subBuilder.MergeFrom(AddItemBulk); } @@ -4024,7 +4280,7 @@ namespace MxGateway.Contracts.Proto { break; } case 234: { - global::MxGateway.Contracts.Proto.AdviseItemBulkCommand subBuilder = new global::MxGateway.Contracts.Proto.AdviseItemBulkCommand(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.AdviseItemBulkCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.AdviseItemBulkCommand(); if (payloadCase_ == PayloadOneofCase.AdviseItemBulk) { subBuilder.MergeFrom(AdviseItemBulk); } @@ -4033,7 +4289,7 @@ namespace MxGateway.Contracts.Proto { break; } case 242: { - global::MxGateway.Contracts.Proto.RemoveItemBulkCommand subBuilder = new global::MxGateway.Contracts.Proto.RemoveItemBulkCommand(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.RemoveItemBulkCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.RemoveItemBulkCommand(); if (payloadCase_ == PayloadOneofCase.RemoveItemBulk) { subBuilder.MergeFrom(RemoveItemBulk); } @@ -4042,7 +4298,7 @@ namespace MxGateway.Contracts.Proto { break; } case 250: { - global::MxGateway.Contracts.Proto.UnAdviseItemBulkCommand subBuilder = new global::MxGateway.Contracts.Proto.UnAdviseItemBulkCommand(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.UnAdviseItemBulkCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.UnAdviseItemBulkCommand(); if (payloadCase_ == PayloadOneofCase.UnAdviseItemBulk) { subBuilder.MergeFrom(UnAdviseItemBulk); } @@ -4051,7 +4307,7 @@ namespace MxGateway.Contracts.Proto { break; } case 258: { - global::MxGateway.Contracts.Proto.SubscribeBulkCommand subBuilder = new global::MxGateway.Contracts.Proto.SubscribeBulkCommand(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.SubscribeBulkCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.SubscribeBulkCommand(); if (payloadCase_ == PayloadOneofCase.SubscribeBulk) { subBuilder.MergeFrom(SubscribeBulk); } @@ -4060,7 +4316,7 @@ namespace MxGateway.Contracts.Proto { break; } case 266: { - global::MxGateway.Contracts.Proto.UnsubscribeBulkCommand subBuilder = new global::MxGateway.Contracts.Proto.UnsubscribeBulkCommand(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.UnsubscribeBulkCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.UnsubscribeBulkCommand(); if (payloadCase_ == PayloadOneofCase.UnsubscribeBulk) { subBuilder.MergeFrom(UnsubscribeBulk); } @@ -4069,7 +4325,7 @@ namespace MxGateway.Contracts.Proto { break; } case 274: { - global::MxGateway.Contracts.Proto.SubscribeAlarmsCommand subBuilder = new global::MxGateway.Contracts.Proto.SubscribeAlarmsCommand(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.SubscribeAlarmsCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.SubscribeAlarmsCommand(); if (payloadCase_ == PayloadOneofCase.SubscribeAlarms) { subBuilder.MergeFrom(SubscribeAlarms); } @@ -4078,7 +4334,7 @@ namespace MxGateway.Contracts.Proto { break; } case 282: { - global::MxGateway.Contracts.Proto.UnsubscribeAlarmsCommand subBuilder = new global::MxGateway.Contracts.Proto.UnsubscribeAlarmsCommand(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.UnsubscribeAlarmsCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.UnsubscribeAlarmsCommand(); if (payloadCase_ == PayloadOneofCase.UnsubscribeAlarms) { subBuilder.MergeFrom(UnsubscribeAlarms); } @@ -4087,7 +4343,7 @@ namespace MxGateway.Contracts.Proto { break; } case 290: { - global::MxGateway.Contracts.Proto.AcknowledgeAlarmCommand subBuilder = new global::MxGateway.Contracts.Proto.AcknowledgeAlarmCommand(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmCommand(); if (payloadCase_ == PayloadOneofCase.AcknowledgeAlarmCommand) { subBuilder.MergeFrom(AcknowledgeAlarmCommand); } @@ -4096,7 +4352,7 @@ namespace MxGateway.Contracts.Proto { break; } case 298: { - global::MxGateway.Contracts.Proto.QueryActiveAlarmsCommand subBuilder = new global::MxGateway.Contracts.Proto.QueryActiveAlarmsCommand(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.QueryActiveAlarmsCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.QueryActiveAlarmsCommand(); if (payloadCase_ == PayloadOneofCase.QueryActiveAlarmsCommand) { subBuilder.MergeFrom(QueryActiveAlarmsCommand); } @@ -4105,7 +4361,7 @@ namespace MxGateway.Contracts.Proto { break; } case 306: { - global::MxGateway.Contracts.Proto.AcknowledgeAlarmByNameCommand subBuilder = new global::MxGateway.Contracts.Proto.AcknowledgeAlarmByNameCommand(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmByNameCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmByNameCommand(); if (payloadCase_ == PayloadOneofCase.AcknowledgeAlarmByNameCommand) { subBuilder.MergeFrom(AcknowledgeAlarmByNameCommand); } @@ -4113,8 +4369,53 @@ namespace MxGateway.Contracts.Proto { AcknowledgeAlarmByNameCommand = subBuilder; break; } + case 314: { + global::ZB.MOM.WW.MxGateway.Contracts.Proto.WriteBulkCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.WriteBulkCommand(); + if (payloadCase_ == PayloadOneofCase.WriteBulk) { + subBuilder.MergeFrom(WriteBulk); + } + input.ReadMessage(subBuilder); + WriteBulk = subBuilder; + break; + } + case 322: { + global::ZB.MOM.WW.MxGateway.Contracts.Proto.Write2BulkCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.Write2BulkCommand(); + if (payloadCase_ == PayloadOneofCase.Write2Bulk) { + subBuilder.MergeFrom(Write2Bulk); + } + input.ReadMessage(subBuilder); + Write2Bulk = subBuilder; + break; + } + case 330: { + global::ZB.MOM.WW.MxGateway.Contracts.Proto.WriteSecuredBulkCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.WriteSecuredBulkCommand(); + if (payloadCase_ == PayloadOneofCase.WriteSecuredBulk) { + subBuilder.MergeFrom(WriteSecuredBulk); + } + input.ReadMessage(subBuilder); + WriteSecuredBulk = subBuilder; + break; + } + case 338: { + global::ZB.MOM.WW.MxGateway.Contracts.Proto.WriteSecured2BulkCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.WriteSecured2BulkCommand(); + if (payloadCase_ == PayloadOneofCase.WriteSecured2Bulk) { + subBuilder.MergeFrom(WriteSecured2Bulk); + } + input.ReadMessage(subBuilder); + WriteSecured2Bulk = subBuilder; + break; + } + case 346: { + global::ZB.MOM.WW.MxGateway.Contracts.Proto.ReadBulkCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.ReadBulkCommand(); + if (payloadCase_ == PayloadOneofCase.ReadBulk) { + subBuilder.MergeFrom(ReadBulk); + } + input.ReadMessage(subBuilder); + ReadBulk = subBuilder; + break; + } case 802: { - global::MxGateway.Contracts.Proto.PingCommand subBuilder = new global::MxGateway.Contracts.Proto.PingCommand(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.PingCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.PingCommand(); if (payloadCase_ == PayloadOneofCase.Ping) { subBuilder.MergeFrom(Ping); } @@ -4123,7 +4424,7 @@ namespace MxGateway.Contracts.Proto { break; } case 810: { - global::MxGateway.Contracts.Proto.GetSessionStateCommand subBuilder = new global::MxGateway.Contracts.Proto.GetSessionStateCommand(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.GetSessionStateCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.GetSessionStateCommand(); if (payloadCase_ == PayloadOneofCase.GetSessionState) { subBuilder.MergeFrom(GetSessionState); } @@ -4132,7 +4433,7 @@ namespace MxGateway.Contracts.Proto { break; } case 818: { - global::MxGateway.Contracts.Proto.GetWorkerInfoCommand subBuilder = new global::MxGateway.Contracts.Proto.GetWorkerInfoCommand(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.GetWorkerInfoCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.GetWorkerInfoCommand(); if (payloadCase_ == PayloadOneofCase.GetWorkerInfo) { subBuilder.MergeFrom(GetWorkerInfo); } @@ -4141,7 +4442,7 @@ namespace MxGateway.Contracts.Proto { break; } case 826: { - global::MxGateway.Contracts.Proto.DrainEventsCommand subBuilder = new global::MxGateway.Contracts.Proto.DrainEventsCommand(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.DrainEventsCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.DrainEventsCommand(); if (payloadCase_ == PayloadOneofCase.DrainEvents) { subBuilder.MergeFrom(DrainEvents); } @@ -4150,7 +4451,7 @@ namespace MxGateway.Contracts.Proto { break; } case 834: { - global::MxGateway.Contracts.Proto.ShutdownWorkerCommand subBuilder = new global::MxGateway.Contracts.Proto.ShutdownWorkerCommand(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.ShutdownWorkerCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.ShutdownWorkerCommand(); if (payloadCase_ == PayloadOneofCase.ShutdownWorker) { subBuilder.MergeFrom(ShutdownWorker); } @@ -4178,11 +4479,11 @@ namespace MxGateway.Contracts.Proto { _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); break; case 8: { - Kind = (global::MxGateway.Contracts.Proto.MxCommandKind) input.ReadEnum(); + Kind = (global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxCommandKind) input.ReadEnum(); break; } case 82: { - global::MxGateway.Contracts.Proto.RegisterCommand subBuilder = new global::MxGateway.Contracts.Proto.RegisterCommand(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.RegisterCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.RegisterCommand(); if (payloadCase_ == PayloadOneofCase.Register) { subBuilder.MergeFrom(Register); } @@ -4191,7 +4492,7 @@ namespace MxGateway.Contracts.Proto { break; } case 90: { - global::MxGateway.Contracts.Proto.UnregisterCommand subBuilder = new global::MxGateway.Contracts.Proto.UnregisterCommand(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.UnregisterCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.UnregisterCommand(); if (payloadCase_ == PayloadOneofCase.Unregister) { subBuilder.MergeFrom(Unregister); } @@ -4200,7 +4501,7 @@ namespace MxGateway.Contracts.Proto { break; } case 98: { - global::MxGateway.Contracts.Proto.AddItemCommand subBuilder = new global::MxGateway.Contracts.Proto.AddItemCommand(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.AddItemCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.AddItemCommand(); if (payloadCase_ == PayloadOneofCase.AddItem) { subBuilder.MergeFrom(AddItem); } @@ -4209,7 +4510,7 @@ namespace MxGateway.Contracts.Proto { break; } case 106: { - global::MxGateway.Contracts.Proto.AddItem2Command subBuilder = new global::MxGateway.Contracts.Proto.AddItem2Command(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.AddItem2Command subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.AddItem2Command(); if (payloadCase_ == PayloadOneofCase.AddItem2) { subBuilder.MergeFrom(AddItem2); } @@ -4218,7 +4519,7 @@ namespace MxGateway.Contracts.Proto { break; } case 114: { - global::MxGateway.Contracts.Proto.RemoveItemCommand subBuilder = new global::MxGateway.Contracts.Proto.RemoveItemCommand(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.RemoveItemCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.RemoveItemCommand(); if (payloadCase_ == PayloadOneofCase.RemoveItem) { subBuilder.MergeFrom(RemoveItem); } @@ -4227,7 +4528,7 @@ namespace MxGateway.Contracts.Proto { break; } case 122: { - global::MxGateway.Contracts.Proto.AdviseCommand subBuilder = new global::MxGateway.Contracts.Proto.AdviseCommand(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.AdviseCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.AdviseCommand(); if (payloadCase_ == PayloadOneofCase.Advise) { subBuilder.MergeFrom(Advise); } @@ -4236,7 +4537,7 @@ namespace MxGateway.Contracts.Proto { break; } case 130: { - global::MxGateway.Contracts.Proto.UnAdviseCommand subBuilder = new global::MxGateway.Contracts.Proto.UnAdviseCommand(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.UnAdviseCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.UnAdviseCommand(); if (payloadCase_ == PayloadOneofCase.UnAdvise) { subBuilder.MergeFrom(UnAdvise); } @@ -4245,7 +4546,7 @@ namespace MxGateway.Contracts.Proto { break; } case 138: { - global::MxGateway.Contracts.Proto.AdviseSupervisoryCommand subBuilder = new global::MxGateway.Contracts.Proto.AdviseSupervisoryCommand(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.AdviseSupervisoryCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.AdviseSupervisoryCommand(); if (payloadCase_ == PayloadOneofCase.AdviseSupervisory) { subBuilder.MergeFrom(AdviseSupervisory); } @@ -4254,7 +4555,7 @@ namespace MxGateway.Contracts.Proto { break; } case 146: { - global::MxGateway.Contracts.Proto.AddBufferedItemCommand subBuilder = new global::MxGateway.Contracts.Proto.AddBufferedItemCommand(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.AddBufferedItemCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.AddBufferedItemCommand(); if (payloadCase_ == PayloadOneofCase.AddBufferedItem) { subBuilder.MergeFrom(AddBufferedItem); } @@ -4263,7 +4564,7 @@ namespace MxGateway.Contracts.Proto { break; } case 154: { - global::MxGateway.Contracts.Proto.SetBufferedUpdateIntervalCommand subBuilder = new global::MxGateway.Contracts.Proto.SetBufferedUpdateIntervalCommand(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.SetBufferedUpdateIntervalCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.SetBufferedUpdateIntervalCommand(); if (payloadCase_ == PayloadOneofCase.SetBufferedUpdateInterval) { subBuilder.MergeFrom(SetBufferedUpdateInterval); } @@ -4272,7 +4573,7 @@ namespace MxGateway.Contracts.Proto { break; } case 162: { - global::MxGateway.Contracts.Proto.SuspendCommand subBuilder = new global::MxGateway.Contracts.Proto.SuspendCommand(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.SuspendCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.SuspendCommand(); if (payloadCase_ == PayloadOneofCase.Suspend) { subBuilder.MergeFrom(Suspend); } @@ -4281,7 +4582,7 @@ namespace MxGateway.Contracts.Proto { break; } case 170: { - global::MxGateway.Contracts.Proto.ActivateCommand subBuilder = new global::MxGateway.Contracts.Proto.ActivateCommand(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.ActivateCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.ActivateCommand(); if (payloadCase_ == PayloadOneofCase.Activate) { subBuilder.MergeFrom(Activate); } @@ -4290,7 +4591,7 @@ namespace MxGateway.Contracts.Proto { break; } case 178: { - global::MxGateway.Contracts.Proto.WriteCommand subBuilder = new global::MxGateway.Contracts.Proto.WriteCommand(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.WriteCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.WriteCommand(); if (payloadCase_ == PayloadOneofCase.Write) { subBuilder.MergeFrom(Write); } @@ -4299,7 +4600,7 @@ namespace MxGateway.Contracts.Proto { break; } case 186: { - global::MxGateway.Contracts.Proto.Write2Command subBuilder = new global::MxGateway.Contracts.Proto.Write2Command(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.Write2Command subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.Write2Command(); if (payloadCase_ == PayloadOneofCase.Write2) { subBuilder.MergeFrom(Write2); } @@ -4308,7 +4609,7 @@ namespace MxGateway.Contracts.Proto { break; } case 194: { - global::MxGateway.Contracts.Proto.WriteSecuredCommand subBuilder = new global::MxGateway.Contracts.Proto.WriteSecuredCommand(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.WriteSecuredCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.WriteSecuredCommand(); if (payloadCase_ == PayloadOneofCase.WriteSecured) { subBuilder.MergeFrom(WriteSecured); } @@ -4317,7 +4618,7 @@ namespace MxGateway.Contracts.Proto { break; } case 202: { - global::MxGateway.Contracts.Proto.WriteSecured2Command subBuilder = new global::MxGateway.Contracts.Proto.WriteSecured2Command(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.WriteSecured2Command subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.WriteSecured2Command(); if (payloadCase_ == PayloadOneofCase.WriteSecured2) { subBuilder.MergeFrom(WriteSecured2); } @@ -4326,7 +4627,7 @@ namespace MxGateway.Contracts.Proto { break; } case 210: { - global::MxGateway.Contracts.Proto.AuthenticateUserCommand subBuilder = new global::MxGateway.Contracts.Proto.AuthenticateUserCommand(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.AuthenticateUserCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.AuthenticateUserCommand(); if (payloadCase_ == PayloadOneofCase.AuthenticateUser) { subBuilder.MergeFrom(AuthenticateUser); } @@ -4335,7 +4636,7 @@ namespace MxGateway.Contracts.Proto { break; } case 218: { - global::MxGateway.Contracts.Proto.ArchestrAUserToIdCommand subBuilder = new global::MxGateway.Contracts.Proto.ArchestrAUserToIdCommand(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.ArchestrAUserToIdCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.ArchestrAUserToIdCommand(); if (payloadCase_ == PayloadOneofCase.ArchestraUserToId) { subBuilder.MergeFrom(ArchestraUserToId); } @@ -4344,7 +4645,7 @@ namespace MxGateway.Contracts.Proto { break; } case 226: { - global::MxGateway.Contracts.Proto.AddItemBulkCommand subBuilder = new global::MxGateway.Contracts.Proto.AddItemBulkCommand(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.AddItemBulkCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.AddItemBulkCommand(); if (payloadCase_ == PayloadOneofCase.AddItemBulk) { subBuilder.MergeFrom(AddItemBulk); } @@ -4353,7 +4654,7 @@ namespace MxGateway.Contracts.Proto { break; } case 234: { - global::MxGateway.Contracts.Proto.AdviseItemBulkCommand subBuilder = new global::MxGateway.Contracts.Proto.AdviseItemBulkCommand(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.AdviseItemBulkCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.AdviseItemBulkCommand(); if (payloadCase_ == PayloadOneofCase.AdviseItemBulk) { subBuilder.MergeFrom(AdviseItemBulk); } @@ -4362,7 +4663,7 @@ namespace MxGateway.Contracts.Proto { break; } case 242: { - global::MxGateway.Contracts.Proto.RemoveItemBulkCommand subBuilder = new global::MxGateway.Contracts.Proto.RemoveItemBulkCommand(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.RemoveItemBulkCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.RemoveItemBulkCommand(); if (payloadCase_ == PayloadOneofCase.RemoveItemBulk) { subBuilder.MergeFrom(RemoveItemBulk); } @@ -4371,7 +4672,7 @@ namespace MxGateway.Contracts.Proto { break; } case 250: { - global::MxGateway.Contracts.Proto.UnAdviseItemBulkCommand subBuilder = new global::MxGateway.Contracts.Proto.UnAdviseItemBulkCommand(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.UnAdviseItemBulkCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.UnAdviseItemBulkCommand(); if (payloadCase_ == PayloadOneofCase.UnAdviseItemBulk) { subBuilder.MergeFrom(UnAdviseItemBulk); } @@ -4380,7 +4681,7 @@ namespace MxGateway.Contracts.Proto { break; } case 258: { - global::MxGateway.Contracts.Proto.SubscribeBulkCommand subBuilder = new global::MxGateway.Contracts.Proto.SubscribeBulkCommand(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.SubscribeBulkCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.SubscribeBulkCommand(); if (payloadCase_ == PayloadOneofCase.SubscribeBulk) { subBuilder.MergeFrom(SubscribeBulk); } @@ -4389,7 +4690,7 @@ namespace MxGateway.Contracts.Proto { break; } case 266: { - global::MxGateway.Contracts.Proto.UnsubscribeBulkCommand subBuilder = new global::MxGateway.Contracts.Proto.UnsubscribeBulkCommand(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.UnsubscribeBulkCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.UnsubscribeBulkCommand(); if (payloadCase_ == PayloadOneofCase.UnsubscribeBulk) { subBuilder.MergeFrom(UnsubscribeBulk); } @@ -4398,7 +4699,7 @@ namespace MxGateway.Contracts.Proto { break; } case 274: { - global::MxGateway.Contracts.Proto.SubscribeAlarmsCommand subBuilder = new global::MxGateway.Contracts.Proto.SubscribeAlarmsCommand(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.SubscribeAlarmsCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.SubscribeAlarmsCommand(); if (payloadCase_ == PayloadOneofCase.SubscribeAlarms) { subBuilder.MergeFrom(SubscribeAlarms); } @@ -4407,7 +4708,7 @@ namespace MxGateway.Contracts.Proto { break; } case 282: { - global::MxGateway.Contracts.Proto.UnsubscribeAlarmsCommand subBuilder = new global::MxGateway.Contracts.Proto.UnsubscribeAlarmsCommand(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.UnsubscribeAlarmsCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.UnsubscribeAlarmsCommand(); if (payloadCase_ == PayloadOneofCase.UnsubscribeAlarms) { subBuilder.MergeFrom(UnsubscribeAlarms); } @@ -4416,7 +4717,7 @@ namespace MxGateway.Contracts.Proto { break; } case 290: { - global::MxGateway.Contracts.Proto.AcknowledgeAlarmCommand subBuilder = new global::MxGateway.Contracts.Proto.AcknowledgeAlarmCommand(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmCommand(); if (payloadCase_ == PayloadOneofCase.AcknowledgeAlarmCommand) { subBuilder.MergeFrom(AcknowledgeAlarmCommand); } @@ -4425,7 +4726,7 @@ namespace MxGateway.Contracts.Proto { break; } case 298: { - global::MxGateway.Contracts.Proto.QueryActiveAlarmsCommand subBuilder = new global::MxGateway.Contracts.Proto.QueryActiveAlarmsCommand(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.QueryActiveAlarmsCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.QueryActiveAlarmsCommand(); if (payloadCase_ == PayloadOneofCase.QueryActiveAlarmsCommand) { subBuilder.MergeFrom(QueryActiveAlarmsCommand); } @@ -4434,7 +4735,7 @@ namespace MxGateway.Contracts.Proto { break; } case 306: { - global::MxGateway.Contracts.Proto.AcknowledgeAlarmByNameCommand subBuilder = new global::MxGateway.Contracts.Proto.AcknowledgeAlarmByNameCommand(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmByNameCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmByNameCommand(); if (payloadCase_ == PayloadOneofCase.AcknowledgeAlarmByNameCommand) { subBuilder.MergeFrom(AcknowledgeAlarmByNameCommand); } @@ -4442,8 +4743,53 @@ namespace MxGateway.Contracts.Proto { AcknowledgeAlarmByNameCommand = subBuilder; break; } + case 314: { + global::ZB.MOM.WW.MxGateway.Contracts.Proto.WriteBulkCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.WriteBulkCommand(); + if (payloadCase_ == PayloadOneofCase.WriteBulk) { + subBuilder.MergeFrom(WriteBulk); + } + input.ReadMessage(subBuilder); + WriteBulk = subBuilder; + break; + } + case 322: { + global::ZB.MOM.WW.MxGateway.Contracts.Proto.Write2BulkCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.Write2BulkCommand(); + if (payloadCase_ == PayloadOneofCase.Write2Bulk) { + subBuilder.MergeFrom(Write2Bulk); + } + input.ReadMessage(subBuilder); + Write2Bulk = subBuilder; + break; + } + case 330: { + global::ZB.MOM.WW.MxGateway.Contracts.Proto.WriteSecuredBulkCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.WriteSecuredBulkCommand(); + if (payloadCase_ == PayloadOneofCase.WriteSecuredBulk) { + subBuilder.MergeFrom(WriteSecuredBulk); + } + input.ReadMessage(subBuilder); + WriteSecuredBulk = subBuilder; + break; + } + case 338: { + global::ZB.MOM.WW.MxGateway.Contracts.Proto.WriteSecured2BulkCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.WriteSecured2BulkCommand(); + if (payloadCase_ == PayloadOneofCase.WriteSecured2Bulk) { + subBuilder.MergeFrom(WriteSecured2Bulk); + } + input.ReadMessage(subBuilder); + WriteSecured2Bulk = subBuilder; + break; + } + case 346: { + global::ZB.MOM.WW.MxGateway.Contracts.Proto.ReadBulkCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.ReadBulkCommand(); + if (payloadCase_ == PayloadOneofCase.ReadBulk) { + subBuilder.MergeFrom(ReadBulk); + } + input.ReadMessage(subBuilder); + ReadBulk = subBuilder; + break; + } case 802: { - global::MxGateway.Contracts.Proto.PingCommand subBuilder = new global::MxGateway.Contracts.Proto.PingCommand(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.PingCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.PingCommand(); if (payloadCase_ == PayloadOneofCase.Ping) { subBuilder.MergeFrom(Ping); } @@ -4452,7 +4798,7 @@ namespace MxGateway.Contracts.Proto { break; } case 810: { - global::MxGateway.Contracts.Proto.GetSessionStateCommand subBuilder = new global::MxGateway.Contracts.Proto.GetSessionStateCommand(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.GetSessionStateCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.GetSessionStateCommand(); if (payloadCase_ == PayloadOneofCase.GetSessionState) { subBuilder.MergeFrom(GetSessionState); } @@ -4461,7 +4807,7 @@ namespace MxGateway.Contracts.Proto { break; } case 818: { - global::MxGateway.Contracts.Proto.GetWorkerInfoCommand subBuilder = new global::MxGateway.Contracts.Proto.GetWorkerInfoCommand(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.GetWorkerInfoCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.GetWorkerInfoCommand(); if (payloadCase_ == PayloadOneofCase.GetWorkerInfo) { subBuilder.MergeFrom(GetWorkerInfo); } @@ -4470,7 +4816,7 @@ namespace MxGateway.Contracts.Proto { break; } case 826: { - global::MxGateway.Contracts.Proto.DrainEventsCommand subBuilder = new global::MxGateway.Contracts.Proto.DrainEventsCommand(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.DrainEventsCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.DrainEventsCommand(); if (payloadCase_ == PayloadOneofCase.DrainEvents) { subBuilder.MergeFrom(DrainEvents); } @@ -4479,7 +4825,7 @@ namespace MxGateway.Contracts.Proto { break; } case 834: { - global::MxGateway.Contracts.Proto.ShutdownWorkerCommand subBuilder = new global::MxGateway.Contracts.Proto.ShutdownWorkerCommand(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.ShutdownWorkerCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.ShutdownWorkerCommand(); if (payloadCase_ == PayloadOneofCase.ShutdownWorker) { subBuilder.MergeFrom(ShutdownWorker); } @@ -4509,7 +4855,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[7]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[7]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -4707,7 +5053,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[8]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[8]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -4905,7 +5251,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[9]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[9]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -5140,7 +5486,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[10]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[10]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -5412,7 +5758,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[11]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[11]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -5647,7 +5993,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[12]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[12]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -5882,7 +6228,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[13]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[13]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -6117,7 +6463,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[14]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[14]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -6352,7 +6698,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[15]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[15]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -6624,7 +6970,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[16]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[16]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -6859,7 +7205,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[17]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[17]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -7094,7 +7440,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[18]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[18]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -7329,7 +7675,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[19]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[19]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -7388,10 +7734,10 @@ namespace MxGateway.Contracts.Proto { /// Field number for the "value" field. public const int ValueFieldNumber = 3; - private global::MxGateway.Contracts.Proto.MxValue value_; + private global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue value_; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.MxValue Value { + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue Value { get { return value_; } set { value_ = value; @@ -7542,7 +7888,7 @@ namespace MxGateway.Contracts.Proto { } if (other.value_ != null) { if (value_ == null) { - Value = new global::MxGateway.Contracts.Proto.MxValue(); + Value = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue(); } Value.MergeFrom(other.Value); } @@ -7578,7 +7924,7 @@ namespace MxGateway.Contracts.Proto { } case 26: { if (value_ == null) { - Value = new global::MxGateway.Contracts.Proto.MxValue(); + Value = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue(); } input.ReadMessage(Value); break; @@ -7616,7 +7962,7 @@ namespace MxGateway.Contracts.Proto { } case 26: { if (value_ == null) { - Value = new global::MxGateway.Contracts.Proto.MxValue(); + Value = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue(); } input.ReadMessage(Value); break; @@ -7647,7 +7993,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[20]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[20]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -7707,10 +8053,10 @@ namespace MxGateway.Contracts.Proto { /// Field number for the "value" field. public const int ValueFieldNumber = 3; - private global::MxGateway.Contracts.Proto.MxValue value_; + private global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue value_; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.MxValue Value { + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue Value { get { return value_; } set { value_ = value; @@ -7719,10 +8065,10 @@ namespace MxGateway.Contracts.Proto { /// Field number for the "timestamp_value" field. public const int TimestampValueFieldNumber = 4; - private global::MxGateway.Contracts.Proto.MxValue timestampValue_; + private global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue timestampValue_; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.MxValue TimestampValue { + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue TimestampValue { get { return timestampValue_; } set { timestampValue_ = value; @@ -7886,13 +8232,13 @@ namespace MxGateway.Contracts.Proto { } if (other.value_ != null) { if (value_ == null) { - Value = new global::MxGateway.Contracts.Proto.MxValue(); + Value = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue(); } Value.MergeFrom(other.Value); } if (other.timestampValue_ != null) { if (timestampValue_ == null) { - TimestampValue = new global::MxGateway.Contracts.Proto.MxValue(); + TimestampValue = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue(); } TimestampValue.MergeFrom(other.TimestampValue); } @@ -7928,14 +8274,14 @@ namespace MxGateway.Contracts.Proto { } case 26: { if (value_ == null) { - Value = new global::MxGateway.Contracts.Proto.MxValue(); + Value = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue(); } input.ReadMessage(Value); break; } case 34: { if (timestampValue_ == null) { - TimestampValue = new global::MxGateway.Contracts.Proto.MxValue(); + TimestampValue = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue(); } input.ReadMessage(TimestampValue); break; @@ -7973,14 +8319,14 @@ namespace MxGateway.Contracts.Proto { } case 26: { if (value_ == null) { - Value = new global::MxGateway.Contracts.Proto.MxValue(); + Value = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue(); } input.ReadMessage(Value); break; } case 34: { if (timestampValue_ == null) { - TimestampValue = new global::MxGateway.Contracts.Proto.MxValue(); + TimestampValue = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue(); } input.ReadMessage(TimestampValue); break; @@ -8011,7 +8357,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[21]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[21]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -8095,14 +8441,14 @@ namespace MxGateway.Contracts.Proto { /// Field number for the "value" field. public const int ValueFieldNumber = 5; - private global::MxGateway.Contracts.Proto.MxValue value_; + private global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue value_; /// /// Credential-sensitive write value. Implementations must not log this field /// unless an explicit redacted value-logging path is enabled. /// [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.MxValue Value { + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue Value { get { return value_; } set { value_ = value; @@ -8260,7 +8606,7 @@ namespace MxGateway.Contracts.Proto { } if (other.value_ != null) { if (value_ == null) { - Value = new global::MxGateway.Contracts.Proto.MxValue(); + Value = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue(); } Value.MergeFrom(other.Value); } @@ -8301,7 +8647,7 @@ namespace MxGateway.Contracts.Proto { } case 42: { if (value_ == null) { - Value = new global::MxGateway.Contracts.Proto.MxValue(); + Value = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue(); } input.ReadMessage(Value); break; @@ -8343,7 +8689,7 @@ namespace MxGateway.Contracts.Proto { } case 42: { if (value_ == null) { - Value = new global::MxGateway.Contracts.Proto.MxValue(); + Value = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue(); } input.ReadMessage(Value); break; @@ -8370,7 +8716,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[22]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[22]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -8455,14 +8801,14 @@ namespace MxGateway.Contracts.Proto { /// Field number for the "value" field. public const int ValueFieldNumber = 5; - private global::MxGateway.Contracts.Proto.MxValue value_; + private global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue value_; /// /// Credential-sensitive write value. Implementations must not log this field /// unless an explicit redacted value-logging path is enabled. /// [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.MxValue Value { + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue Value { get { return value_; } set { value_ = value; @@ -8471,10 +8817,10 @@ namespace MxGateway.Contracts.Proto { /// Field number for the "timestamp_value" field. public const int TimestampValueFieldNumber = 6; - private global::MxGateway.Contracts.Proto.MxValue timestampValue_; + private global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue timestampValue_; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.MxValue TimestampValue { + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue TimestampValue { get { return timestampValue_; } set { timestampValue_ = value; @@ -8645,13 +8991,13 @@ namespace MxGateway.Contracts.Proto { } if (other.value_ != null) { if (value_ == null) { - Value = new global::MxGateway.Contracts.Proto.MxValue(); + Value = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue(); } Value.MergeFrom(other.Value); } if (other.timestampValue_ != null) { if (timestampValue_ == null) { - TimestampValue = new global::MxGateway.Contracts.Proto.MxValue(); + TimestampValue = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue(); } TimestampValue.MergeFrom(other.TimestampValue); } @@ -8692,14 +9038,14 @@ namespace MxGateway.Contracts.Proto { } case 42: { if (value_ == null) { - Value = new global::MxGateway.Contracts.Proto.MxValue(); + Value = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue(); } input.ReadMessage(Value); break; } case 50: { if (timestampValue_ == null) { - TimestampValue = new global::MxGateway.Contracts.Proto.MxValue(); + TimestampValue = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue(); } input.ReadMessage(TimestampValue); break; @@ -8741,14 +9087,14 @@ namespace MxGateway.Contracts.Proto { } case 42: { if (value_ == null) { - Value = new global::MxGateway.Contracts.Proto.MxValue(); + Value = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue(); } input.ReadMessage(Value); break; } case 50: { if (timestampValue_ == null) { - TimestampValue = new global::MxGateway.Contracts.Proto.MxValue(); + TimestampValue = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue(); } input.ReadMessage(TimestampValue); break; @@ -8775,7 +9121,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[23]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[23]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -9051,7 +9397,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[24]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[24]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -9286,7 +9632,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[25]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[25]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -9510,7 +9856,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[26]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[26]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -9736,7 +10082,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[27]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[27]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -9962,7 +10308,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[28]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[28]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -10188,7 +10534,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[29]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[29]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -10420,7 +10766,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[30]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[30]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -10622,7 +10968,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[31]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[31]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -10789,7 +11135,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[32]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[32]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -11180,7 +11526,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[33]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[33]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -11385,7 +11731,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[34]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[34]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -11853,7 +12199,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[35]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[35]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -12064,6 +12410,2495 @@ namespace MxGateway.Contracts.Proto { } + /// + /// Bulk Write — sequential MXAccess Write per entry, on the worker's STA. + /// MXAccess has no native bulk write; each entry round-trips through the same + /// single-item Write path the gateway uses today. Per-item failures appear as + /// BulkWriteResult entries with `was_successful = false` and never throw. + /// + [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] + public sealed partial class WriteBulkCommand : pb::IMessage + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + , pb::IBufferMessage + #endif + { + private static readonly pb::MessageParser _parser = new pb::MessageParser(() => new WriteBulkCommand()); + private pb::UnknownFieldSet _unknownFields; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pb::MessageParser Parser { get { return _parser; } } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pbr::MessageDescriptor Descriptor { + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[36]; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + pbr::MessageDescriptor pb::IMessage.Descriptor { + get { return Descriptor; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public WriteBulkCommand() { + OnConstruction(); + } + + partial void OnConstruction(); + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public WriteBulkCommand(WriteBulkCommand other) : this() { + serverHandle_ = other.serverHandle_; + entries_ = other.entries_.Clone(); + _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public WriteBulkCommand Clone() { + return new WriteBulkCommand(this); + } + + /// Field number for the "server_handle" field. + public const int ServerHandleFieldNumber = 1; + private int serverHandle_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int ServerHandle { + get { return serverHandle_; } + set { + serverHandle_ = value; + } + } + + /// Field number for the "entries" field. + public const int EntriesFieldNumber = 2; + private static readonly pb::FieldCodec _repeated_entries_codec + = pb::FieldCodec.ForMessage(18, global::ZB.MOM.WW.MxGateway.Contracts.Proto.WriteBulkEntry.Parser); + private readonly pbc::RepeatedField entries_ = new pbc::RepeatedField(); + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public pbc::RepeatedField Entries { + get { return entries_; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override bool Equals(object other) { + return Equals(other as WriteBulkCommand); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool Equals(WriteBulkCommand other) { + if (ReferenceEquals(other, null)) { + return false; + } + if (ReferenceEquals(other, this)) { + return true; + } + if (ServerHandle != other.ServerHandle) return false; + if(!entries_.Equals(other.entries_)) return false; + return Equals(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override int GetHashCode() { + int hash = 1; + if (ServerHandle != 0) hash ^= ServerHandle.GetHashCode(); + hash ^= entries_.GetHashCode(); + if (_unknownFields != null) { + hash ^= _unknownFields.GetHashCode(); + } + return hash; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override string ToString() { + return pb::JsonFormatter.ToDiagnosticString(this); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void WriteTo(pb::CodedOutputStream output) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + output.WriteRawMessage(this); + #else + if (ServerHandle != 0) { + output.WriteRawTag(8); + output.WriteInt32(ServerHandle); + } + entries_.WriteTo(output, _repeated_entries_codec); + if (_unknownFields != null) { + _unknownFields.WriteTo(output); + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { + if (ServerHandle != 0) { + output.WriteRawTag(8); + output.WriteInt32(ServerHandle); + } + entries_.WriteTo(ref output, _repeated_entries_codec); + if (_unknownFields != null) { + _unknownFields.WriteTo(ref output); + } + } + #endif + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int CalculateSize() { + int size = 0; + if (ServerHandle != 0) { + size += 1 + pb::CodedOutputStream.ComputeInt32Size(ServerHandle); + } + size += entries_.CalculateSize(_repeated_entries_codec); + if (_unknownFields != null) { + size += _unknownFields.CalculateSize(); + } + return size; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(WriteBulkCommand other) { + if (other == null) { + return; + } + if (other.ServerHandle != 0) { + ServerHandle = other.ServerHandle; + } + entries_.Add(other.entries_); + _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(pb::CodedInputStream input) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + input.ReadRawMessage(this); + #else + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); + break; + case 8: { + ServerHandle = input.ReadInt32(); + break; + } + case 18: { + entries_.AddEntriesFrom(input, _repeated_entries_codec); + break; + } + } + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalMergeFrom(ref pb::ParseContext input) { + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); + break; + case 8: { + ServerHandle = input.ReadInt32(); + break; + } + case 18: { + entries_.AddEntriesFrom(ref input, _repeated_entries_codec); + break; + } + } + } + } + #endif + + } + + [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] + public sealed partial class WriteBulkEntry : pb::IMessage + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + , pb::IBufferMessage + #endif + { + private static readonly pb::MessageParser _parser = new pb::MessageParser(() => new WriteBulkEntry()); + private pb::UnknownFieldSet _unknownFields; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pb::MessageParser Parser { get { return _parser; } } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pbr::MessageDescriptor Descriptor { + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[37]; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + pbr::MessageDescriptor pb::IMessage.Descriptor { + get { return Descriptor; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public WriteBulkEntry() { + OnConstruction(); + } + + partial void OnConstruction(); + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public WriteBulkEntry(WriteBulkEntry other) : this() { + itemHandle_ = other.itemHandle_; + value_ = other.value_ != null ? other.value_.Clone() : null; + userId_ = other.userId_; + _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public WriteBulkEntry Clone() { + return new WriteBulkEntry(this); + } + + /// Field number for the "item_handle" field. + public const int ItemHandleFieldNumber = 1; + private int itemHandle_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int ItemHandle { + get { return itemHandle_; } + set { + itemHandle_ = value; + } + } + + /// Field number for the "value" field. + public const int ValueFieldNumber = 2; + private global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue value_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue Value { + get { return value_; } + set { + value_ = value; + } + } + + /// Field number for the "user_id" field. + public const int UserIdFieldNumber = 3; + private int userId_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int UserId { + get { return userId_; } + set { + userId_ = value; + } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override bool Equals(object other) { + return Equals(other as WriteBulkEntry); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool Equals(WriteBulkEntry other) { + if (ReferenceEquals(other, null)) { + return false; + } + if (ReferenceEquals(other, this)) { + return true; + } + if (ItemHandle != other.ItemHandle) return false; + if (!object.Equals(Value, other.Value)) return false; + if (UserId != other.UserId) return false; + return Equals(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override int GetHashCode() { + int hash = 1; + if (ItemHandle != 0) hash ^= ItemHandle.GetHashCode(); + if (value_ != null) hash ^= Value.GetHashCode(); + if (UserId != 0) hash ^= UserId.GetHashCode(); + if (_unknownFields != null) { + hash ^= _unknownFields.GetHashCode(); + } + return hash; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override string ToString() { + return pb::JsonFormatter.ToDiagnosticString(this); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void WriteTo(pb::CodedOutputStream output) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + output.WriteRawMessage(this); + #else + if (ItemHandle != 0) { + output.WriteRawTag(8); + output.WriteInt32(ItemHandle); + } + if (value_ != null) { + output.WriteRawTag(18); + output.WriteMessage(Value); + } + if (UserId != 0) { + output.WriteRawTag(24); + output.WriteInt32(UserId); + } + if (_unknownFields != null) { + _unknownFields.WriteTo(output); + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { + if (ItemHandle != 0) { + output.WriteRawTag(8); + output.WriteInt32(ItemHandle); + } + if (value_ != null) { + output.WriteRawTag(18); + output.WriteMessage(Value); + } + if (UserId != 0) { + output.WriteRawTag(24); + output.WriteInt32(UserId); + } + if (_unknownFields != null) { + _unknownFields.WriteTo(ref output); + } + } + #endif + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int CalculateSize() { + int size = 0; + if (ItemHandle != 0) { + size += 1 + pb::CodedOutputStream.ComputeInt32Size(ItemHandle); + } + if (value_ != null) { + size += 1 + pb::CodedOutputStream.ComputeMessageSize(Value); + } + if (UserId != 0) { + size += 1 + pb::CodedOutputStream.ComputeInt32Size(UserId); + } + if (_unknownFields != null) { + size += _unknownFields.CalculateSize(); + } + return size; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(WriteBulkEntry other) { + if (other == null) { + return; + } + if (other.ItemHandle != 0) { + ItemHandle = other.ItemHandle; + } + if (other.value_ != null) { + if (value_ == null) { + Value = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue(); + } + Value.MergeFrom(other.Value); + } + if (other.UserId != 0) { + UserId = other.UserId; + } + _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(pb::CodedInputStream input) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + input.ReadRawMessage(this); + #else + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); + break; + case 8: { + ItemHandle = input.ReadInt32(); + break; + } + case 18: { + if (value_ == null) { + Value = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue(); + } + input.ReadMessage(Value); + break; + } + case 24: { + UserId = input.ReadInt32(); + break; + } + } + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalMergeFrom(ref pb::ParseContext input) { + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); + break; + case 8: { + ItemHandle = input.ReadInt32(); + break; + } + case 18: { + if (value_ == null) { + Value = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue(); + } + input.ReadMessage(Value); + break; + } + case 24: { + UserId = input.ReadInt32(); + break; + } + } + } + } + #endif + + } + + /// + /// Bulk Write2 — sequential MXAccess Write2 (timestamped) per entry. + /// + [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] + public sealed partial class Write2BulkCommand : pb::IMessage + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + , pb::IBufferMessage + #endif + { + private static readonly pb::MessageParser _parser = new pb::MessageParser(() => new Write2BulkCommand()); + private pb::UnknownFieldSet _unknownFields; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pb::MessageParser Parser { get { return _parser; } } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pbr::MessageDescriptor Descriptor { + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[38]; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + pbr::MessageDescriptor pb::IMessage.Descriptor { + get { return Descriptor; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public Write2BulkCommand() { + OnConstruction(); + } + + partial void OnConstruction(); + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public Write2BulkCommand(Write2BulkCommand other) : this() { + serverHandle_ = other.serverHandle_; + entries_ = other.entries_.Clone(); + _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public Write2BulkCommand Clone() { + return new Write2BulkCommand(this); + } + + /// Field number for the "server_handle" field. + public const int ServerHandleFieldNumber = 1; + private int serverHandle_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int ServerHandle { + get { return serverHandle_; } + set { + serverHandle_ = value; + } + } + + /// Field number for the "entries" field. + public const int EntriesFieldNumber = 2; + private static readonly pb::FieldCodec _repeated_entries_codec + = pb::FieldCodec.ForMessage(18, global::ZB.MOM.WW.MxGateway.Contracts.Proto.Write2BulkEntry.Parser); + private readonly pbc::RepeatedField entries_ = new pbc::RepeatedField(); + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public pbc::RepeatedField Entries { + get { return entries_; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override bool Equals(object other) { + return Equals(other as Write2BulkCommand); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool Equals(Write2BulkCommand other) { + if (ReferenceEquals(other, null)) { + return false; + } + if (ReferenceEquals(other, this)) { + return true; + } + if (ServerHandle != other.ServerHandle) return false; + if(!entries_.Equals(other.entries_)) return false; + return Equals(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override int GetHashCode() { + int hash = 1; + if (ServerHandle != 0) hash ^= ServerHandle.GetHashCode(); + hash ^= entries_.GetHashCode(); + if (_unknownFields != null) { + hash ^= _unknownFields.GetHashCode(); + } + return hash; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override string ToString() { + return pb::JsonFormatter.ToDiagnosticString(this); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void WriteTo(pb::CodedOutputStream output) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + output.WriteRawMessage(this); + #else + if (ServerHandle != 0) { + output.WriteRawTag(8); + output.WriteInt32(ServerHandle); + } + entries_.WriteTo(output, _repeated_entries_codec); + if (_unknownFields != null) { + _unknownFields.WriteTo(output); + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { + if (ServerHandle != 0) { + output.WriteRawTag(8); + output.WriteInt32(ServerHandle); + } + entries_.WriteTo(ref output, _repeated_entries_codec); + if (_unknownFields != null) { + _unknownFields.WriteTo(ref output); + } + } + #endif + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int CalculateSize() { + int size = 0; + if (ServerHandle != 0) { + size += 1 + pb::CodedOutputStream.ComputeInt32Size(ServerHandle); + } + size += entries_.CalculateSize(_repeated_entries_codec); + if (_unknownFields != null) { + size += _unknownFields.CalculateSize(); + } + return size; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(Write2BulkCommand other) { + if (other == null) { + return; + } + if (other.ServerHandle != 0) { + ServerHandle = other.ServerHandle; + } + entries_.Add(other.entries_); + _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(pb::CodedInputStream input) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + input.ReadRawMessage(this); + #else + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); + break; + case 8: { + ServerHandle = input.ReadInt32(); + break; + } + case 18: { + entries_.AddEntriesFrom(input, _repeated_entries_codec); + break; + } + } + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalMergeFrom(ref pb::ParseContext input) { + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); + break; + case 8: { + ServerHandle = input.ReadInt32(); + break; + } + case 18: { + entries_.AddEntriesFrom(ref input, _repeated_entries_codec); + break; + } + } + } + } + #endif + + } + + [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] + public sealed partial class Write2BulkEntry : pb::IMessage + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + , pb::IBufferMessage + #endif + { + private static readonly pb::MessageParser _parser = new pb::MessageParser(() => new Write2BulkEntry()); + private pb::UnknownFieldSet _unknownFields; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pb::MessageParser Parser { get { return _parser; } } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pbr::MessageDescriptor Descriptor { + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[39]; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + pbr::MessageDescriptor pb::IMessage.Descriptor { + get { return Descriptor; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public Write2BulkEntry() { + OnConstruction(); + } + + partial void OnConstruction(); + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public Write2BulkEntry(Write2BulkEntry other) : this() { + itemHandle_ = other.itemHandle_; + value_ = other.value_ != null ? other.value_.Clone() : null; + timestampValue_ = other.timestampValue_ != null ? other.timestampValue_.Clone() : null; + userId_ = other.userId_; + _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public Write2BulkEntry Clone() { + return new Write2BulkEntry(this); + } + + /// Field number for the "item_handle" field. + public const int ItemHandleFieldNumber = 1; + private int itemHandle_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int ItemHandle { + get { return itemHandle_; } + set { + itemHandle_ = value; + } + } + + /// Field number for the "value" field. + public const int ValueFieldNumber = 2; + private global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue value_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue Value { + get { return value_; } + set { + value_ = value; + } + } + + /// Field number for the "timestamp_value" field. + public const int TimestampValueFieldNumber = 3; + private global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue timestampValue_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue TimestampValue { + get { return timestampValue_; } + set { + timestampValue_ = value; + } + } + + /// Field number for the "user_id" field. + public const int UserIdFieldNumber = 4; + private int userId_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int UserId { + get { return userId_; } + set { + userId_ = value; + } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override bool Equals(object other) { + return Equals(other as Write2BulkEntry); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool Equals(Write2BulkEntry other) { + if (ReferenceEquals(other, null)) { + return false; + } + if (ReferenceEquals(other, this)) { + return true; + } + if (ItemHandle != other.ItemHandle) return false; + if (!object.Equals(Value, other.Value)) return false; + if (!object.Equals(TimestampValue, other.TimestampValue)) return false; + if (UserId != other.UserId) return false; + return Equals(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override int GetHashCode() { + int hash = 1; + if (ItemHandle != 0) hash ^= ItemHandle.GetHashCode(); + if (value_ != null) hash ^= Value.GetHashCode(); + if (timestampValue_ != null) hash ^= TimestampValue.GetHashCode(); + if (UserId != 0) hash ^= UserId.GetHashCode(); + if (_unknownFields != null) { + hash ^= _unknownFields.GetHashCode(); + } + return hash; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override string ToString() { + return pb::JsonFormatter.ToDiagnosticString(this); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void WriteTo(pb::CodedOutputStream output) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + output.WriteRawMessage(this); + #else + if (ItemHandle != 0) { + output.WriteRawTag(8); + output.WriteInt32(ItemHandle); + } + if (value_ != null) { + output.WriteRawTag(18); + output.WriteMessage(Value); + } + if (timestampValue_ != null) { + output.WriteRawTag(26); + output.WriteMessage(TimestampValue); + } + if (UserId != 0) { + output.WriteRawTag(32); + output.WriteInt32(UserId); + } + if (_unknownFields != null) { + _unknownFields.WriteTo(output); + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { + if (ItemHandle != 0) { + output.WriteRawTag(8); + output.WriteInt32(ItemHandle); + } + if (value_ != null) { + output.WriteRawTag(18); + output.WriteMessage(Value); + } + if (timestampValue_ != null) { + output.WriteRawTag(26); + output.WriteMessage(TimestampValue); + } + if (UserId != 0) { + output.WriteRawTag(32); + output.WriteInt32(UserId); + } + if (_unknownFields != null) { + _unknownFields.WriteTo(ref output); + } + } + #endif + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int CalculateSize() { + int size = 0; + if (ItemHandle != 0) { + size += 1 + pb::CodedOutputStream.ComputeInt32Size(ItemHandle); + } + if (value_ != null) { + size += 1 + pb::CodedOutputStream.ComputeMessageSize(Value); + } + if (timestampValue_ != null) { + size += 1 + pb::CodedOutputStream.ComputeMessageSize(TimestampValue); + } + if (UserId != 0) { + size += 1 + pb::CodedOutputStream.ComputeInt32Size(UserId); + } + if (_unknownFields != null) { + size += _unknownFields.CalculateSize(); + } + return size; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(Write2BulkEntry other) { + if (other == null) { + return; + } + if (other.ItemHandle != 0) { + ItemHandle = other.ItemHandle; + } + if (other.value_ != null) { + if (value_ == null) { + Value = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue(); + } + Value.MergeFrom(other.Value); + } + if (other.timestampValue_ != null) { + if (timestampValue_ == null) { + TimestampValue = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue(); + } + TimestampValue.MergeFrom(other.TimestampValue); + } + if (other.UserId != 0) { + UserId = other.UserId; + } + _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(pb::CodedInputStream input) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + input.ReadRawMessage(this); + #else + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); + break; + case 8: { + ItemHandle = input.ReadInt32(); + break; + } + case 18: { + if (value_ == null) { + Value = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue(); + } + input.ReadMessage(Value); + break; + } + case 26: { + if (timestampValue_ == null) { + TimestampValue = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue(); + } + input.ReadMessage(TimestampValue); + break; + } + case 32: { + UserId = input.ReadInt32(); + break; + } + } + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalMergeFrom(ref pb::ParseContext input) { + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); + break; + case 8: { + ItemHandle = input.ReadInt32(); + break; + } + case 18: { + if (value_ == null) { + Value = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue(); + } + input.ReadMessage(Value); + break; + } + case 26: { + if (timestampValue_ == null) { + TimestampValue = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue(); + } + input.ReadMessage(TimestampValue); + break; + } + case 32: { + UserId = input.ReadInt32(); + break; + } + } + } + } + #endif + + } + + /// + /// Bulk WriteSecured — sequential MXAccess WriteSecured per entry. + /// Credential-sensitive values (`value`) MUST be kept out of logs, metrics + /// labels, command lines, and diagnostics — same redaction rules as the + /// single-item WriteSecured contract. + /// + [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] + public sealed partial class WriteSecuredBulkCommand : pb::IMessage + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + , pb::IBufferMessage + #endif + { + private static readonly pb::MessageParser _parser = new pb::MessageParser(() => new WriteSecuredBulkCommand()); + private pb::UnknownFieldSet _unknownFields; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pb::MessageParser Parser { get { return _parser; } } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pbr::MessageDescriptor Descriptor { + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[40]; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + pbr::MessageDescriptor pb::IMessage.Descriptor { + get { return Descriptor; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public WriteSecuredBulkCommand() { + OnConstruction(); + } + + partial void OnConstruction(); + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public WriteSecuredBulkCommand(WriteSecuredBulkCommand other) : this() { + serverHandle_ = other.serverHandle_; + entries_ = other.entries_.Clone(); + _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public WriteSecuredBulkCommand Clone() { + return new WriteSecuredBulkCommand(this); + } + + /// Field number for the "server_handle" field. + public const int ServerHandleFieldNumber = 1; + private int serverHandle_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int ServerHandle { + get { return serverHandle_; } + set { + serverHandle_ = value; + } + } + + /// Field number for the "entries" field. + public const int EntriesFieldNumber = 2; + private static readonly pb::FieldCodec _repeated_entries_codec + = pb::FieldCodec.ForMessage(18, global::ZB.MOM.WW.MxGateway.Contracts.Proto.WriteSecuredBulkEntry.Parser); + private readonly pbc::RepeatedField entries_ = new pbc::RepeatedField(); + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public pbc::RepeatedField Entries { + get { return entries_; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override bool Equals(object other) { + return Equals(other as WriteSecuredBulkCommand); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool Equals(WriteSecuredBulkCommand other) { + if (ReferenceEquals(other, null)) { + return false; + } + if (ReferenceEquals(other, this)) { + return true; + } + if (ServerHandle != other.ServerHandle) return false; + if(!entries_.Equals(other.entries_)) return false; + return Equals(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override int GetHashCode() { + int hash = 1; + if (ServerHandle != 0) hash ^= ServerHandle.GetHashCode(); + hash ^= entries_.GetHashCode(); + if (_unknownFields != null) { + hash ^= _unknownFields.GetHashCode(); + } + return hash; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override string ToString() { + return pb::JsonFormatter.ToDiagnosticString(this); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void WriteTo(pb::CodedOutputStream output) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + output.WriteRawMessage(this); + #else + if (ServerHandle != 0) { + output.WriteRawTag(8); + output.WriteInt32(ServerHandle); + } + entries_.WriteTo(output, _repeated_entries_codec); + if (_unknownFields != null) { + _unknownFields.WriteTo(output); + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { + if (ServerHandle != 0) { + output.WriteRawTag(8); + output.WriteInt32(ServerHandle); + } + entries_.WriteTo(ref output, _repeated_entries_codec); + if (_unknownFields != null) { + _unknownFields.WriteTo(ref output); + } + } + #endif + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int CalculateSize() { + int size = 0; + if (ServerHandle != 0) { + size += 1 + pb::CodedOutputStream.ComputeInt32Size(ServerHandle); + } + size += entries_.CalculateSize(_repeated_entries_codec); + if (_unknownFields != null) { + size += _unknownFields.CalculateSize(); + } + return size; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(WriteSecuredBulkCommand other) { + if (other == null) { + return; + } + if (other.ServerHandle != 0) { + ServerHandle = other.ServerHandle; + } + entries_.Add(other.entries_); + _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(pb::CodedInputStream input) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + input.ReadRawMessage(this); + #else + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); + break; + case 8: { + ServerHandle = input.ReadInt32(); + break; + } + case 18: { + entries_.AddEntriesFrom(input, _repeated_entries_codec); + break; + } + } + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalMergeFrom(ref pb::ParseContext input) { + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); + break; + case 8: { + ServerHandle = input.ReadInt32(); + break; + } + case 18: { + entries_.AddEntriesFrom(ref input, _repeated_entries_codec); + break; + } + } + } + } + #endif + + } + + [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] + public sealed partial class WriteSecuredBulkEntry : pb::IMessage + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + , pb::IBufferMessage + #endif + { + private static readonly pb::MessageParser _parser = new pb::MessageParser(() => new WriteSecuredBulkEntry()); + private pb::UnknownFieldSet _unknownFields; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pb::MessageParser Parser { get { return _parser; } } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pbr::MessageDescriptor Descriptor { + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[41]; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + pbr::MessageDescriptor pb::IMessage.Descriptor { + get { return Descriptor; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public WriteSecuredBulkEntry() { + OnConstruction(); + } + + partial void OnConstruction(); + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public WriteSecuredBulkEntry(WriteSecuredBulkEntry other) : this() { + itemHandle_ = other.itemHandle_; + currentUserId_ = other.currentUserId_; + verifierUserId_ = other.verifierUserId_; + value_ = other.value_ != null ? other.value_.Clone() : null; + _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public WriteSecuredBulkEntry Clone() { + return new WriteSecuredBulkEntry(this); + } + + /// Field number for the "item_handle" field. + public const int ItemHandleFieldNumber = 1; + private int itemHandle_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int ItemHandle { + get { return itemHandle_; } + set { + itemHandle_ = value; + } + } + + /// Field number for the "current_user_id" field. + public const int CurrentUserIdFieldNumber = 2; + private int currentUserId_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int CurrentUserId { + get { return currentUserId_; } + set { + currentUserId_ = value; + } + } + + /// Field number for the "verifier_user_id" field. + public const int VerifierUserIdFieldNumber = 3; + private int verifierUserId_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int VerifierUserId { + get { return verifierUserId_; } + set { + verifierUserId_ = value; + } + } + + /// Field number for the "value" field. + public const int ValueFieldNumber = 4; + private global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue value_; + /// + /// Credential-sensitive write value. Implementations must not log this field + /// unless an explicit redacted value-logging path is enabled. + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue Value { + get { return value_; } + set { + value_ = value; + } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override bool Equals(object other) { + return Equals(other as WriteSecuredBulkEntry); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool Equals(WriteSecuredBulkEntry other) { + if (ReferenceEquals(other, null)) { + return false; + } + if (ReferenceEquals(other, this)) { + return true; + } + if (ItemHandle != other.ItemHandle) return false; + if (CurrentUserId != other.CurrentUserId) return false; + if (VerifierUserId != other.VerifierUserId) return false; + if (!object.Equals(Value, other.Value)) return false; + return Equals(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override int GetHashCode() { + int hash = 1; + if (ItemHandle != 0) hash ^= ItemHandle.GetHashCode(); + if (CurrentUserId != 0) hash ^= CurrentUserId.GetHashCode(); + if (VerifierUserId != 0) hash ^= VerifierUserId.GetHashCode(); + if (value_ != null) hash ^= Value.GetHashCode(); + if (_unknownFields != null) { + hash ^= _unknownFields.GetHashCode(); + } + return hash; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override string ToString() { + return pb::JsonFormatter.ToDiagnosticString(this); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void WriteTo(pb::CodedOutputStream output) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + output.WriteRawMessage(this); + #else + if (ItemHandle != 0) { + output.WriteRawTag(8); + output.WriteInt32(ItemHandle); + } + if (CurrentUserId != 0) { + output.WriteRawTag(16); + output.WriteInt32(CurrentUserId); + } + if (VerifierUserId != 0) { + output.WriteRawTag(24); + output.WriteInt32(VerifierUserId); + } + if (value_ != null) { + output.WriteRawTag(34); + output.WriteMessage(Value); + } + if (_unknownFields != null) { + _unknownFields.WriteTo(output); + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { + if (ItemHandle != 0) { + output.WriteRawTag(8); + output.WriteInt32(ItemHandle); + } + if (CurrentUserId != 0) { + output.WriteRawTag(16); + output.WriteInt32(CurrentUserId); + } + if (VerifierUserId != 0) { + output.WriteRawTag(24); + output.WriteInt32(VerifierUserId); + } + if (value_ != null) { + output.WriteRawTag(34); + output.WriteMessage(Value); + } + if (_unknownFields != null) { + _unknownFields.WriteTo(ref output); + } + } + #endif + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int CalculateSize() { + int size = 0; + if (ItemHandle != 0) { + size += 1 + pb::CodedOutputStream.ComputeInt32Size(ItemHandle); + } + if (CurrentUserId != 0) { + size += 1 + pb::CodedOutputStream.ComputeInt32Size(CurrentUserId); + } + if (VerifierUserId != 0) { + size += 1 + pb::CodedOutputStream.ComputeInt32Size(VerifierUserId); + } + if (value_ != null) { + size += 1 + pb::CodedOutputStream.ComputeMessageSize(Value); + } + if (_unknownFields != null) { + size += _unknownFields.CalculateSize(); + } + return size; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(WriteSecuredBulkEntry other) { + if (other == null) { + return; + } + if (other.ItemHandle != 0) { + ItemHandle = other.ItemHandle; + } + if (other.CurrentUserId != 0) { + CurrentUserId = other.CurrentUserId; + } + if (other.VerifierUserId != 0) { + VerifierUserId = other.VerifierUserId; + } + if (other.value_ != null) { + if (value_ == null) { + Value = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue(); + } + Value.MergeFrom(other.Value); + } + _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(pb::CodedInputStream input) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + input.ReadRawMessage(this); + #else + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); + break; + case 8: { + ItemHandle = input.ReadInt32(); + break; + } + case 16: { + CurrentUserId = input.ReadInt32(); + break; + } + case 24: { + VerifierUserId = input.ReadInt32(); + break; + } + case 34: { + if (value_ == null) { + Value = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue(); + } + input.ReadMessage(Value); + break; + } + } + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalMergeFrom(ref pb::ParseContext input) { + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); + break; + case 8: { + ItemHandle = input.ReadInt32(); + break; + } + case 16: { + CurrentUserId = input.ReadInt32(); + break; + } + case 24: { + VerifierUserId = input.ReadInt32(); + break; + } + case 34: { + if (value_ == null) { + Value = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue(); + } + input.ReadMessage(Value); + break; + } + } + } + } + #endif + + } + + /// + /// Bulk WriteSecured2 — sequential MXAccess WriteSecured2 (timestamped) per + /// entry. Same redaction rules apply. + /// + [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] + public sealed partial class WriteSecured2BulkCommand : pb::IMessage + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + , pb::IBufferMessage + #endif + { + private static readonly pb::MessageParser _parser = new pb::MessageParser(() => new WriteSecured2BulkCommand()); + private pb::UnknownFieldSet _unknownFields; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pb::MessageParser Parser { get { return _parser; } } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pbr::MessageDescriptor Descriptor { + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[42]; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + pbr::MessageDescriptor pb::IMessage.Descriptor { + get { return Descriptor; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public WriteSecured2BulkCommand() { + OnConstruction(); + } + + partial void OnConstruction(); + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public WriteSecured2BulkCommand(WriteSecured2BulkCommand other) : this() { + serverHandle_ = other.serverHandle_; + entries_ = other.entries_.Clone(); + _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public WriteSecured2BulkCommand Clone() { + return new WriteSecured2BulkCommand(this); + } + + /// Field number for the "server_handle" field. + public const int ServerHandleFieldNumber = 1; + private int serverHandle_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int ServerHandle { + get { return serverHandle_; } + set { + serverHandle_ = value; + } + } + + /// Field number for the "entries" field. + public const int EntriesFieldNumber = 2; + private static readonly pb::FieldCodec _repeated_entries_codec + = pb::FieldCodec.ForMessage(18, global::ZB.MOM.WW.MxGateway.Contracts.Proto.WriteSecured2BulkEntry.Parser); + private readonly pbc::RepeatedField entries_ = new pbc::RepeatedField(); + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public pbc::RepeatedField Entries { + get { return entries_; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override bool Equals(object other) { + return Equals(other as WriteSecured2BulkCommand); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool Equals(WriteSecured2BulkCommand other) { + if (ReferenceEquals(other, null)) { + return false; + } + if (ReferenceEquals(other, this)) { + return true; + } + if (ServerHandle != other.ServerHandle) return false; + if(!entries_.Equals(other.entries_)) return false; + return Equals(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override int GetHashCode() { + int hash = 1; + if (ServerHandle != 0) hash ^= ServerHandle.GetHashCode(); + hash ^= entries_.GetHashCode(); + if (_unknownFields != null) { + hash ^= _unknownFields.GetHashCode(); + } + return hash; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override string ToString() { + return pb::JsonFormatter.ToDiagnosticString(this); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void WriteTo(pb::CodedOutputStream output) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + output.WriteRawMessage(this); + #else + if (ServerHandle != 0) { + output.WriteRawTag(8); + output.WriteInt32(ServerHandle); + } + entries_.WriteTo(output, _repeated_entries_codec); + if (_unknownFields != null) { + _unknownFields.WriteTo(output); + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { + if (ServerHandle != 0) { + output.WriteRawTag(8); + output.WriteInt32(ServerHandle); + } + entries_.WriteTo(ref output, _repeated_entries_codec); + if (_unknownFields != null) { + _unknownFields.WriteTo(ref output); + } + } + #endif + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int CalculateSize() { + int size = 0; + if (ServerHandle != 0) { + size += 1 + pb::CodedOutputStream.ComputeInt32Size(ServerHandle); + } + size += entries_.CalculateSize(_repeated_entries_codec); + if (_unknownFields != null) { + size += _unknownFields.CalculateSize(); + } + return size; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(WriteSecured2BulkCommand other) { + if (other == null) { + return; + } + if (other.ServerHandle != 0) { + ServerHandle = other.ServerHandle; + } + entries_.Add(other.entries_); + _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(pb::CodedInputStream input) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + input.ReadRawMessage(this); + #else + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); + break; + case 8: { + ServerHandle = input.ReadInt32(); + break; + } + case 18: { + entries_.AddEntriesFrom(input, _repeated_entries_codec); + break; + } + } + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalMergeFrom(ref pb::ParseContext input) { + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); + break; + case 8: { + ServerHandle = input.ReadInt32(); + break; + } + case 18: { + entries_.AddEntriesFrom(ref input, _repeated_entries_codec); + break; + } + } + } + } + #endif + + } + + [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] + public sealed partial class WriteSecured2BulkEntry : pb::IMessage + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + , pb::IBufferMessage + #endif + { + private static readonly pb::MessageParser _parser = new pb::MessageParser(() => new WriteSecured2BulkEntry()); + private pb::UnknownFieldSet _unknownFields; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pb::MessageParser Parser { get { return _parser; } } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pbr::MessageDescriptor Descriptor { + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[43]; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + pbr::MessageDescriptor pb::IMessage.Descriptor { + get { return Descriptor; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public WriteSecured2BulkEntry() { + OnConstruction(); + } + + partial void OnConstruction(); + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public WriteSecured2BulkEntry(WriteSecured2BulkEntry other) : this() { + itemHandle_ = other.itemHandle_; + currentUserId_ = other.currentUserId_; + verifierUserId_ = other.verifierUserId_; + value_ = other.value_ != null ? other.value_.Clone() : null; + timestampValue_ = other.timestampValue_ != null ? other.timestampValue_.Clone() : null; + _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public WriteSecured2BulkEntry Clone() { + return new WriteSecured2BulkEntry(this); + } + + /// Field number for the "item_handle" field. + public const int ItemHandleFieldNumber = 1; + private int itemHandle_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int ItemHandle { + get { return itemHandle_; } + set { + itemHandle_ = value; + } + } + + /// Field number for the "current_user_id" field. + public const int CurrentUserIdFieldNumber = 2; + private int currentUserId_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int CurrentUserId { + get { return currentUserId_; } + set { + currentUserId_ = value; + } + } + + /// Field number for the "verifier_user_id" field. + public const int VerifierUserIdFieldNumber = 3; + private int verifierUserId_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int VerifierUserId { + get { return verifierUserId_; } + set { + verifierUserId_ = value; + } + } + + /// Field number for the "value" field. + public const int ValueFieldNumber = 4; + private global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue value_; + /// + /// Credential-sensitive write value. Implementations must not log this field + /// unless an explicit redacted value-logging path is enabled. + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue Value { + get { return value_; } + set { + value_ = value; + } + } + + /// Field number for the "timestamp_value" field. + public const int TimestampValueFieldNumber = 5; + private global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue timestampValue_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue TimestampValue { + get { return timestampValue_; } + set { + timestampValue_ = value; + } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override bool Equals(object other) { + return Equals(other as WriteSecured2BulkEntry); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool Equals(WriteSecured2BulkEntry other) { + if (ReferenceEquals(other, null)) { + return false; + } + if (ReferenceEquals(other, this)) { + return true; + } + if (ItemHandle != other.ItemHandle) return false; + if (CurrentUserId != other.CurrentUserId) return false; + if (VerifierUserId != other.VerifierUserId) return false; + if (!object.Equals(Value, other.Value)) return false; + if (!object.Equals(TimestampValue, other.TimestampValue)) return false; + return Equals(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override int GetHashCode() { + int hash = 1; + if (ItemHandle != 0) hash ^= ItemHandle.GetHashCode(); + if (CurrentUserId != 0) hash ^= CurrentUserId.GetHashCode(); + if (VerifierUserId != 0) hash ^= VerifierUserId.GetHashCode(); + if (value_ != null) hash ^= Value.GetHashCode(); + if (timestampValue_ != null) hash ^= TimestampValue.GetHashCode(); + if (_unknownFields != null) { + hash ^= _unknownFields.GetHashCode(); + } + return hash; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override string ToString() { + return pb::JsonFormatter.ToDiagnosticString(this); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void WriteTo(pb::CodedOutputStream output) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + output.WriteRawMessage(this); + #else + if (ItemHandle != 0) { + output.WriteRawTag(8); + output.WriteInt32(ItemHandle); + } + if (CurrentUserId != 0) { + output.WriteRawTag(16); + output.WriteInt32(CurrentUserId); + } + if (VerifierUserId != 0) { + output.WriteRawTag(24); + output.WriteInt32(VerifierUserId); + } + if (value_ != null) { + output.WriteRawTag(34); + output.WriteMessage(Value); + } + if (timestampValue_ != null) { + output.WriteRawTag(42); + output.WriteMessage(TimestampValue); + } + if (_unknownFields != null) { + _unknownFields.WriteTo(output); + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { + if (ItemHandle != 0) { + output.WriteRawTag(8); + output.WriteInt32(ItemHandle); + } + if (CurrentUserId != 0) { + output.WriteRawTag(16); + output.WriteInt32(CurrentUserId); + } + if (VerifierUserId != 0) { + output.WriteRawTag(24); + output.WriteInt32(VerifierUserId); + } + if (value_ != null) { + output.WriteRawTag(34); + output.WriteMessage(Value); + } + if (timestampValue_ != null) { + output.WriteRawTag(42); + output.WriteMessage(TimestampValue); + } + if (_unknownFields != null) { + _unknownFields.WriteTo(ref output); + } + } + #endif + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int CalculateSize() { + int size = 0; + if (ItemHandle != 0) { + size += 1 + pb::CodedOutputStream.ComputeInt32Size(ItemHandle); + } + if (CurrentUserId != 0) { + size += 1 + pb::CodedOutputStream.ComputeInt32Size(CurrentUserId); + } + if (VerifierUserId != 0) { + size += 1 + pb::CodedOutputStream.ComputeInt32Size(VerifierUserId); + } + if (value_ != null) { + size += 1 + pb::CodedOutputStream.ComputeMessageSize(Value); + } + if (timestampValue_ != null) { + size += 1 + pb::CodedOutputStream.ComputeMessageSize(TimestampValue); + } + if (_unknownFields != null) { + size += _unknownFields.CalculateSize(); + } + return size; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(WriteSecured2BulkEntry other) { + if (other == null) { + return; + } + if (other.ItemHandle != 0) { + ItemHandle = other.ItemHandle; + } + if (other.CurrentUserId != 0) { + CurrentUserId = other.CurrentUserId; + } + if (other.VerifierUserId != 0) { + VerifierUserId = other.VerifierUserId; + } + if (other.value_ != null) { + if (value_ == null) { + Value = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue(); + } + Value.MergeFrom(other.Value); + } + if (other.timestampValue_ != null) { + if (timestampValue_ == null) { + TimestampValue = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue(); + } + TimestampValue.MergeFrom(other.TimestampValue); + } + _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(pb::CodedInputStream input) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + input.ReadRawMessage(this); + #else + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); + break; + case 8: { + ItemHandle = input.ReadInt32(); + break; + } + case 16: { + CurrentUserId = input.ReadInt32(); + break; + } + case 24: { + VerifierUserId = input.ReadInt32(); + break; + } + case 34: { + if (value_ == null) { + Value = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue(); + } + input.ReadMessage(Value); + break; + } + case 42: { + if (timestampValue_ == null) { + TimestampValue = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue(); + } + input.ReadMessage(TimestampValue); + break; + } + } + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalMergeFrom(ref pb::ParseContext input) { + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); + break; + case 8: { + ItemHandle = input.ReadInt32(); + break; + } + case 16: { + CurrentUserId = input.ReadInt32(); + break; + } + case 24: { + VerifierUserId = input.ReadInt32(); + break; + } + case 34: { + if (value_ == null) { + Value = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue(); + } + input.ReadMessage(Value); + break; + } + case 42: { + if (timestampValue_ == null) { + TimestampValue = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue(); + } + input.ReadMessage(TimestampValue); + break; + } + } + } + } + #endif + + } + + /// + /// Bulk Read — snapshot the current value for each requested tag. MXAccess COM + /// has no synchronous Read; the worker implements ReadBulk as: + /// + /// - If the tag is already in the session's item registry AND that item is + /// currently advised AND the worker has a cached OnDataChange for it, the + /// reply returns the cached value WITHOUT modifying the existing + /// subscription (was_cached = true). + /// - Otherwise the worker takes the snapshot lifecycle itself: AddItem + + /// Advise, wait up to `timeout_ms` for the first OnDataChange, then + /// UnAdvise + RemoveItem before returning. The session is left exactly + /// as it was before the call (was_cached = false). + /// + /// `timeout_ms == 0` uses the gateway-configured default (1000 ms). + /// + [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] + public sealed partial class ReadBulkCommand : pb::IMessage + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + , pb::IBufferMessage + #endif + { + private static readonly pb::MessageParser _parser = new pb::MessageParser(() => new ReadBulkCommand()); + private pb::UnknownFieldSet _unknownFields; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pb::MessageParser Parser { get { return _parser; } } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pbr::MessageDescriptor Descriptor { + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[44]; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + pbr::MessageDescriptor pb::IMessage.Descriptor { + get { return Descriptor; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public ReadBulkCommand() { + OnConstruction(); + } + + partial void OnConstruction(); + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public ReadBulkCommand(ReadBulkCommand other) : this() { + serverHandle_ = other.serverHandle_; + tagAddresses_ = other.tagAddresses_.Clone(); + timeoutMs_ = other.timeoutMs_; + _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public ReadBulkCommand Clone() { + return new ReadBulkCommand(this); + } + + /// Field number for the "server_handle" field. + public const int ServerHandleFieldNumber = 1; + private int serverHandle_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int ServerHandle { + get { return serverHandle_; } + set { + serverHandle_ = value; + } + } + + /// Field number for the "tag_addresses" field. + public const int TagAddressesFieldNumber = 2; + private static readonly pb::FieldCodec _repeated_tagAddresses_codec + = pb::FieldCodec.ForString(18); + private readonly pbc::RepeatedField tagAddresses_ = new pbc::RepeatedField(); + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public pbc::RepeatedField TagAddresses { + get { return tagAddresses_; } + } + + /// Field number for the "timeout_ms" field. + public const int TimeoutMsFieldNumber = 3; + private uint timeoutMs_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public uint TimeoutMs { + get { return timeoutMs_; } + set { + timeoutMs_ = value; + } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override bool Equals(object other) { + return Equals(other as ReadBulkCommand); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool Equals(ReadBulkCommand other) { + if (ReferenceEquals(other, null)) { + return false; + } + if (ReferenceEquals(other, this)) { + return true; + } + if (ServerHandle != other.ServerHandle) return false; + if(!tagAddresses_.Equals(other.tagAddresses_)) return false; + if (TimeoutMs != other.TimeoutMs) return false; + return Equals(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override int GetHashCode() { + int hash = 1; + if (ServerHandle != 0) hash ^= ServerHandle.GetHashCode(); + hash ^= tagAddresses_.GetHashCode(); + if (TimeoutMs != 0) hash ^= TimeoutMs.GetHashCode(); + if (_unknownFields != null) { + hash ^= _unknownFields.GetHashCode(); + } + return hash; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override string ToString() { + return pb::JsonFormatter.ToDiagnosticString(this); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void WriteTo(pb::CodedOutputStream output) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + output.WriteRawMessage(this); + #else + if (ServerHandle != 0) { + output.WriteRawTag(8); + output.WriteInt32(ServerHandle); + } + tagAddresses_.WriteTo(output, _repeated_tagAddresses_codec); + if (TimeoutMs != 0) { + output.WriteRawTag(24); + output.WriteUInt32(TimeoutMs); + } + if (_unknownFields != null) { + _unknownFields.WriteTo(output); + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { + if (ServerHandle != 0) { + output.WriteRawTag(8); + output.WriteInt32(ServerHandle); + } + tagAddresses_.WriteTo(ref output, _repeated_tagAddresses_codec); + if (TimeoutMs != 0) { + output.WriteRawTag(24); + output.WriteUInt32(TimeoutMs); + } + if (_unknownFields != null) { + _unknownFields.WriteTo(ref output); + } + } + #endif + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int CalculateSize() { + int size = 0; + if (ServerHandle != 0) { + size += 1 + pb::CodedOutputStream.ComputeInt32Size(ServerHandle); + } + size += tagAddresses_.CalculateSize(_repeated_tagAddresses_codec); + if (TimeoutMs != 0) { + size += 1 + pb::CodedOutputStream.ComputeUInt32Size(TimeoutMs); + } + if (_unknownFields != null) { + size += _unknownFields.CalculateSize(); + } + return size; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(ReadBulkCommand other) { + if (other == null) { + return; + } + if (other.ServerHandle != 0) { + ServerHandle = other.ServerHandle; + } + tagAddresses_.Add(other.tagAddresses_); + if (other.TimeoutMs != 0) { + TimeoutMs = other.TimeoutMs; + } + _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(pb::CodedInputStream input) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + input.ReadRawMessage(this); + #else + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); + break; + case 8: { + ServerHandle = input.ReadInt32(); + break; + } + case 18: { + tagAddresses_.AddEntriesFrom(input, _repeated_tagAddresses_codec); + break; + } + case 24: { + TimeoutMs = input.ReadUInt32(); + break; + } + } + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalMergeFrom(ref pb::ParseContext input) { + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); + break; + case 8: { + ServerHandle = input.ReadInt32(); + break; + } + case 18: { + tagAddresses_.AddEntriesFrom(ref input, _repeated_tagAddresses_codec); + break; + } + case 24: { + TimeoutMs = input.ReadUInt32(); + break; + } + } + } + } + #endif + + } + [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] public sealed partial class PingCommand : pb::IMessage #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE @@ -12079,7 +14914,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[36]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[45]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -12277,7 +15112,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[37]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[46]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -12438,7 +15273,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[38]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[47]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -12599,7 +15434,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[39]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[48]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -12797,7 +15632,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[40]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[49]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -13005,7 +15840,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[41]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[50]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -13083,6 +15918,21 @@ namespace MxGateway.Contracts.Proto { case PayloadOneofCase.QueryActiveAlarms: QueryActiveAlarms = other.QueryActiveAlarms.Clone(); break; + case PayloadOneofCase.WriteBulk: + WriteBulk = other.WriteBulk.Clone(); + break; + case PayloadOneofCase.Write2Bulk: + Write2Bulk = other.Write2Bulk.Clone(); + break; + case PayloadOneofCase.WriteSecuredBulk: + WriteSecuredBulk = other.WriteSecuredBulk.Clone(); + break; + case PayloadOneofCase.WriteSecured2Bulk: + WriteSecured2Bulk = other.WriteSecured2Bulk.Clone(); + break; + case PayloadOneofCase.ReadBulk: + ReadBulk = other.ReadBulk.Clone(); + break; case PayloadOneofCase.SessionState: SessionState = other.SessionState.Clone(); break; @@ -13129,10 +15979,10 @@ namespace MxGateway.Contracts.Proto { /// Field number for the "kind" field. public const int KindFieldNumber = 3; - private global::MxGateway.Contracts.Proto.MxCommandKind kind_ = global::MxGateway.Contracts.Proto.MxCommandKind.Unspecified; + private global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxCommandKind kind_ = global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxCommandKind.Unspecified; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.MxCommandKind Kind { + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxCommandKind Kind { get { return kind_; } set { kind_ = value; @@ -13141,10 +15991,10 @@ namespace MxGateway.Contracts.Proto { /// Field number for the "protocol_status" field. public const int ProtocolStatusFieldNumber = 4; - private global::MxGateway.Contracts.Proto.ProtocolStatus protocolStatus_; + private global::ZB.MOM.WW.MxGateway.Contracts.Proto.ProtocolStatus protocolStatus_; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.ProtocolStatus ProtocolStatus { + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.ProtocolStatus ProtocolStatus { get { return protocolStatus_; } set { protocolStatus_ = value; @@ -13185,10 +16035,10 @@ namespace MxGateway.Contracts.Proto { /// Field number for the "return_value" field. public const int ReturnValueFieldNumber = 6; - private global::MxGateway.Contracts.Proto.MxValue returnValue_; + private global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue returnValue_; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.MxValue ReturnValue { + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue ReturnValue { get { return returnValue_; } set { returnValue_ = value; @@ -13197,12 +16047,12 @@ namespace MxGateway.Contracts.Proto { /// Field number for the "statuses" field. public const int StatusesFieldNumber = 7; - private static readonly pb::FieldCodec _repeated_statuses_codec - = pb::FieldCodec.ForMessage(58, global::MxGateway.Contracts.Proto.MxStatusProxy.Parser); - private readonly pbc::RepeatedField statuses_ = new pbc::RepeatedField(); + private static readonly pb::FieldCodec _repeated_statuses_codec + = pb::FieldCodec.ForMessage(58, global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusProxy.Parser); + private readonly pbc::RepeatedField statuses_ = new pbc::RepeatedField(); [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public pbc::RepeatedField Statuses { + public pbc::RepeatedField Statuses { get { return statuses_; } } @@ -13222,8 +16072,8 @@ namespace MxGateway.Contracts.Proto { public const int RegisterFieldNumber = 20; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.RegisterReply Register { - get { return payloadCase_ == PayloadOneofCase.Register ? (global::MxGateway.Contracts.Proto.RegisterReply) payload_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.RegisterReply Register { + get { return payloadCase_ == PayloadOneofCase.Register ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.RegisterReply) payload_ : null; } set { payload_ = value; payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.Register; @@ -13234,8 +16084,8 @@ namespace MxGateway.Contracts.Proto { public const int AddItemFieldNumber = 21; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.AddItemReply AddItem { - get { return payloadCase_ == PayloadOneofCase.AddItem ? (global::MxGateway.Contracts.Proto.AddItemReply) payload_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.AddItemReply AddItem { + get { return payloadCase_ == PayloadOneofCase.AddItem ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.AddItemReply) payload_ : null; } set { payload_ = value; payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.AddItem; @@ -13246,8 +16096,8 @@ namespace MxGateway.Contracts.Proto { public const int AddItem2FieldNumber = 22; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.AddItem2Reply AddItem2 { - get { return payloadCase_ == PayloadOneofCase.AddItem2 ? (global::MxGateway.Contracts.Proto.AddItem2Reply) payload_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.AddItem2Reply AddItem2 { + get { return payloadCase_ == PayloadOneofCase.AddItem2 ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.AddItem2Reply) payload_ : null; } set { payload_ = value; payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.AddItem2; @@ -13258,8 +16108,8 @@ namespace MxGateway.Contracts.Proto { public const int AddBufferedItemFieldNumber = 23; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.AddBufferedItemReply AddBufferedItem { - get { return payloadCase_ == PayloadOneofCase.AddBufferedItem ? (global::MxGateway.Contracts.Proto.AddBufferedItemReply) payload_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.AddBufferedItemReply AddBufferedItem { + get { return payloadCase_ == PayloadOneofCase.AddBufferedItem ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.AddBufferedItemReply) payload_ : null; } set { payload_ = value; payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.AddBufferedItem; @@ -13270,8 +16120,8 @@ namespace MxGateway.Contracts.Proto { public const int SuspendFieldNumber = 24; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.SuspendReply Suspend { - get { return payloadCase_ == PayloadOneofCase.Suspend ? (global::MxGateway.Contracts.Proto.SuspendReply) payload_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.SuspendReply Suspend { + get { return payloadCase_ == PayloadOneofCase.Suspend ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.SuspendReply) payload_ : null; } set { payload_ = value; payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.Suspend; @@ -13282,8 +16132,8 @@ namespace MxGateway.Contracts.Proto { public const int ActivateFieldNumber = 25; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.ActivateReply Activate { - get { return payloadCase_ == PayloadOneofCase.Activate ? (global::MxGateway.Contracts.Proto.ActivateReply) payload_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.ActivateReply Activate { + get { return payloadCase_ == PayloadOneofCase.Activate ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.ActivateReply) payload_ : null; } set { payload_ = value; payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.Activate; @@ -13294,8 +16144,8 @@ namespace MxGateway.Contracts.Proto { public const int AuthenticateUserFieldNumber = 26; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.AuthenticateUserReply AuthenticateUser { - get { return payloadCase_ == PayloadOneofCase.AuthenticateUser ? (global::MxGateway.Contracts.Proto.AuthenticateUserReply) payload_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.AuthenticateUserReply AuthenticateUser { + get { return payloadCase_ == PayloadOneofCase.AuthenticateUser ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.AuthenticateUserReply) payload_ : null; } set { payload_ = value; payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.AuthenticateUser; @@ -13306,8 +16156,8 @@ namespace MxGateway.Contracts.Proto { public const int ArchestraUserToIdFieldNumber = 27; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.ArchestrAUserToIdReply ArchestraUserToId { - get { return payloadCase_ == PayloadOneofCase.ArchestraUserToId ? (global::MxGateway.Contracts.Proto.ArchestrAUserToIdReply) payload_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.ArchestrAUserToIdReply ArchestraUserToId { + get { return payloadCase_ == PayloadOneofCase.ArchestraUserToId ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.ArchestrAUserToIdReply) payload_ : null; } set { payload_ = value; payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.ArchestraUserToId; @@ -13318,8 +16168,8 @@ namespace MxGateway.Contracts.Proto { public const int AddItemBulkFieldNumber = 28; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.BulkSubscribeReply AddItemBulk { - get { return payloadCase_ == PayloadOneofCase.AddItemBulk ? (global::MxGateway.Contracts.Proto.BulkSubscribeReply) payload_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkSubscribeReply AddItemBulk { + get { return payloadCase_ == PayloadOneofCase.AddItemBulk ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkSubscribeReply) payload_ : null; } set { payload_ = value; payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.AddItemBulk; @@ -13330,8 +16180,8 @@ namespace MxGateway.Contracts.Proto { public const int AdviseItemBulkFieldNumber = 29; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.BulkSubscribeReply AdviseItemBulk { - get { return payloadCase_ == PayloadOneofCase.AdviseItemBulk ? (global::MxGateway.Contracts.Proto.BulkSubscribeReply) payload_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkSubscribeReply AdviseItemBulk { + get { return payloadCase_ == PayloadOneofCase.AdviseItemBulk ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkSubscribeReply) payload_ : null; } set { payload_ = value; payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.AdviseItemBulk; @@ -13342,8 +16192,8 @@ namespace MxGateway.Contracts.Proto { public const int RemoveItemBulkFieldNumber = 30; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.BulkSubscribeReply RemoveItemBulk { - get { return payloadCase_ == PayloadOneofCase.RemoveItemBulk ? (global::MxGateway.Contracts.Proto.BulkSubscribeReply) payload_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkSubscribeReply RemoveItemBulk { + get { return payloadCase_ == PayloadOneofCase.RemoveItemBulk ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkSubscribeReply) payload_ : null; } set { payload_ = value; payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.RemoveItemBulk; @@ -13354,8 +16204,8 @@ namespace MxGateway.Contracts.Proto { public const int UnAdviseItemBulkFieldNumber = 31; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.BulkSubscribeReply UnAdviseItemBulk { - get { return payloadCase_ == PayloadOneofCase.UnAdviseItemBulk ? (global::MxGateway.Contracts.Proto.BulkSubscribeReply) payload_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkSubscribeReply UnAdviseItemBulk { + get { return payloadCase_ == PayloadOneofCase.UnAdviseItemBulk ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkSubscribeReply) payload_ : null; } set { payload_ = value; payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.UnAdviseItemBulk; @@ -13366,8 +16216,8 @@ namespace MxGateway.Contracts.Proto { public const int SubscribeBulkFieldNumber = 32; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.BulkSubscribeReply SubscribeBulk { - get { return payloadCase_ == PayloadOneofCase.SubscribeBulk ? (global::MxGateway.Contracts.Proto.BulkSubscribeReply) payload_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkSubscribeReply SubscribeBulk { + get { return payloadCase_ == PayloadOneofCase.SubscribeBulk ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkSubscribeReply) payload_ : null; } set { payload_ = value; payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.SubscribeBulk; @@ -13378,8 +16228,8 @@ namespace MxGateway.Contracts.Proto { public const int UnsubscribeBulkFieldNumber = 33; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.BulkSubscribeReply UnsubscribeBulk { - get { return payloadCase_ == PayloadOneofCase.UnsubscribeBulk ? (global::MxGateway.Contracts.Proto.BulkSubscribeReply) payload_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkSubscribeReply UnsubscribeBulk { + get { return payloadCase_ == PayloadOneofCase.UnsubscribeBulk ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkSubscribeReply) payload_ : null; } set { payload_ = value; payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.UnsubscribeBulk; @@ -13388,10 +16238,21 @@ namespace MxGateway.Contracts.Proto { /// Field number for the "acknowledge_alarm" field. public const int AcknowledgeAlarmFieldNumber = 34; + /// + /// Reply payload for BOTH MX_COMMAND_KIND_ACKNOWLEDGE_ALARM (by GUID) + /// and MX_COMMAND_KIND_ACKNOWLEDGE_ALARM_BY_NAME. There is intentionally + /// no by-name-specific reply case: the by-name ack carries no outcome + /// detail beyond the native ack return code, so the worker reuses this + /// `acknowledge_alarm` payload for both command kinds (the worker's + /// MxAccessCommandExecutor sets `acknowledge_alarm` for the by-name arm + /// too). Consumers must dispatch on MxCommandReply.kind, not on the + /// payload case, to tell the two acks apart. The top-level `hresult` + /// mirrors AcknowledgeAlarmReplyPayload.native_status and is preferred. + /// [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.AcknowledgeAlarmReplyPayload AcknowledgeAlarm { - get { return payloadCase_ == PayloadOneofCase.AcknowledgeAlarm ? (global::MxGateway.Contracts.Proto.AcknowledgeAlarmReplyPayload) payload_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmReplyPayload AcknowledgeAlarm { + get { return payloadCase_ == PayloadOneofCase.AcknowledgeAlarm ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmReplyPayload) payload_ : null; } set { payload_ = value; payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.AcknowledgeAlarm; @@ -13402,20 +16263,80 @@ namespace MxGateway.Contracts.Proto { public const int QueryActiveAlarmsFieldNumber = 35; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.QueryActiveAlarmsReplyPayload QueryActiveAlarms { - get { return payloadCase_ == PayloadOneofCase.QueryActiveAlarms ? (global::MxGateway.Contracts.Proto.QueryActiveAlarmsReplyPayload) payload_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.QueryActiveAlarmsReplyPayload QueryActiveAlarms { + get { return payloadCase_ == PayloadOneofCase.QueryActiveAlarms ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.QueryActiveAlarmsReplyPayload) payload_ : null; } set { payload_ = value; payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.QueryActiveAlarms; } } + /// Field number for the "write_bulk" field. + public const int WriteBulkFieldNumber = 36; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkWriteReply WriteBulk { + get { return payloadCase_ == PayloadOneofCase.WriteBulk ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkWriteReply) payload_ : null; } + set { + payload_ = value; + payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.WriteBulk; + } + } + + /// Field number for the "write2_bulk" field. + public const int Write2BulkFieldNumber = 37; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkWriteReply Write2Bulk { + get { return payloadCase_ == PayloadOneofCase.Write2Bulk ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkWriteReply) payload_ : null; } + set { + payload_ = value; + payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.Write2Bulk; + } + } + + /// Field number for the "write_secured_bulk" field. + public const int WriteSecuredBulkFieldNumber = 38; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkWriteReply WriteSecuredBulk { + get { return payloadCase_ == PayloadOneofCase.WriteSecuredBulk ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkWriteReply) payload_ : null; } + set { + payload_ = value; + payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.WriteSecuredBulk; + } + } + + /// Field number for the "write_secured2_bulk" field. + public const int WriteSecured2BulkFieldNumber = 39; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkWriteReply WriteSecured2Bulk { + get { return payloadCase_ == PayloadOneofCase.WriteSecured2Bulk ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkWriteReply) payload_ : null; } + set { + payload_ = value; + payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.WriteSecured2Bulk; + } + } + + /// Field number for the "read_bulk" field. + public const int ReadBulkFieldNumber = 40; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkReadReply ReadBulk { + get { return payloadCase_ == PayloadOneofCase.ReadBulk ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkReadReply) payload_ : null; } + set { + payload_ = value; + payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.ReadBulk; + } + } + /// Field number for the "session_state" field. public const int SessionStateFieldNumber = 100; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.SessionStateReply SessionState { - get { return payloadCase_ == PayloadOneofCase.SessionState ? (global::MxGateway.Contracts.Proto.SessionStateReply) payload_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.SessionStateReply SessionState { + get { return payloadCase_ == PayloadOneofCase.SessionState ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.SessionStateReply) payload_ : null; } set { payload_ = value; payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.SessionState; @@ -13426,8 +16347,8 @@ namespace MxGateway.Contracts.Proto { public const int WorkerInfoFieldNumber = 101; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.WorkerInfoReply WorkerInfo { - get { return payloadCase_ == PayloadOneofCase.WorkerInfo ? (global::MxGateway.Contracts.Proto.WorkerInfoReply) payload_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerInfoReply WorkerInfo { + get { return payloadCase_ == PayloadOneofCase.WorkerInfo ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerInfoReply) payload_ : null; } set { payload_ = value; payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.WorkerInfo; @@ -13438,8 +16359,8 @@ namespace MxGateway.Contracts.Proto { public const int DrainEventsFieldNumber = 102; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.DrainEventsReply DrainEvents { - get { return payloadCase_ == PayloadOneofCase.DrainEvents ? (global::MxGateway.Contracts.Proto.DrainEventsReply) payload_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.DrainEventsReply DrainEvents { + get { return payloadCase_ == PayloadOneofCase.DrainEvents ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.DrainEventsReply) payload_ : null; } set { payload_ = value; payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.DrainEvents; @@ -13466,6 +16387,11 @@ namespace MxGateway.Contracts.Proto { UnsubscribeBulk = 33, AcknowledgeAlarm = 34, QueryActiveAlarms = 35, + WriteBulk = 36, + Write2Bulk = 37, + WriteSecuredBulk = 38, + WriteSecured2Bulk = 39, + ReadBulk = 40, SessionState = 100, WorkerInfo = 101, DrainEvents = 102, @@ -13523,6 +16449,11 @@ namespace MxGateway.Contracts.Proto { if (!object.Equals(UnsubscribeBulk, other.UnsubscribeBulk)) return false; if (!object.Equals(AcknowledgeAlarm, other.AcknowledgeAlarm)) return false; if (!object.Equals(QueryActiveAlarms, other.QueryActiveAlarms)) return false; + if (!object.Equals(WriteBulk, other.WriteBulk)) return false; + if (!object.Equals(Write2Bulk, other.Write2Bulk)) return false; + if (!object.Equals(WriteSecuredBulk, other.WriteSecuredBulk)) return false; + if (!object.Equals(WriteSecured2Bulk, other.WriteSecured2Bulk)) return false; + if (!object.Equals(ReadBulk, other.ReadBulk)) return false; if (!object.Equals(SessionState, other.SessionState)) return false; if (!object.Equals(WorkerInfo, other.WorkerInfo)) return false; if (!object.Equals(DrainEvents, other.DrainEvents)) return false; @@ -13536,7 +16467,7 @@ namespace MxGateway.Contracts.Proto { int hash = 1; if (SessionId.Length != 0) hash ^= SessionId.GetHashCode(); if (CorrelationId.Length != 0) hash ^= CorrelationId.GetHashCode(); - if (Kind != global::MxGateway.Contracts.Proto.MxCommandKind.Unspecified) hash ^= Kind.GetHashCode(); + if (Kind != global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxCommandKind.Unspecified) hash ^= Kind.GetHashCode(); if (protocolStatus_ != null) hash ^= ProtocolStatus.GetHashCode(); if (HasHresult) hash ^= Hresult.GetHashCode(); if (returnValue_ != null) hash ^= ReturnValue.GetHashCode(); @@ -13558,6 +16489,11 @@ namespace MxGateway.Contracts.Proto { if (payloadCase_ == PayloadOneofCase.UnsubscribeBulk) hash ^= UnsubscribeBulk.GetHashCode(); if (payloadCase_ == PayloadOneofCase.AcknowledgeAlarm) hash ^= AcknowledgeAlarm.GetHashCode(); if (payloadCase_ == PayloadOneofCase.QueryActiveAlarms) hash ^= QueryActiveAlarms.GetHashCode(); + if (payloadCase_ == PayloadOneofCase.WriteBulk) hash ^= WriteBulk.GetHashCode(); + if (payloadCase_ == PayloadOneofCase.Write2Bulk) hash ^= Write2Bulk.GetHashCode(); + if (payloadCase_ == PayloadOneofCase.WriteSecuredBulk) hash ^= WriteSecuredBulk.GetHashCode(); + if (payloadCase_ == PayloadOneofCase.WriteSecured2Bulk) hash ^= WriteSecured2Bulk.GetHashCode(); + if (payloadCase_ == PayloadOneofCase.ReadBulk) hash ^= ReadBulk.GetHashCode(); if (payloadCase_ == PayloadOneofCase.SessionState) hash ^= SessionState.GetHashCode(); if (payloadCase_ == PayloadOneofCase.WorkerInfo) hash ^= WorkerInfo.GetHashCode(); if (payloadCase_ == PayloadOneofCase.DrainEvents) hash ^= DrainEvents.GetHashCode(); @@ -13588,7 +16524,7 @@ namespace MxGateway.Contracts.Proto { output.WriteRawTag(18); output.WriteString(CorrelationId); } - if (Kind != global::MxGateway.Contracts.Proto.MxCommandKind.Unspecified) { + if (Kind != global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxCommandKind.Unspecified) { output.WriteRawTag(24); output.WriteEnum((int) Kind); } @@ -13673,6 +16609,26 @@ namespace MxGateway.Contracts.Proto { output.WriteRawTag(154, 2); output.WriteMessage(QueryActiveAlarms); } + if (payloadCase_ == PayloadOneofCase.WriteBulk) { + output.WriteRawTag(162, 2); + output.WriteMessage(WriteBulk); + } + if (payloadCase_ == PayloadOneofCase.Write2Bulk) { + output.WriteRawTag(170, 2); + output.WriteMessage(Write2Bulk); + } + if (payloadCase_ == PayloadOneofCase.WriteSecuredBulk) { + output.WriteRawTag(178, 2); + output.WriteMessage(WriteSecuredBulk); + } + if (payloadCase_ == PayloadOneofCase.WriteSecured2Bulk) { + output.WriteRawTag(186, 2); + output.WriteMessage(WriteSecured2Bulk); + } + if (payloadCase_ == PayloadOneofCase.ReadBulk) { + output.WriteRawTag(194, 2); + output.WriteMessage(ReadBulk); + } if (payloadCase_ == PayloadOneofCase.SessionState) { output.WriteRawTag(162, 6); output.WriteMessage(SessionState); @@ -13703,7 +16659,7 @@ namespace MxGateway.Contracts.Proto { output.WriteRawTag(18); output.WriteString(CorrelationId); } - if (Kind != global::MxGateway.Contracts.Proto.MxCommandKind.Unspecified) { + if (Kind != global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxCommandKind.Unspecified) { output.WriteRawTag(24); output.WriteEnum((int) Kind); } @@ -13788,6 +16744,26 @@ namespace MxGateway.Contracts.Proto { output.WriteRawTag(154, 2); output.WriteMessage(QueryActiveAlarms); } + if (payloadCase_ == PayloadOneofCase.WriteBulk) { + output.WriteRawTag(162, 2); + output.WriteMessage(WriteBulk); + } + if (payloadCase_ == PayloadOneofCase.Write2Bulk) { + output.WriteRawTag(170, 2); + output.WriteMessage(Write2Bulk); + } + if (payloadCase_ == PayloadOneofCase.WriteSecuredBulk) { + output.WriteRawTag(178, 2); + output.WriteMessage(WriteSecuredBulk); + } + if (payloadCase_ == PayloadOneofCase.WriteSecured2Bulk) { + output.WriteRawTag(186, 2); + output.WriteMessage(WriteSecured2Bulk); + } + if (payloadCase_ == PayloadOneofCase.ReadBulk) { + output.WriteRawTag(194, 2); + output.WriteMessage(ReadBulk); + } if (payloadCase_ == PayloadOneofCase.SessionState) { output.WriteRawTag(162, 6); output.WriteMessage(SessionState); @@ -13816,7 +16792,7 @@ namespace MxGateway.Contracts.Proto { if (CorrelationId.Length != 0) { size += 1 + pb::CodedOutputStream.ComputeStringSize(CorrelationId); } - if (Kind != global::MxGateway.Contracts.Proto.MxCommandKind.Unspecified) { + if (Kind != global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxCommandKind.Unspecified) { size += 1 + pb::CodedOutputStream.ComputeEnumSize((int) Kind); } if (protocolStatus_ != null) { @@ -13880,6 +16856,21 @@ namespace MxGateway.Contracts.Proto { if (payloadCase_ == PayloadOneofCase.QueryActiveAlarms) { size += 2 + pb::CodedOutputStream.ComputeMessageSize(QueryActiveAlarms); } + if (payloadCase_ == PayloadOneofCase.WriteBulk) { + size += 2 + pb::CodedOutputStream.ComputeMessageSize(WriteBulk); + } + if (payloadCase_ == PayloadOneofCase.Write2Bulk) { + size += 2 + pb::CodedOutputStream.ComputeMessageSize(Write2Bulk); + } + if (payloadCase_ == PayloadOneofCase.WriteSecuredBulk) { + size += 2 + pb::CodedOutputStream.ComputeMessageSize(WriteSecuredBulk); + } + if (payloadCase_ == PayloadOneofCase.WriteSecured2Bulk) { + size += 2 + pb::CodedOutputStream.ComputeMessageSize(WriteSecured2Bulk); + } + if (payloadCase_ == PayloadOneofCase.ReadBulk) { + size += 2 + pb::CodedOutputStream.ComputeMessageSize(ReadBulk); + } if (payloadCase_ == PayloadOneofCase.SessionState) { size += 2 + pb::CodedOutputStream.ComputeMessageSize(SessionState); } @@ -13907,12 +16898,12 @@ namespace MxGateway.Contracts.Proto { if (other.CorrelationId.Length != 0) { CorrelationId = other.CorrelationId; } - if (other.Kind != global::MxGateway.Contracts.Proto.MxCommandKind.Unspecified) { + if (other.Kind != global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxCommandKind.Unspecified) { Kind = other.Kind; } if (other.protocolStatus_ != null) { if (protocolStatus_ == null) { - ProtocolStatus = new global::MxGateway.Contracts.Proto.ProtocolStatus(); + ProtocolStatus = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.ProtocolStatus(); } ProtocolStatus.MergeFrom(other.ProtocolStatus); } @@ -13921,7 +16912,7 @@ namespace MxGateway.Contracts.Proto { } if (other.returnValue_ != null) { if (returnValue_ == null) { - ReturnValue = new global::MxGateway.Contracts.Proto.MxValue(); + ReturnValue = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue(); } ReturnValue.MergeFrom(other.ReturnValue); } @@ -13932,115 +16923,145 @@ namespace MxGateway.Contracts.Proto { switch (other.PayloadCase) { case PayloadOneofCase.Register: if (Register == null) { - Register = new global::MxGateway.Contracts.Proto.RegisterReply(); + Register = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.RegisterReply(); } Register.MergeFrom(other.Register); break; case PayloadOneofCase.AddItem: if (AddItem == null) { - AddItem = new global::MxGateway.Contracts.Proto.AddItemReply(); + AddItem = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.AddItemReply(); } AddItem.MergeFrom(other.AddItem); break; case PayloadOneofCase.AddItem2: if (AddItem2 == null) { - AddItem2 = new global::MxGateway.Contracts.Proto.AddItem2Reply(); + AddItem2 = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.AddItem2Reply(); } AddItem2.MergeFrom(other.AddItem2); break; case PayloadOneofCase.AddBufferedItem: if (AddBufferedItem == null) { - AddBufferedItem = new global::MxGateway.Contracts.Proto.AddBufferedItemReply(); + AddBufferedItem = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.AddBufferedItemReply(); } AddBufferedItem.MergeFrom(other.AddBufferedItem); break; case PayloadOneofCase.Suspend: if (Suspend == null) { - Suspend = new global::MxGateway.Contracts.Proto.SuspendReply(); + Suspend = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.SuspendReply(); } Suspend.MergeFrom(other.Suspend); break; case PayloadOneofCase.Activate: if (Activate == null) { - Activate = new global::MxGateway.Contracts.Proto.ActivateReply(); + Activate = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.ActivateReply(); } Activate.MergeFrom(other.Activate); break; case PayloadOneofCase.AuthenticateUser: if (AuthenticateUser == null) { - AuthenticateUser = new global::MxGateway.Contracts.Proto.AuthenticateUserReply(); + AuthenticateUser = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.AuthenticateUserReply(); } AuthenticateUser.MergeFrom(other.AuthenticateUser); break; case PayloadOneofCase.ArchestraUserToId: if (ArchestraUserToId == null) { - ArchestraUserToId = new global::MxGateway.Contracts.Proto.ArchestrAUserToIdReply(); + ArchestraUserToId = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.ArchestrAUserToIdReply(); } ArchestraUserToId.MergeFrom(other.ArchestraUserToId); break; case PayloadOneofCase.AddItemBulk: if (AddItemBulk == null) { - AddItemBulk = new global::MxGateway.Contracts.Proto.BulkSubscribeReply(); + AddItemBulk = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkSubscribeReply(); } AddItemBulk.MergeFrom(other.AddItemBulk); break; case PayloadOneofCase.AdviseItemBulk: if (AdviseItemBulk == null) { - AdviseItemBulk = new global::MxGateway.Contracts.Proto.BulkSubscribeReply(); + AdviseItemBulk = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkSubscribeReply(); } AdviseItemBulk.MergeFrom(other.AdviseItemBulk); break; case PayloadOneofCase.RemoveItemBulk: if (RemoveItemBulk == null) { - RemoveItemBulk = new global::MxGateway.Contracts.Proto.BulkSubscribeReply(); + RemoveItemBulk = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkSubscribeReply(); } RemoveItemBulk.MergeFrom(other.RemoveItemBulk); break; case PayloadOneofCase.UnAdviseItemBulk: if (UnAdviseItemBulk == null) { - UnAdviseItemBulk = new global::MxGateway.Contracts.Proto.BulkSubscribeReply(); + UnAdviseItemBulk = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkSubscribeReply(); } UnAdviseItemBulk.MergeFrom(other.UnAdviseItemBulk); break; case PayloadOneofCase.SubscribeBulk: if (SubscribeBulk == null) { - SubscribeBulk = new global::MxGateway.Contracts.Proto.BulkSubscribeReply(); + SubscribeBulk = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkSubscribeReply(); } SubscribeBulk.MergeFrom(other.SubscribeBulk); break; case PayloadOneofCase.UnsubscribeBulk: if (UnsubscribeBulk == null) { - UnsubscribeBulk = new global::MxGateway.Contracts.Proto.BulkSubscribeReply(); + UnsubscribeBulk = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkSubscribeReply(); } UnsubscribeBulk.MergeFrom(other.UnsubscribeBulk); break; case PayloadOneofCase.AcknowledgeAlarm: if (AcknowledgeAlarm == null) { - AcknowledgeAlarm = new global::MxGateway.Contracts.Proto.AcknowledgeAlarmReplyPayload(); + AcknowledgeAlarm = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmReplyPayload(); } AcknowledgeAlarm.MergeFrom(other.AcknowledgeAlarm); break; case PayloadOneofCase.QueryActiveAlarms: if (QueryActiveAlarms == null) { - QueryActiveAlarms = new global::MxGateway.Contracts.Proto.QueryActiveAlarmsReplyPayload(); + QueryActiveAlarms = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.QueryActiveAlarmsReplyPayload(); } QueryActiveAlarms.MergeFrom(other.QueryActiveAlarms); break; + case PayloadOneofCase.WriteBulk: + if (WriteBulk == null) { + WriteBulk = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkWriteReply(); + } + WriteBulk.MergeFrom(other.WriteBulk); + break; + case PayloadOneofCase.Write2Bulk: + if (Write2Bulk == null) { + Write2Bulk = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkWriteReply(); + } + Write2Bulk.MergeFrom(other.Write2Bulk); + break; + case PayloadOneofCase.WriteSecuredBulk: + if (WriteSecuredBulk == null) { + WriteSecuredBulk = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkWriteReply(); + } + WriteSecuredBulk.MergeFrom(other.WriteSecuredBulk); + break; + case PayloadOneofCase.WriteSecured2Bulk: + if (WriteSecured2Bulk == null) { + WriteSecured2Bulk = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkWriteReply(); + } + WriteSecured2Bulk.MergeFrom(other.WriteSecured2Bulk); + break; + case PayloadOneofCase.ReadBulk: + if (ReadBulk == null) { + ReadBulk = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkReadReply(); + } + ReadBulk.MergeFrom(other.ReadBulk); + break; case PayloadOneofCase.SessionState: if (SessionState == null) { - SessionState = new global::MxGateway.Contracts.Proto.SessionStateReply(); + SessionState = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.SessionStateReply(); } SessionState.MergeFrom(other.SessionState); break; case PayloadOneofCase.WorkerInfo: if (WorkerInfo == null) { - WorkerInfo = new global::MxGateway.Contracts.Proto.WorkerInfoReply(); + WorkerInfo = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerInfoReply(); } WorkerInfo.MergeFrom(other.WorkerInfo); break; case PayloadOneofCase.DrainEvents: if (DrainEvents == null) { - DrainEvents = new global::MxGateway.Contracts.Proto.DrainEventsReply(); + DrainEvents = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.DrainEventsReply(); } DrainEvents.MergeFrom(other.DrainEvents); break; @@ -14074,12 +17095,12 @@ namespace MxGateway.Contracts.Proto { break; } case 24: { - Kind = (global::MxGateway.Contracts.Proto.MxCommandKind) input.ReadEnum(); + Kind = (global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxCommandKind) input.ReadEnum(); break; } case 34: { if (protocolStatus_ == null) { - ProtocolStatus = new global::MxGateway.Contracts.Proto.ProtocolStatus(); + ProtocolStatus = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.ProtocolStatus(); } input.ReadMessage(ProtocolStatus); break; @@ -14090,7 +17111,7 @@ namespace MxGateway.Contracts.Proto { } case 50: { if (returnValue_ == null) { - ReturnValue = new global::MxGateway.Contracts.Proto.MxValue(); + ReturnValue = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue(); } input.ReadMessage(ReturnValue); break; @@ -14104,7 +17125,7 @@ namespace MxGateway.Contracts.Proto { break; } case 162: { - global::MxGateway.Contracts.Proto.RegisterReply subBuilder = new global::MxGateway.Contracts.Proto.RegisterReply(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.RegisterReply subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.RegisterReply(); if (payloadCase_ == PayloadOneofCase.Register) { subBuilder.MergeFrom(Register); } @@ -14113,7 +17134,7 @@ namespace MxGateway.Contracts.Proto { break; } case 170: { - global::MxGateway.Contracts.Proto.AddItemReply subBuilder = new global::MxGateway.Contracts.Proto.AddItemReply(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.AddItemReply subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.AddItemReply(); if (payloadCase_ == PayloadOneofCase.AddItem) { subBuilder.MergeFrom(AddItem); } @@ -14122,7 +17143,7 @@ namespace MxGateway.Contracts.Proto { break; } case 178: { - global::MxGateway.Contracts.Proto.AddItem2Reply subBuilder = new global::MxGateway.Contracts.Proto.AddItem2Reply(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.AddItem2Reply subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.AddItem2Reply(); if (payloadCase_ == PayloadOneofCase.AddItem2) { subBuilder.MergeFrom(AddItem2); } @@ -14131,7 +17152,7 @@ namespace MxGateway.Contracts.Proto { break; } case 186: { - global::MxGateway.Contracts.Proto.AddBufferedItemReply subBuilder = new global::MxGateway.Contracts.Proto.AddBufferedItemReply(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.AddBufferedItemReply subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.AddBufferedItemReply(); if (payloadCase_ == PayloadOneofCase.AddBufferedItem) { subBuilder.MergeFrom(AddBufferedItem); } @@ -14140,7 +17161,7 @@ namespace MxGateway.Contracts.Proto { break; } case 194: { - global::MxGateway.Contracts.Proto.SuspendReply subBuilder = new global::MxGateway.Contracts.Proto.SuspendReply(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.SuspendReply subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.SuspendReply(); if (payloadCase_ == PayloadOneofCase.Suspend) { subBuilder.MergeFrom(Suspend); } @@ -14149,7 +17170,7 @@ namespace MxGateway.Contracts.Proto { break; } case 202: { - global::MxGateway.Contracts.Proto.ActivateReply subBuilder = new global::MxGateway.Contracts.Proto.ActivateReply(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.ActivateReply subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.ActivateReply(); if (payloadCase_ == PayloadOneofCase.Activate) { subBuilder.MergeFrom(Activate); } @@ -14158,7 +17179,7 @@ namespace MxGateway.Contracts.Proto { break; } case 210: { - global::MxGateway.Contracts.Proto.AuthenticateUserReply subBuilder = new global::MxGateway.Contracts.Proto.AuthenticateUserReply(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.AuthenticateUserReply subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.AuthenticateUserReply(); if (payloadCase_ == PayloadOneofCase.AuthenticateUser) { subBuilder.MergeFrom(AuthenticateUser); } @@ -14167,7 +17188,7 @@ namespace MxGateway.Contracts.Proto { break; } case 218: { - global::MxGateway.Contracts.Proto.ArchestrAUserToIdReply subBuilder = new global::MxGateway.Contracts.Proto.ArchestrAUserToIdReply(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.ArchestrAUserToIdReply subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.ArchestrAUserToIdReply(); if (payloadCase_ == PayloadOneofCase.ArchestraUserToId) { subBuilder.MergeFrom(ArchestraUserToId); } @@ -14176,7 +17197,7 @@ namespace MxGateway.Contracts.Proto { break; } case 226: { - global::MxGateway.Contracts.Proto.BulkSubscribeReply subBuilder = new global::MxGateway.Contracts.Proto.BulkSubscribeReply(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkSubscribeReply subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkSubscribeReply(); if (payloadCase_ == PayloadOneofCase.AddItemBulk) { subBuilder.MergeFrom(AddItemBulk); } @@ -14185,7 +17206,7 @@ namespace MxGateway.Contracts.Proto { break; } case 234: { - global::MxGateway.Contracts.Proto.BulkSubscribeReply subBuilder = new global::MxGateway.Contracts.Proto.BulkSubscribeReply(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkSubscribeReply subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkSubscribeReply(); if (payloadCase_ == PayloadOneofCase.AdviseItemBulk) { subBuilder.MergeFrom(AdviseItemBulk); } @@ -14194,7 +17215,7 @@ namespace MxGateway.Contracts.Proto { break; } case 242: { - global::MxGateway.Contracts.Proto.BulkSubscribeReply subBuilder = new global::MxGateway.Contracts.Proto.BulkSubscribeReply(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkSubscribeReply subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkSubscribeReply(); if (payloadCase_ == PayloadOneofCase.RemoveItemBulk) { subBuilder.MergeFrom(RemoveItemBulk); } @@ -14203,7 +17224,7 @@ namespace MxGateway.Contracts.Proto { break; } case 250: { - global::MxGateway.Contracts.Proto.BulkSubscribeReply subBuilder = new global::MxGateway.Contracts.Proto.BulkSubscribeReply(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkSubscribeReply subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkSubscribeReply(); if (payloadCase_ == PayloadOneofCase.UnAdviseItemBulk) { subBuilder.MergeFrom(UnAdviseItemBulk); } @@ -14212,7 +17233,7 @@ namespace MxGateway.Contracts.Proto { break; } case 258: { - global::MxGateway.Contracts.Proto.BulkSubscribeReply subBuilder = new global::MxGateway.Contracts.Proto.BulkSubscribeReply(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkSubscribeReply subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkSubscribeReply(); if (payloadCase_ == PayloadOneofCase.SubscribeBulk) { subBuilder.MergeFrom(SubscribeBulk); } @@ -14221,7 +17242,7 @@ namespace MxGateway.Contracts.Proto { break; } case 266: { - global::MxGateway.Contracts.Proto.BulkSubscribeReply subBuilder = new global::MxGateway.Contracts.Proto.BulkSubscribeReply(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkSubscribeReply subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkSubscribeReply(); if (payloadCase_ == PayloadOneofCase.UnsubscribeBulk) { subBuilder.MergeFrom(UnsubscribeBulk); } @@ -14230,7 +17251,7 @@ namespace MxGateway.Contracts.Proto { break; } case 274: { - global::MxGateway.Contracts.Proto.AcknowledgeAlarmReplyPayload subBuilder = new global::MxGateway.Contracts.Proto.AcknowledgeAlarmReplyPayload(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmReplyPayload subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmReplyPayload(); if (payloadCase_ == PayloadOneofCase.AcknowledgeAlarm) { subBuilder.MergeFrom(AcknowledgeAlarm); } @@ -14239,7 +17260,7 @@ namespace MxGateway.Contracts.Proto { break; } case 282: { - global::MxGateway.Contracts.Proto.QueryActiveAlarmsReplyPayload subBuilder = new global::MxGateway.Contracts.Proto.QueryActiveAlarmsReplyPayload(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.QueryActiveAlarmsReplyPayload subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.QueryActiveAlarmsReplyPayload(); if (payloadCase_ == PayloadOneofCase.QueryActiveAlarms) { subBuilder.MergeFrom(QueryActiveAlarms); } @@ -14247,8 +17268,53 @@ namespace MxGateway.Contracts.Proto { QueryActiveAlarms = subBuilder; break; } + case 290: { + global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkWriteReply subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkWriteReply(); + if (payloadCase_ == PayloadOneofCase.WriteBulk) { + subBuilder.MergeFrom(WriteBulk); + } + input.ReadMessage(subBuilder); + WriteBulk = subBuilder; + break; + } + case 298: { + global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkWriteReply subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkWriteReply(); + if (payloadCase_ == PayloadOneofCase.Write2Bulk) { + subBuilder.MergeFrom(Write2Bulk); + } + input.ReadMessage(subBuilder); + Write2Bulk = subBuilder; + break; + } + case 306: { + global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkWriteReply subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkWriteReply(); + if (payloadCase_ == PayloadOneofCase.WriteSecuredBulk) { + subBuilder.MergeFrom(WriteSecuredBulk); + } + input.ReadMessage(subBuilder); + WriteSecuredBulk = subBuilder; + break; + } + case 314: { + global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkWriteReply subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkWriteReply(); + if (payloadCase_ == PayloadOneofCase.WriteSecured2Bulk) { + subBuilder.MergeFrom(WriteSecured2Bulk); + } + input.ReadMessage(subBuilder); + WriteSecured2Bulk = subBuilder; + break; + } + case 322: { + global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkReadReply subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkReadReply(); + if (payloadCase_ == PayloadOneofCase.ReadBulk) { + subBuilder.MergeFrom(ReadBulk); + } + input.ReadMessage(subBuilder); + ReadBulk = subBuilder; + break; + } case 802: { - global::MxGateway.Contracts.Proto.SessionStateReply subBuilder = new global::MxGateway.Contracts.Proto.SessionStateReply(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.SessionStateReply subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.SessionStateReply(); if (payloadCase_ == PayloadOneofCase.SessionState) { subBuilder.MergeFrom(SessionState); } @@ -14257,7 +17323,7 @@ namespace MxGateway.Contracts.Proto { break; } case 810: { - global::MxGateway.Contracts.Proto.WorkerInfoReply subBuilder = new global::MxGateway.Contracts.Proto.WorkerInfoReply(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerInfoReply subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerInfoReply(); if (payloadCase_ == PayloadOneofCase.WorkerInfo) { subBuilder.MergeFrom(WorkerInfo); } @@ -14266,7 +17332,7 @@ namespace MxGateway.Contracts.Proto { break; } case 818: { - global::MxGateway.Contracts.Proto.DrainEventsReply subBuilder = new global::MxGateway.Contracts.Proto.DrainEventsReply(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.DrainEventsReply subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.DrainEventsReply(); if (payloadCase_ == PayloadOneofCase.DrainEvents) { subBuilder.MergeFrom(DrainEvents); } @@ -14302,12 +17368,12 @@ namespace MxGateway.Contracts.Proto { break; } case 24: { - Kind = (global::MxGateway.Contracts.Proto.MxCommandKind) input.ReadEnum(); + Kind = (global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxCommandKind) input.ReadEnum(); break; } case 34: { if (protocolStatus_ == null) { - ProtocolStatus = new global::MxGateway.Contracts.Proto.ProtocolStatus(); + ProtocolStatus = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.ProtocolStatus(); } input.ReadMessage(ProtocolStatus); break; @@ -14318,7 +17384,7 @@ namespace MxGateway.Contracts.Proto { } case 50: { if (returnValue_ == null) { - ReturnValue = new global::MxGateway.Contracts.Proto.MxValue(); + ReturnValue = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue(); } input.ReadMessage(ReturnValue); break; @@ -14332,7 +17398,7 @@ namespace MxGateway.Contracts.Proto { break; } case 162: { - global::MxGateway.Contracts.Proto.RegisterReply subBuilder = new global::MxGateway.Contracts.Proto.RegisterReply(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.RegisterReply subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.RegisterReply(); if (payloadCase_ == PayloadOneofCase.Register) { subBuilder.MergeFrom(Register); } @@ -14341,7 +17407,7 @@ namespace MxGateway.Contracts.Proto { break; } case 170: { - global::MxGateway.Contracts.Proto.AddItemReply subBuilder = new global::MxGateway.Contracts.Proto.AddItemReply(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.AddItemReply subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.AddItemReply(); if (payloadCase_ == PayloadOneofCase.AddItem) { subBuilder.MergeFrom(AddItem); } @@ -14350,7 +17416,7 @@ namespace MxGateway.Contracts.Proto { break; } case 178: { - global::MxGateway.Contracts.Proto.AddItem2Reply subBuilder = new global::MxGateway.Contracts.Proto.AddItem2Reply(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.AddItem2Reply subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.AddItem2Reply(); if (payloadCase_ == PayloadOneofCase.AddItem2) { subBuilder.MergeFrom(AddItem2); } @@ -14359,7 +17425,7 @@ namespace MxGateway.Contracts.Proto { break; } case 186: { - global::MxGateway.Contracts.Proto.AddBufferedItemReply subBuilder = new global::MxGateway.Contracts.Proto.AddBufferedItemReply(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.AddBufferedItemReply subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.AddBufferedItemReply(); if (payloadCase_ == PayloadOneofCase.AddBufferedItem) { subBuilder.MergeFrom(AddBufferedItem); } @@ -14368,7 +17434,7 @@ namespace MxGateway.Contracts.Proto { break; } case 194: { - global::MxGateway.Contracts.Proto.SuspendReply subBuilder = new global::MxGateway.Contracts.Proto.SuspendReply(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.SuspendReply subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.SuspendReply(); if (payloadCase_ == PayloadOneofCase.Suspend) { subBuilder.MergeFrom(Suspend); } @@ -14377,7 +17443,7 @@ namespace MxGateway.Contracts.Proto { break; } case 202: { - global::MxGateway.Contracts.Proto.ActivateReply subBuilder = new global::MxGateway.Contracts.Proto.ActivateReply(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.ActivateReply subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.ActivateReply(); if (payloadCase_ == PayloadOneofCase.Activate) { subBuilder.MergeFrom(Activate); } @@ -14386,7 +17452,7 @@ namespace MxGateway.Contracts.Proto { break; } case 210: { - global::MxGateway.Contracts.Proto.AuthenticateUserReply subBuilder = new global::MxGateway.Contracts.Proto.AuthenticateUserReply(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.AuthenticateUserReply subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.AuthenticateUserReply(); if (payloadCase_ == PayloadOneofCase.AuthenticateUser) { subBuilder.MergeFrom(AuthenticateUser); } @@ -14395,7 +17461,7 @@ namespace MxGateway.Contracts.Proto { break; } case 218: { - global::MxGateway.Contracts.Proto.ArchestrAUserToIdReply subBuilder = new global::MxGateway.Contracts.Proto.ArchestrAUserToIdReply(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.ArchestrAUserToIdReply subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.ArchestrAUserToIdReply(); if (payloadCase_ == PayloadOneofCase.ArchestraUserToId) { subBuilder.MergeFrom(ArchestraUserToId); } @@ -14404,7 +17470,7 @@ namespace MxGateway.Contracts.Proto { break; } case 226: { - global::MxGateway.Contracts.Proto.BulkSubscribeReply subBuilder = new global::MxGateway.Contracts.Proto.BulkSubscribeReply(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkSubscribeReply subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkSubscribeReply(); if (payloadCase_ == PayloadOneofCase.AddItemBulk) { subBuilder.MergeFrom(AddItemBulk); } @@ -14413,7 +17479,7 @@ namespace MxGateway.Contracts.Proto { break; } case 234: { - global::MxGateway.Contracts.Proto.BulkSubscribeReply subBuilder = new global::MxGateway.Contracts.Proto.BulkSubscribeReply(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkSubscribeReply subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkSubscribeReply(); if (payloadCase_ == PayloadOneofCase.AdviseItemBulk) { subBuilder.MergeFrom(AdviseItemBulk); } @@ -14422,7 +17488,7 @@ namespace MxGateway.Contracts.Proto { break; } case 242: { - global::MxGateway.Contracts.Proto.BulkSubscribeReply subBuilder = new global::MxGateway.Contracts.Proto.BulkSubscribeReply(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkSubscribeReply subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkSubscribeReply(); if (payloadCase_ == PayloadOneofCase.RemoveItemBulk) { subBuilder.MergeFrom(RemoveItemBulk); } @@ -14431,7 +17497,7 @@ namespace MxGateway.Contracts.Proto { break; } case 250: { - global::MxGateway.Contracts.Proto.BulkSubscribeReply subBuilder = new global::MxGateway.Contracts.Proto.BulkSubscribeReply(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkSubscribeReply subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkSubscribeReply(); if (payloadCase_ == PayloadOneofCase.UnAdviseItemBulk) { subBuilder.MergeFrom(UnAdviseItemBulk); } @@ -14440,7 +17506,7 @@ namespace MxGateway.Contracts.Proto { break; } case 258: { - global::MxGateway.Contracts.Proto.BulkSubscribeReply subBuilder = new global::MxGateway.Contracts.Proto.BulkSubscribeReply(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkSubscribeReply subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkSubscribeReply(); if (payloadCase_ == PayloadOneofCase.SubscribeBulk) { subBuilder.MergeFrom(SubscribeBulk); } @@ -14449,7 +17515,7 @@ namespace MxGateway.Contracts.Proto { break; } case 266: { - global::MxGateway.Contracts.Proto.BulkSubscribeReply subBuilder = new global::MxGateway.Contracts.Proto.BulkSubscribeReply(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkSubscribeReply subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkSubscribeReply(); if (payloadCase_ == PayloadOneofCase.UnsubscribeBulk) { subBuilder.MergeFrom(UnsubscribeBulk); } @@ -14458,7 +17524,7 @@ namespace MxGateway.Contracts.Proto { break; } case 274: { - global::MxGateway.Contracts.Proto.AcknowledgeAlarmReplyPayload subBuilder = new global::MxGateway.Contracts.Proto.AcknowledgeAlarmReplyPayload(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmReplyPayload subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmReplyPayload(); if (payloadCase_ == PayloadOneofCase.AcknowledgeAlarm) { subBuilder.MergeFrom(AcknowledgeAlarm); } @@ -14467,7 +17533,7 @@ namespace MxGateway.Contracts.Proto { break; } case 282: { - global::MxGateway.Contracts.Proto.QueryActiveAlarmsReplyPayload subBuilder = new global::MxGateway.Contracts.Proto.QueryActiveAlarmsReplyPayload(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.QueryActiveAlarmsReplyPayload subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.QueryActiveAlarmsReplyPayload(); if (payloadCase_ == PayloadOneofCase.QueryActiveAlarms) { subBuilder.MergeFrom(QueryActiveAlarms); } @@ -14475,8 +17541,53 @@ namespace MxGateway.Contracts.Proto { QueryActiveAlarms = subBuilder; break; } + case 290: { + global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkWriteReply subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkWriteReply(); + if (payloadCase_ == PayloadOneofCase.WriteBulk) { + subBuilder.MergeFrom(WriteBulk); + } + input.ReadMessage(subBuilder); + WriteBulk = subBuilder; + break; + } + case 298: { + global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkWriteReply subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkWriteReply(); + if (payloadCase_ == PayloadOneofCase.Write2Bulk) { + subBuilder.MergeFrom(Write2Bulk); + } + input.ReadMessage(subBuilder); + Write2Bulk = subBuilder; + break; + } + case 306: { + global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkWriteReply subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkWriteReply(); + if (payloadCase_ == PayloadOneofCase.WriteSecuredBulk) { + subBuilder.MergeFrom(WriteSecuredBulk); + } + input.ReadMessage(subBuilder); + WriteSecuredBulk = subBuilder; + break; + } + case 314: { + global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkWriteReply subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkWriteReply(); + if (payloadCase_ == PayloadOneofCase.WriteSecured2Bulk) { + subBuilder.MergeFrom(WriteSecured2Bulk); + } + input.ReadMessage(subBuilder); + WriteSecured2Bulk = subBuilder; + break; + } + case 322: { + global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkReadReply subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkReadReply(); + if (payloadCase_ == PayloadOneofCase.ReadBulk) { + subBuilder.MergeFrom(ReadBulk); + } + input.ReadMessage(subBuilder); + ReadBulk = subBuilder; + break; + } case 802: { - global::MxGateway.Contracts.Proto.SessionStateReply subBuilder = new global::MxGateway.Contracts.Proto.SessionStateReply(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.SessionStateReply subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.SessionStateReply(); if (payloadCase_ == PayloadOneofCase.SessionState) { subBuilder.MergeFrom(SessionState); } @@ -14485,7 +17596,7 @@ namespace MxGateway.Contracts.Proto { break; } case 810: { - global::MxGateway.Contracts.Proto.WorkerInfoReply subBuilder = new global::MxGateway.Contracts.Proto.WorkerInfoReply(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerInfoReply subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerInfoReply(); if (payloadCase_ == PayloadOneofCase.WorkerInfo) { subBuilder.MergeFrom(WorkerInfo); } @@ -14494,7 +17605,7 @@ namespace MxGateway.Contracts.Proto { break; } case 818: { - global::MxGateway.Contracts.Proto.DrainEventsReply subBuilder = new global::MxGateway.Contracts.Proto.DrainEventsReply(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.DrainEventsReply subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.DrainEventsReply(); if (payloadCase_ == PayloadOneofCase.DrainEvents) { subBuilder.MergeFrom(DrainEvents); } @@ -14524,7 +17635,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[42]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[51]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -14722,7 +17833,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[43]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[52]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -14920,7 +18031,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[44]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[53]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -15118,7 +18229,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[45]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[54]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -15316,7 +18427,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[46]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[55]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -15348,10 +18459,10 @@ namespace MxGateway.Contracts.Proto { /// Field number for the "status" field. public const int StatusFieldNumber = 1; - private global::MxGateway.Contracts.Proto.MxStatusProxy status_; + private global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusProxy status_; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.MxStatusProxy Status { + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusProxy Status { get { return status_; } set { status_ = value; @@ -15445,7 +18556,7 @@ namespace MxGateway.Contracts.Proto { } if (other.status_ != null) { if (status_ == null) { - Status = new global::MxGateway.Contracts.Proto.MxStatusProxy(); + Status = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusProxy(); } Status.MergeFrom(other.Status); } @@ -15470,7 +18581,7 @@ namespace MxGateway.Contracts.Proto { break; case 10: { if (status_ == null) { - Status = new global::MxGateway.Contracts.Proto.MxStatusProxy(); + Status = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusProxy(); } input.ReadMessage(Status); break; @@ -15496,7 +18607,7 @@ namespace MxGateway.Contracts.Proto { break; case 10: { if (status_ == null) { - Status = new global::MxGateway.Contracts.Proto.MxStatusProxy(); + Status = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusProxy(); } input.ReadMessage(Status); break; @@ -15523,7 +18634,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[47]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[56]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -15555,10 +18666,10 @@ namespace MxGateway.Contracts.Proto { /// Field number for the "status" field. public const int StatusFieldNumber = 1; - private global::MxGateway.Contracts.Proto.MxStatusProxy status_; + private global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusProxy status_; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.MxStatusProxy Status { + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusProxy Status { get { return status_; } set { status_ = value; @@ -15652,7 +18763,7 @@ namespace MxGateway.Contracts.Proto { } if (other.status_ != null) { if (status_ == null) { - Status = new global::MxGateway.Contracts.Proto.MxStatusProxy(); + Status = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusProxy(); } Status.MergeFrom(other.Status); } @@ -15677,7 +18788,7 @@ namespace MxGateway.Contracts.Proto { break; case 10: { if (status_ == null) { - Status = new global::MxGateway.Contracts.Proto.MxStatusProxy(); + Status = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusProxy(); } input.ReadMessage(Status); break; @@ -15703,7 +18814,7 @@ namespace MxGateway.Contracts.Proto { break; case 10: { if (status_ == null) { - Status = new global::MxGateway.Contracts.Proto.MxStatusProxy(); + Status = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusProxy(); } input.ReadMessage(Status); break; @@ -15730,7 +18841,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[48]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[57]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -15928,7 +19039,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[49]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[58]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -16126,7 +19237,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[50]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[59]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -16472,7 +19583,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[51]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[60]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -16504,12 +19615,12 @@ namespace MxGateway.Contracts.Proto { /// Field number for the "results" field. public const int ResultsFieldNumber = 1; - private static readonly pb::FieldCodec _repeated_results_codec - = pb::FieldCodec.ForMessage(10, global::MxGateway.Contracts.Proto.SubscribeResult.Parser); - private readonly pbc::RepeatedField results_ = new pbc::RepeatedField(); + private static readonly pb::FieldCodec _repeated_results_codec + = pb::FieldCodec.ForMessage(10, global::ZB.MOM.WW.MxGateway.Contracts.Proto.SubscribeResult.Parser); + private readonly pbc::RepeatedField results_ = new pbc::RepeatedField(); [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public pbc::RepeatedField Results { + public pbc::RepeatedField Results { get { return results_; } } @@ -16644,6 +19755,1336 @@ namespace MxGateway.Contracts.Proto { } + /// + /// Per-item result for the four bulk write families. `item_handle` mirrors the + /// request entry's item_handle so callers can correlate inputs to outputs even + /// when the gateway's per-entry `IConstraintEnforcer.CheckWriteHandleAsync` + /// filter (see `MxAccessGatewayService.ReplaceWriteBulkEntries` and + /// `docs/Authorization.md`) dropped some entries before reaching the worker. + /// Per-item failures populate `error_message` + `hresult` and never raise — + /// callers iterate and inspect each entry. + /// + [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] + public sealed partial class BulkWriteResult : pb::IMessage + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + , pb::IBufferMessage + #endif + { + private static readonly pb::MessageParser _parser = new pb::MessageParser(() => new BulkWriteResult()); + private pb::UnknownFieldSet _unknownFields; + private int _hasBits0; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pb::MessageParser Parser { get { return _parser; } } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pbr::MessageDescriptor Descriptor { + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[61]; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + pbr::MessageDescriptor pb::IMessage.Descriptor { + get { return Descriptor; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public BulkWriteResult() { + OnConstruction(); + } + + partial void OnConstruction(); + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public BulkWriteResult(BulkWriteResult other) : this() { + _hasBits0 = other._hasBits0; + serverHandle_ = other.serverHandle_; + itemHandle_ = other.itemHandle_; + wasSuccessful_ = other.wasSuccessful_; + hresult_ = other.hresult_; + statuses_ = other.statuses_.Clone(); + errorMessage_ = other.errorMessage_; + _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public BulkWriteResult Clone() { + return new BulkWriteResult(this); + } + + /// Field number for the "server_handle" field. + public const int ServerHandleFieldNumber = 1; + private int serverHandle_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int ServerHandle { + get { return serverHandle_; } + set { + serverHandle_ = value; + } + } + + /// Field number for the "item_handle" field. + public const int ItemHandleFieldNumber = 2; + private int itemHandle_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int ItemHandle { + get { return itemHandle_; } + set { + itemHandle_ = value; + } + } + + /// Field number for the "was_successful" field. + public const int WasSuccessfulFieldNumber = 3; + private bool wasSuccessful_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool WasSuccessful { + get { return wasSuccessful_; } + set { + wasSuccessful_ = value; + } + } + + /// Field number for the "hresult" field. + public const int HresultFieldNumber = 4; + private readonly static int HresultDefaultValue = 0; + + private int hresult_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int Hresult { + get { if ((_hasBits0 & 1) != 0) { return hresult_; } else { return HresultDefaultValue; } } + set { + _hasBits0 |= 1; + hresult_ = value; + } + } + /// Gets whether the "hresult" field is set + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool HasHresult { + get { return (_hasBits0 & 1) != 0; } + } + /// Clears the value of the "hresult" field + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void ClearHresult() { + _hasBits0 &= ~1; + } + + /// Field number for the "statuses" field. + public const int StatusesFieldNumber = 5; + private static readonly pb::FieldCodec _repeated_statuses_codec + = pb::FieldCodec.ForMessage(42, global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusProxy.Parser); + private readonly pbc::RepeatedField statuses_ = new pbc::RepeatedField(); + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public pbc::RepeatedField Statuses { + get { return statuses_; } + } + + /// Field number for the "error_message" field. + public const int ErrorMessageFieldNumber = 6; + private string errorMessage_ = ""; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string ErrorMessage { + get { return errorMessage_; } + set { + errorMessage_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override bool Equals(object other) { + return Equals(other as BulkWriteResult); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool Equals(BulkWriteResult other) { + if (ReferenceEquals(other, null)) { + return false; + } + if (ReferenceEquals(other, this)) { + return true; + } + if (ServerHandle != other.ServerHandle) return false; + if (ItemHandle != other.ItemHandle) return false; + if (WasSuccessful != other.WasSuccessful) return false; + if (Hresult != other.Hresult) return false; + if(!statuses_.Equals(other.statuses_)) return false; + if (ErrorMessage != other.ErrorMessage) return false; + return Equals(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override int GetHashCode() { + int hash = 1; + if (ServerHandle != 0) hash ^= ServerHandle.GetHashCode(); + if (ItemHandle != 0) hash ^= ItemHandle.GetHashCode(); + if (WasSuccessful != false) hash ^= WasSuccessful.GetHashCode(); + if (HasHresult) hash ^= Hresult.GetHashCode(); + hash ^= statuses_.GetHashCode(); + if (ErrorMessage.Length != 0) hash ^= ErrorMessage.GetHashCode(); + if (_unknownFields != null) { + hash ^= _unknownFields.GetHashCode(); + } + return hash; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override string ToString() { + return pb::JsonFormatter.ToDiagnosticString(this); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void WriteTo(pb::CodedOutputStream output) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + output.WriteRawMessage(this); + #else + if (ServerHandle != 0) { + output.WriteRawTag(8); + output.WriteInt32(ServerHandle); + } + if (ItemHandle != 0) { + output.WriteRawTag(16); + output.WriteInt32(ItemHandle); + } + if (WasSuccessful != false) { + output.WriteRawTag(24); + output.WriteBool(WasSuccessful); + } + if (HasHresult) { + output.WriteRawTag(32); + output.WriteInt32(Hresult); + } + statuses_.WriteTo(output, _repeated_statuses_codec); + if (ErrorMessage.Length != 0) { + output.WriteRawTag(50); + output.WriteString(ErrorMessage); + } + if (_unknownFields != null) { + _unknownFields.WriteTo(output); + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { + if (ServerHandle != 0) { + output.WriteRawTag(8); + output.WriteInt32(ServerHandle); + } + if (ItemHandle != 0) { + output.WriteRawTag(16); + output.WriteInt32(ItemHandle); + } + if (WasSuccessful != false) { + output.WriteRawTag(24); + output.WriteBool(WasSuccessful); + } + if (HasHresult) { + output.WriteRawTag(32); + output.WriteInt32(Hresult); + } + statuses_.WriteTo(ref output, _repeated_statuses_codec); + if (ErrorMessage.Length != 0) { + output.WriteRawTag(50); + output.WriteString(ErrorMessage); + } + if (_unknownFields != null) { + _unknownFields.WriteTo(ref output); + } + } + #endif + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int CalculateSize() { + int size = 0; + if (ServerHandle != 0) { + size += 1 + pb::CodedOutputStream.ComputeInt32Size(ServerHandle); + } + if (ItemHandle != 0) { + size += 1 + pb::CodedOutputStream.ComputeInt32Size(ItemHandle); + } + if (WasSuccessful != false) { + size += 1 + 1; + } + if (HasHresult) { + size += 1 + pb::CodedOutputStream.ComputeInt32Size(Hresult); + } + size += statuses_.CalculateSize(_repeated_statuses_codec); + if (ErrorMessage.Length != 0) { + size += 1 + pb::CodedOutputStream.ComputeStringSize(ErrorMessage); + } + if (_unknownFields != null) { + size += _unknownFields.CalculateSize(); + } + return size; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(BulkWriteResult other) { + if (other == null) { + return; + } + if (other.ServerHandle != 0) { + ServerHandle = other.ServerHandle; + } + if (other.ItemHandle != 0) { + ItemHandle = other.ItemHandle; + } + if (other.WasSuccessful != false) { + WasSuccessful = other.WasSuccessful; + } + if (other.HasHresult) { + Hresult = other.Hresult; + } + statuses_.Add(other.statuses_); + if (other.ErrorMessage.Length != 0) { + ErrorMessage = other.ErrorMessage; + } + _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(pb::CodedInputStream input) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + input.ReadRawMessage(this); + #else + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); + break; + case 8: { + ServerHandle = input.ReadInt32(); + break; + } + case 16: { + ItemHandle = input.ReadInt32(); + break; + } + case 24: { + WasSuccessful = input.ReadBool(); + break; + } + case 32: { + Hresult = input.ReadInt32(); + break; + } + case 42: { + statuses_.AddEntriesFrom(input, _repeated_statuses_codec); + break; + } + case 50: { + ErrorMessage = input.ReadString(); + break; + } + } + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalMergeFrom(ref pb::ParseContext input) { + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); + break; + case 8: { + ServerHandle = input.ReadInt32(); + break; + } + case 16: { + ItemHandle = input.ReadInt32(); + break; + } + case 24: { + WasSuccessful = input.ReadBool(); + break; + } + case 32: { + Hresult = input.ReadInt32(); + break; + } + case 42: { + statuses_.AddEntriesFrom(ref input, _repeated_statuses_codec); + break; + } + case 50: { + ErrorMessage = input.ReadString(); + break; + } + } + } + } + #endif + + } + + [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] + public sealed partial class BulkWriteReply : pb::IMessage + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + , pb::IBufferMessage + #endif + { + private static readonly pb::MessageParser _parser = new pb::MessageParser(() => new BulkWriteReply()); + private pb::UnknownFieldSet _unknownFields; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pb::MessageParser Parser { get { return _parser; } } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pbr::MessageDescriptor Descriptor { + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[62]; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + pbr::MessageDescriptor pb::IMessage.Descriptor { + get { return Descriptor; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public BulkWriteReply() { + OnConstruction(); + } + + partial void OnConstruction(); + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public BulkWriteReply(BulkWriteReply other) : this() { + results_ = other.results_.Clone(); + _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public BulkWriteReply Clone() { + return new BulkWriteReply(this); + } + + /// Field number for the "results" field. + public const int ResultsFieldNumber = 1; + private static readonly pb::FieldCodec _repeated_results_codec + = pb::FieldCodec.ForMessage(10, global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkWriteResult.Parser); + private readonly pbc::RepeatedField results_ = new pbc::RepeatedField(); + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public pbc::RepeatedField Results { + get { return results_; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override bool Equals(object other) { + return Equals(other as BulkWriteReply); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool Equals(BulkWriteReply other) { + if (ReferenceEquals(other, null)) { + return false; + } + if (ReferenceEquals(other, this)) { + return true; + } + if(!results_.Equals(other.results_)) return false; + return Equals(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override int GetHashCode() { + int hash = 1; + hash ^= results_.GetHashCode(); + if (_unknownFields != null) { + hash ^= _unknownFields.GetHashCode(); + } + return hash; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override string ToString() { + return pb::JsonFormatter.ToDiagnosticString(this); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void WriteTo(pb::CodedOutputStream output) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + output.WriteRawMessage(this); + #else + results_.WriteTo(output, _repeated_results_codec); + if (_unknownFields != null) { + _unknownFields.WriteTo(output); + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { + results_.WriteTo(ref output, _repeated_results_codec); + if (_unknownFields != null) { + _unknownFields.WriteTo(ref output); + } + } + #endif + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int CalculateSize() { + int size = 0; + size += results_.CalculateSize(_repeated_results_codec); + if (_unknownFields != null) { + size += _unknownFields.CalculateSize(); + } + return size; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(BulkWriteReply other) { + if (other == null) { + return; + } + results_.Add(other.results_); + _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(pb::CodedInputStream input) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + input.ReadRawMessage(this); + #else + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); + break; + case 10: { + results_.AddEntriesFrom(input, _repeated_results_codec); + break; + } + } + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalMergeFrom(ref pb::ParseContext input) { + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); + break; + case 10: { + results_.AddEntriesFrom(ref input, _repeated_results_codec); + break; + } + } + } + } + #endif + + } + + /// + /// Per-tag result for ReadBulk. `was_cached` is true when the value came from + /// an existing live subscription's last OnDataChange (the worker did not touch + /// the subscription); false when the worker took the AddItem + Advise + wait + + /// UnAdvise + RemoveItem snapshot lifecycle itself. + /// + /// On `was_successful = true`, `value`, `quality`, `source_timestamp`, and + /// `statuses` carry the read data (from the cached subscription or the snapshot + /// lifecycle, depending on `was_cached`) and `error_message` is empty. On + /// `was_successful = false`, only `server_handle`, `tag_address`, `item_handle` + /// (when allocated), `was_cached`, and `error_message` are populated; `value`, + /// `quality`, `source_timestamp`, and `statuses` are left at their proto3 + /// defaults (null / 0 / null / empty) and must not be read as data — they are + /// wire-indistinguishable from "value is null with quality bad" data and serve + /// only as absent markers. ReadBulk has no `hresult` field by design (its + /// outcomes are timeout / cache / lifecycle states, not MXAccess COM return + /// codes — see `docs/DesignDecisions.md` "Bulk Command Family"). Per-tag + /// failures populate `error_message` and never raise — callers iterate and + /// inspect each entry. + /// + [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] + public sealed partial class BulkReadResult : pb::IMessage + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + , pb::IBufferMessage + #endif + { + private static readonly pb::MessageParser _parser = new pb::MessageParser(() => new BulkReadResult()); + private pb::UnknownFieldSet _unknownFields; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pb::MessageParser Parser { get { return _parser; } } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pbr::MessageDescriptor Descriptor { + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[63]; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + pbr::MessageDescriptor pb::IMessage.Descriptor { + get { return Descriptor; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public BulkReadResult() { + OnConstruction(); + } + + partial void OnConstruction(); + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public BulkReadResult(BulkReadResult other) : this() { + serverHandle_ = other.serverHandle_; + tagAddress_ = other.tagAddress_; + itemHandle_ = other.itemHandle_; + wasSuccessful_ = other.wasSuccessful_; + wasCached_ = other.wasCached_; + value_ = other.value_ != null ? other.value_.Clone() : null; + quality_ = other.quality_; + sourceTimestamp_ = other.sourceTimestamp_ != null ? other.sourceTimestamp_.Clone() : null; + statuses_ = other.statuses_.Clone(); + errorMessage_ = other.errorMessage_; + _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public BulkReadResult Clone() { + return new BulkReadResult(this); + } + + /// Field number for the "server_handle" field. + public const int ServerHandleFieldNumber = 1; + private int serverHandle_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int ServerHandle { + get { return serverHandle_; } + set { + serverHandle_ = value; + } + } + + /// Field number for the "tag_address" field. + public const int TagAddressFieldNumber = 2; + private string tagAddress_ = ""; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string TagAddress { + get { return tagAddress_; } + set { + tagAddress_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + + /// Field number for the "item_handle" field. + public const int ItemHandleFieldNumber = 3; + private int itemHandle_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int ItemHandle { + get { return itemHandle_; } + set { + itemHandle_ = value; + } + } + + /// Field number for the "was_successful" field. + public const int WasSuccessfulFieldNumber = 4; + private bool wasSuccessful_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool WasSuccessful { + get { return wasSuccessful_; } + set { + wasSuccessful_ = value; + } + } + + /// Field number for the "was_cached" field. + public const int WasCachedFieldNumber = 5; + private bool wasCached_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool WasCached { + get { return wasCached_; } + set { + wasCached_ = value; + } + } + + /// Field number for the "value" field. + public const int ValueFieldNumber = 6; + private global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue value_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue Value { + get { return value_; } + set { + value_ = value; + } + } + + /// Field number for the "quality" field. + public const int QualityFieldNumber = 7; + private int quality_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int Quality { + get { return quality_; } + set { + quality_ = value; + } + } + + /// Field number for the "source_timestamp" field. + public const int SourceTimestampFieldNumber = 8; + private global::Google.Protobuf.WellKnownTypes.Timestamp sourceTimestamp_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public global::Google.Protobuf.WellKnownTypes.Timestamp SourceTimestamp { + get { return sourceTimestamp_; } + set { + sourceTimestamp_ = value; + } + } + + /// Field number for the "statuses" field. + public const int StatusesFieldNumber = 9; + private static readonly pb::FieldCodec _repeated_statuses_codec + = pb::FieldCodec.ForMessage(74, global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusProxy.Parser); + private readonly pbc::RepeatedField statuses_ = new pbc::RepeatedField(); + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public pbc::RepeatedField Statuses { + get { return statuses_; } + } + + /// Field number for the "error_message" field. + public const int ErrorMessageFieldNumber = 10; + private string errorMessage_ = ""; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string ErrorMessage { + get { return errorMessage_; } + set { + errorMessage_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override bool Equals(object other) { + return Equals(other as BulkReadResult); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool Equals(BulkReadResult other) { + if (ReferenceEquals(other, null)) { + return false; + } + if (ReferenceEquals(other, this)) { + return true; + } + if (ServerHandle != other.ServerHandle) return false; + if (TagAddress != other.TagAddress) return false; + if (ItemHandle != other.ItemHandle) return false; + if (WasSuccessful != other.WasSuccessful) return false; + if (WasCached != other.WasCached) return false; + if (!object.Equals(Value, other.Value)) return false; + if (Quality != other.Quality) return false; + if (!object.Equals(SourceTimestamp, other.SourceTimestamp)) return false; + if(!statuses_.Equals(other.statuses_)) return false; + if (ErrorMessage != other.ErrorMessage) return false; + return Equals(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override int GetHashCode() { + int hash = 1; + if (ServerHandle != 0) hash ^= ServerHandle.GetHashCode(); + if (TagAddress.Length != 0) hash ^= TagAddress.GetHashCode(); + if (ItemHandle != 0) hash ^= ItemHandle.GetHashCode(); + if (WasSuccessful != false) hash ^= WasSuccessful.GetHashCode(); + if (WasCached != false) hash ^= WasCached.GetHashCode(); + if (value_ != null) hash ^= Value.GetHashCode(); + if (Quality != 0) hash ^= Quality.GetHashCode(); + if (sourceTimestamp_ != null) hash ^= SourceTimestamp.GetHashCode(); + hash ^= statuses_.GetHashCode(); + if (ErrorMessage.Length != 0) hash ^= ErrorMessage.GetHashCode(); + if (_unknownFields != null) { + hash ^= _unknownFields.GetHashCode(); + } + return hash; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override string ToString() { + return pb::JsonFormatter.ToDiagnosticString(this); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void WriteTo(pb::CodedOutputStream output) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + output.WriteRawMessage(this); + #else + if (ServerHandle != 0) { + output.WriteRawTag(8); + output.WriteInt32(ServerHandle); + } + if (TagAddress.Length != 0) { + output.WriteRawTag(18); + output.WriteString(TagAddress); + } + if (ItemHandle != 0) { + output.WriteRawTag(24); + output.WriteInt32(ItemHandle); + } + if (WasSuccessful != false) { + output.WriteRawTag(32); + output.WriteBool(WasSuccessful); + } + if (WasCached != false) { + output.WriteRawTag(40); + output.WriteBool(WasCached); + } + if (value_ != null) { + output.WriteRawTag(50); + output.WriteMessage(Value); + } + if (Quality != 0) { + output.WriteRawTag(56); + output.WriteInt32(Quality); + } + if (sourceTimestamp_ != null) { + output.WriteRawTag(66); + output.WriteMessage(SourceTimestamp); + } + statuses_.WriteTo(output, _repeated_statuses_codec); + if (ErrorMessage.Length != 0) { + output.WriteRawTag(82); + output.WriteString(ErrorMessage); + } + if (_unknownFields != null) { + _unknownFields.WriteTo(output); + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { + if (ServerHandle != 0) { + output.WriteRawTag(8); + output.WriteInt32(ServerHandle); + } + if (TagAddress.Length != 0) { + output.WriteRawTag(18); + output.WriteString(TagAddress); + } + if (ItemHandle != 0) { + output.WriteRawTag(24); + output.WriteInt32(ItemHandle); + } + if (WasSuccessful != false) { + output.WriteRawTag(32); + output.WriteBool(WasSuccessful); + } + if (WasCached != false) { + output.WriteRawTag(40); + output.WriteBool(WasCached); + } + if (value_ != null) { + output.WriteRawTag(50); + output.WriteMessage(Value); + } + if (Quality != 0) { + output.WriteRawTag(56); + output.WriteInt32(Quality); + } + if (sourceTimestamp_ != null) { + output.WriteRawTag(66); + output.WriteMessage(SourceTimestamp); + } + statuses_.WriteTo(ref output, _repeated_statuses_codec); + if (ErrorMessage.Length != 0) { + output.WriteRawTag(82); + output.WriteString(ErrorMessage); + } + if (_unknownFields != null) { + _unknownFields.WriteTo(ref output); + } + } + #endif + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int CalculateSize() { + int size = 0; + if (ServerHandle != 0) { + size += 1 + pb::CodedOutputStream.ComputeInt32Size(ServerHandle); + } + if (TagAddress.Length != 0) { + size += 1 + pb::CodedOutputStream.ComputeStringSize(TagAddress); + } + if (ItemHandle != 0) { + size += 1 + pb::CodedOutputStream.ComputeInt32Size(ItemHandle); + } + if (WasSuccessful != false) { + size += 1 + 1; + } + if (WasCached != false) { + size += 1 + 1; + } + if (value_ != null) { + size += 1 + pb::CodedOutputStream.ComputeMessageSize(Value); + } + if (Quality != 0) { + size += 1 + pb::CodedOutputStream.ComputeInt32Size(Quality); + } + if (sourceTimestamp_ != null) { + size += 1 + pb::CodedOutputStream.ComputeMessageSize(SourceTimestamp); + } + size += statuses_.CalculateSize(_repeated_statuses_codec); + if (ErrorMessage.Length != 0) { + size += 1 + pb::CodedOutputStream.ComputeStringSize(ErrorMessage); + } + if (_unknownFields != null) { + size += _unknownFields.CalculateSize(); + } + return size; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(BulkReadResult other) { + if (other == null) { + return; + } + if (other.ServerHandle != 0) { + ServerHandle = other.ServerHandle; + } + if (other.TagAddress.Length != 0) { + TagAddress = other.TagAddress; + } + if (other.ItemHandle != 0) { + ItemHandle = other.ItemHandle; + } + if (other.WasSuccessful != false) { + WasSuccessful = other.WasSuccessful; + } + if (other.WasCached != false) { + WasCached = other.WasCached; + } + if (other.value_ != null) { + if (value_ == null) { + Value = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue(); + } + Value.MergeFrom(other.Value); + } + if (other.Quality != 0) { + Quality = other.Quality; + } + if (other.sourceTimestamp_ != null) { + if (sourceTimestamp_ == null) { + SourceTimestamp = new global::Google.Protobuf.WellKnownTypes.Timestamp(); + } + SourceTimestamp.MergeFrom(other.SourceTimestamp); + } + statuses_.Add(other.statuses_); + if (other.ErrorMessage.Length != 0) { + ErrorMessage = other.ErrorMessage; + } + _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(pb::CodedInputStream input) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + input.ReadRawMessage(this); + #else + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); + break; + case 8: { + ServerHandle = input.ReadInt32(); + break; + } + case 18: { + TagAddress = input.ReadString(); + break; + } + case 24: { + ItemHandle = input.ReadInt32(); + break; + } + case 32: { + WasSuccessful = input.ReadBool(); + break; + } + case 40: { + WasCached = input.ReadBool(); + break; + } + case 50: { + if (value_ == null) { + Value = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue(); + } + input.ReadMessage(Value); + break; + } + case 56: { + Quality = input.ReadInt32(); + break; + } + case 66: { + if (sourceTimestamp_ == null) { + SourceTimestamp = new global::Google.Protobuf.WellKnownTypes.Timestamp(); + } + input.ReadMessage(SourceTimestamp); + break; + } + case 74: { + statuses_.AddEntriesFrom(input, _repeated_statuses_codec); + break; + } + case 82: { + ErrorMessage = input.ReadString(); + break; + } + } + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalMergeFrom(ref pb::ParseContext input) { + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); + break; + case 8: { + ServerHandle = input.ReadInt32(); + break; + } + case 18: { + TagAddress = input.ReadString(); + break; + } + case 24: { + ItemHandle = input.ReadInt32(); + break; + } + case 32: { + WasSuccessful = input.ReadBool(); + break; + } + case 40: { + WasCached = input.ReadBool(); + break; + } + case 50: { + if (value_ == null) { + Value = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue(); + } + input.ReadMessage(Value); + break; + } + case 56: { + Quality = input.ReadInt32(); + break; + } + case 66: { + if (sourceTimestamp_ == null) { + SourceTimestamp = new global::Google.Protobuf.WellKnownTypes.Timestamp(); + } + input.ReadMessage(SourceTimestamp); + break; + } + case 74: { + statuses_.AddEntriesFrom(ref input, _repeated_statuses_codec); + break; + } + case 82: { + ErrorMessage = input.ReadString(); + break; + } + } + } + } + #endif + + } + + [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] + public sealed partial class BulkReadReply : pb::IMessage + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + , pb::IBufferMessage + #endif + { + private static readonly pb::MessageParser _parser = new pb::MessageParser(() => new BulkReadReply()); + private pb::UnknownFieldSet _unknownFields; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pb::MessageParser Parser { get { return _parser; } } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pbr::MessageDescriptor Descriptor { + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[64]; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + pbr::MessageDescriptor pb::IMessage.Descriptor { + get { return Descriptor; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public BulkReadReply() { + OnConstruction(); + } + + partial void OnConstruction(); + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public BulkReadReply(BulkReadReply other) : this() { + results_ = other.results_.Clone(); + _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public BulkReadReply Clone() { + return new BulkReadReply(this); + } + + /// Field number for the "results" field. + public const int ResultsFieldNumber = 1; + private static readonly pb::FieldCodec _repeated_results_codec + = pb::FieldCodec.ForMessage(10, global::ZB.MOM.WW.MxGateway.Contracts.Proto.BulkReadResult.Parser); + private readonly pbc::RepeatedField results_ = new pbc::RepeatedField(); + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public pbc::RepeatedField Results { + get { return results_; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override bool Equals(object other) { + return Equals(other as BulkReadReply); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool Equals(BulkReadReply other) { + if (ReferenceEquals(other, null)) { + return false; + } + if (ReferenceEquals(other, this)) { + return true; + } + if(!results_.Equals(other.results_)) return false; + return Equals(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override int GetHashCode() { + int hash = 1; + hash ^= results_.GetHashCode(); + if (_unknownFields != null) { + hash ^= _unknownFields.GetHashCode(); + } + return hash; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override string ToString() { + return pb::JsonFormatter.ToDiagnosticString(this); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void WriteTo(pb::CodedOutputStream output) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + output.WriteRawMessage(this); + #else + results_.WriteTo(output, _repeated_results_codec); + if (_unknownFields != null) { + _unknownFields.WriteTo(output); + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { + results_.WriteTo(ref output, _repeated_results_codec); + if (_unknownFields != null) { + _unknownFields.WriteTo(ref output); + } + } + #endif + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int CalculateSize() { + int size = 0; + size += results_.CalculateSize(_repeated_results_codec); + if (_unknownFields != null) { + size += _unknownFields.CalculateSize(); + } + return size; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(BulkReadReply other) { + if (other == null) { + return; + } + results_.Add(other.results_); + _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(pb::CodedInputStream input) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + input.ReadRawMessage(this); + #else + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); + break; + case 10: { + results_.AddEntriesFrom(input, _repeated_results_codec); + break; + } + } + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalMergeFrom(ref pb::ParseContext input) { + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); + break; + case 10: { + results_.AddEntriesFrom(ref input, _repeated_results_codec); + break; + } + } + } + } + #endif + + } + [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] public sealed partial class SessionStateReply : pb::IMessage #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE @@ -16659,7 +21100,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[52]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[65]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -16691,10 +21132,10 @@ namespace MxGateway.Contracts.Proto { /// Field number for the "state" field. public const int StateFieldNumber = 1; - private global::MxGateway.Contracts.Proto.SessionState state_ = global::MxGateway.Contracts.Proto.SessionState.Unspecified; + private global::ZB.MOM.WW.MxGateway.Contracts.Proto.SessionState state_ = global::ZB.MOM.WW.MxGateway.Contracts.Proto.SessionState.Unspecified; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.SessionState State { + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.SessionState State { get { return state_; } set { state_ = value; @@ -16724,7 +21165,7 @@ namespace MxGateway.Contracts.Proto { [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public override int GetHashCode() { int hash = 1; - if (State != global::MxGateway.Contracts.Proto.SessionState.Unspecified) hash ^= State.GetHashCode(); + if (State != global::ZB.MOM.WW.MxGateway.Contracts.Proto.SessionState.Unspecified) hash ^= State.GetHashCode(); if (_unknownFields != null) { hash ^= _unknownFields.GetHashCode(); } @@ -16743,7 +21184,7 @@ namespace MxGateway.Contracts.Proto { #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE output.WriteRawMessage(this); #else - if (State != global::MxGateway.Contracts.Proto.SessionState.Unspecified) { + if (State != global::ZB.MOM.WW.MxGateway.Contracts.Proto.SessionState.Unspecified) { output.WriteRawTag(8); output.WriteEnum((int) State); } @@ -16757,7 +21198,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { - if (State != global::MxGateway.Contracts.Proto.SessionState.Unspecified) { + if (State != global::ZB.MOM.WW.MxGateway.Contracts.Proto.SessionState.Unspecified) { output.WriteRawTag(8); output.WriteEnum((int) State); } @@ -16771,7 +21212,7 @@ namespace MxGateway.Contracts.Proto { [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public int CalculateSize() { int size = 0; - if (State != global::MxGateway.Contracts.Proto.SessionState.Unspecified) { + if (State != global::ZB.MOM.WW.MxGateway.Contracts.Proto.SessionState.Unspecified) { size += 1 + pb::CodedOutputStream.ComputeEnumSize((int) State); } if (_unknownFields != null) { @@ -16786,7 +21227,7 @@ namespace MxGateway.Contracts.Proto { if (other == null) { return; } - if (other.State != global::MxGateway.Contracts.Proto.SessionState.Unspecified) { + if (other.State != global::ZB.MOM.WW.MxGateway.Contracts.Proto.SessionState.Unspecified) { State = other.State; } _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); @@ -16809,7 +21250,7 @@ namespace MxGateway.Contracts.Proto { _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); break; case 8: { - State = (global::MxGateway.Contracts.Proto.SessionState) input.ReadEnum(); + State = (global::ZB.MOM.WW.MxGateway.Contracts.Proto.SessionState) input.ReadEnum(); break; } } @@ -16832,7 +21273,7 @@ namespace MxGateway.Contracts.Proto { _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); break; case 8: { - State = (global::MxGateway.Contracts.Proto.SessionState) input.ReadEnum(); + State = (global::ZB.MOM.WW.MxGateway.Contracts.Proto.SessionState) input.ReadEnum(); break; } } @@ -16857,7 +21298,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[53]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[66]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -17166,7 +21607,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[54]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[67]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -17198,12 +21639,12 @@ namespace MxGateway.Contracts.Proto { /// Field number for the "events" field. public const int EventsFieldNumber = 1; - private static readonly pb::FieldCodec _repeated_events_codec - = pb::FieldCodec.ForMessage(10, global::MxGateway.Contracts.Proto.MxEvent.Parser); - private readonly pbc::RepeatedField events_ = new pbc::RepeatedField(); + private static readonly pb::FieldCodec _repeated_events_codec + = pb::FieldCodec.ForMessage(10, global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxEvent.Parser); + private readonly pbc::RepeatedField events_ = new pbc::RepeatedField(); [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public pbc::RepeatedField Events { + public pbc::RepeatedField Events { get { return events_; } } @@ -17339,12 +21780,16 @@ namespace MxGateway.Contracts.Proto { } /// - /// Reply payload for AcknowledgeAlarmCommand. Surfaces AVEVA's native - /// AlarmAckByGUID return code; 0 means success. The MxCommandReply's - /// hresult field carries the same value and is preferred for protocol - /// consumers — this payload exists so the gateway-side - /// WorkerAlarmRpcDispatcher can echo native_status into - /// AcknowledgeAlarmReply.hresult without unpacking the outer envelope. + /// Reply payload for AcknowledgeAlarmCommand AND + /// AcknowledgeAlarmByNameCommand — both ack command kinds reuse this + /// payload case (`MxCommandReply.acknowledge_alarm`); there is no + /// dedicated by-name reply case. Surfaces AVEVA's native ack return + /// code (AlarmAckByGUID for the GUID arm, AlarmAckByName for the + /// by-name arm); 0 means success. The MxCommandReply's hresult field + /// carries the same value and is preferred for protocol consumers — + /// this payload exists so the gateway-side WorkerAlarmRpcDispatcher + /// can echo native_status into AcknowledgeAlarmReply.hresult without + /// unpacking the outer envelope. /// [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] public sealed partial class AcknowledgeAlarmReplyPayload : pb::IMessage @@ -17361,7 +21806,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[55]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[68]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -17565,7 +22010,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[56]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[69]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -17597,12 +22042,12 @@ namespace MxGateway.Contracts.Proto { /// Field number for the "snapshots" field. public const int SnapshotsFieldNumber = 1; - private static readonly pb::FieldCodec _repeated_snapshots_codec - = pb::FieldCodec.ForMessage(10, global::MxGateway.Contracts.Proto.ActiveAlarmSnapshot.Parser); - private readonly pbc::RepeatedField snapshots_ = new pbc::RepeatedField(); + private static readonly pb::FieldCodec _repeated_snapshots_codec + = pb::FieldCodec.ForMessage(10, global::ZB.MOM.WW.MxGateway.Contracts.Proto.ActiveAlarmSnapshot.Parser); + private readonly pbc::RepeatedField snapshots_ = new pbc::RepeatedField(); [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public pbc::RepeatedField Snapshots { + public pbc::RepeatedField Snapshots { get { return snapshots_; } } @@ -17753,7 +22198,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[57]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[70]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -17816,10 +22261,10 @@ namespace MxGateway.Contracts.Proto { /// Field number for the "family" field. public const int FamilyFieldNumber = 1; - private global::MxGateway.Contracts.Proto.MxEventFamily family_ = global::MxGateway.Contracts.Proto.MxEventFamily.Unspecified; + private global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxEventFamily family_ = global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxEventFamily.Unspecified; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.MxEventFamily Family { + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxEventFamily Family { get { return family_; } set { family_ = value; @@ -17864,10 +22309,10 @@ namespace MxGateway.Contracts.Proto { /// Field number for the "value" field. public const int ValueFieldNumber = 5; - private global::MxGateway.Contracts.Proto.MxValue value_; + private global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue value_; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.MxValue Value { + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue Value { get { return value_; } set { value_ = value; @@ -17900,12 +22345,12 @@ namespace MxGateway.Contracts.Proto { /// Field number for the "statuses" field. public const int StatusesFieldNumber = 8; - private static readonly pb::FieldCodec _repeated_statuses_codec - = pb::FieldCodec.ForMessage(66, global::MxGateway.Contracts.Proto.MxStatusProxy.Parser); - private readonly pbc::RepeatedField statuses_ = new pbc::RepeatedField(); + private static readonly pb::FieldCodec _repeated_statuses_codec + = pb::FieldCodec.ForMessage(66, global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusProxy.Parser); + private readonly pbc::RepeatedField statuses_ = new pbc::RepeatedField(); [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public pbc::RepeatedField Statuses { + public pbc::RepeatedField Statuses { get { return statuses_; } } @@ -17988,8 +22433,8 @@ namespace MxGateway.Contracts.Proto { public const int OnDataChangeFieldNumber = 20; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.OnDataChangeEvent OnDataChange { - get { return bodyCase_ == BodyOneofCase.OnDataChange ? (global::MxGateway.Contracts.Proto.OnDataChangeEvent) body_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnDataChangeEvent OnDataChange { + get { return bodyCase_ == BodyOneofCase.OnDataChange ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnDataChangeEvent) body_ : null; } set { body_ = value; bodyCase_ = value == null ? BodyOneofCase.None : BodyOneofCase.OnDataChange; @@ -18000,8 +22445,8 @@ namespace MxGateway.Contracts.Proto { public const int OnWriteCompleteFieldNumber = 21; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.OnWriteCompleteEvent OnWriteComplete { - get { return bodyCase_ == BodyOneofCase.OnWriteComplete ? (global::MxGateway.Contracts.Proto.OnWriteCompleteEvent) body_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnWriteCompleteEvent OnWriteComplete { + get { return bodyCase_ == BodyOneofCase.OnWriteComplete ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnWriteCompleteEvent) body_ : null; } set { body_ = value; bodyCase_ = value == null ? BodyOneofCase.None : BodyOneofCase.OnWriteComplete; @@ -18012,8 +22457,8 @@ namespace MxGateway.Contracts.Proto { public const int OperationCompleteFieldNumber = 22; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.OperationCompleteEvent OperationComplete { - get { return bodyCase_ == BodyOneofCase.OperationComplete ? (global::MxGateway.Contracts.Proto.OperationCompleteEvent) body_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.OperationCompleteEvent OperationComplete { + get { return bodyCase_ == BodyOneofCase.OperationComplete ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.OperationCompleteEvent) body_ : null; } set { body_ = value; bodyCase_ = value == null ? BodyOneofCase.None : BodyOneofCase.OperationComplete; @@ -18024,8 +22469,8 @@ namespace MxGateway.Contracts.Proto { public const int OnBufferedDataChangeFieldNumber = 23; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.OnBufferedDataChangeEvent OnBufferedDataChange { - get { return bodyCase_ == BodyOneofCase.OnBufferedDataChange ? (global::MxGateway.Contracts.Proto.OnBufferedDataChangeEvent) body_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnBufferedDataChangeEvent OnBufferedDataChange { + get { return bodyCase_ == BodyOneofCase.OnBufferedDataChange ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnBufferedDataChangeEvent) body_ : null; } set { body_ = value; bodyCase_ = value == null ? BodyOneofCase.None : BodyOneofCase.OnBufferedDataChange; @@ -18036,8 +22481,8 @@ namespace MxGateway.Contracts.Proto { public const int OnAlarmTransitionFieldNumber = 24; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.OnAlarmTransitionEvent OnAlarmTransition { - get { return bodyCase_ == BodyOneofCase.OnAlarmTransition ? (global::MxGateway.Contracts.Proto.OnAlarmTransitionEvent) body_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnAlarmTransitionEvent OnAlarmTransition { + get { return bodyCase_ == BodyOneofCase.OnAlarmTransition ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnAlarmTransitionEvent) body_ : null; } set { body_ = value; bodyCase_ = value == null ? BodyOneofCase.None : BodyOneofCase.OnAlarmTransition; @@ -18109,7 +22554,7 @@ namespace MxGateway.Contracts.Proto { [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public override int GetHashCode() { int hash = 1; - if (Family != global::MxGateway.Contracts.Proto.MxEventFamily.Unspecified) hash ^= Family.GetHashCode(); + if (Family != global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxEventFamily.Unspecified) hash ^= Family.GetHashCode(); if (SessionId.Length != 0) hash ^= SessionId.GetHashCode(); if (ServerHandle != 0) hash ^= ServerHandle.GetHashCode(); if (ItemHandle != 0) hash ^= ItemHandle.GetHashCode(); @@ -18146,7 +22591,7 @@ namespace MxGateway.Contracts.Proto { #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE output.WriteRawMessage(this); #else - if (Family != global::MxGateway.Contracts.Proto.MxEventFamily.Unspecified) { + if (Family != global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxEventFamily.Unspecified) { output.WriteRawTag(8); output.WriteEnum((int) Family); } @@ -18225,7 +22670,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { - if (Family != global::MxGateway.Contracts.Proto.MxEventFamily.Unspecified) { + if (Family != global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxEventFamily.Unspecified) { output.WriteRawTag(8); output.WriteEnum((int) Family); } @@ -18304,7 +22749,7 @@ namespace MxGateway.Contracts.Proto { [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public int CalculateSize() { int size = 0; - if (Family != global::MxGateway.Contracts.Proto.MxEventFamily.Unspecified) { + if (Family != global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxEventFamily.Unspecified) { size += 1 + pb::CodedOutputStream.ComputeEnumSize((int) Family); } if (SessionId.Length != 0) { @@ -18368,7 +22813,7 @@ namespace MxGateway.Contracts.Proto { if (other == null) { return; } - if (other.Family != global::MxGateway.Contracts.Proto.MxEventFamily.Unspecified) { + if (other.Family != global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxEventFamily.Unspecified) { Family = other.Family; } if (other.SessionId.Length != 0) { @@ -18382,7 +22827,7 @@ namespace MxGateway.Contracts.Proto { } if (other.value_ != null) { if (value_ == null) { - Value = new global::MxGateway.Contracts.Proto.MxValue(); + Value = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue(); } Value.MergeFrom(other.Value); } @@ -18420,31 +22865,31 @@ namespace MxGateway.Contracts.Proto { switch (other.BodyCase) { case BodyOneofCase.OnDataChange: if (OnDataChange == null) { - OnDataChange = new global::MxGateway.Contracts.Proto.OnDataChangeEvent(); + OnDataChange = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnDataChangeEvent(); } OnDataChange.MergeFrom(other.OnDataChange); break; case BodyOneofCase.OnWriteComplete: if (OnWriteComplete == null) { - OnWriteComplete = new global::MxGateway.Contracts.Proto.OnWriteCompleteEvent(); + OnWriteComplete = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnWriteCompleteEvent(); } OnWriteComplete.MergeFrom(other.OnWriteComplete); break; case BodyOneofCase.OperationComplete: if (OperationComplete == null) { - OperationComplete = new global::MxGateway.Contracts.Proto.OperationCompleteEvent(); + OperationComplete = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.OperationCompleteEvent(); } OperationComplete.MergeFrom(other.OperationComplete); break; case BodyOneofCase.OnBufferedDataChange: if (OnBufferedDataChange == null) { - OnBufferedDataChange = new global::MxGateway.Contracts.Proto.OnBufferedDataChangeEvent(); + OnBufferedDataChange = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnBufferedDataChangeEvent(); } OnBufferedDataChange.MergeFrom(other.OnBufferedDataChange); break; case BodyOneofCase.OnAlarmTransition: if (OnAlarmTransition == null) { - OnAlarmTransition = new global::MxGateway.Contracts.Proto.OnAlarmTransitionEvent(); + OnAlarmTransition = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnAlarmTransitionEvent(); } OnAlarmTransition.MergeFrom(other.OnAlarmTransition); break; @@ -18470,7 +22915,7 @@ namespace MxGateway.Contracts.Proto { _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); break; case 8: { - Family = (global::MxGateway.Contracts.Proto.MxEventFamily) input.ReadEnum(); + Family = (global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxEventFamily) input.ReadEnum(); break; } case 18: { @@ -18487,7 +22932,7 @@ namespace MxGateway.Contracts.Proto { } case 42: { if (value_ == null) { - Value = new global::MxGateway.Contracts.Proto.MxValue(); + Value = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue(); } input.ReadMessage(Value); break; @@ -18534,7 +22979,7 @@ namespace MxGateway.Contracts.Proto { break; } case 162: { - global::MxGateway.Contracts.Proto.OnDataChangeEvent subBuilder = new global::MxGateway.Contracts.Proto.OnDataChangeEvent(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnDataChangeEvent subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnDataChangeEvent(); if (bodyCase_ == BodyOneofCase.OnDataChange) { subBuilder.MergeFrom(OnDataChange); } @@ -18543,7 +22988,7 @@ namespace MxGateway.Contracts.Proto { break; } case 170: { - global::MxGateway.Contracts.Proto.OnWriteCompleteEvent subBuilder = new global::MxGateway.Contracts.Proto.OnWriteCompleteEvent(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnWriteCompleteEvent subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnWriteCompleteEvent(); if (bodyCase_ == BodyOneofCase.OnWriteComplete) { subBuilder.MergeFrom(OnWriteComplete); } @@ -18552,7 +22997,7 @@ namespace MxGateway.Contracts.Proto { break; } case 178: { - global::MxGateway.Contracts.Proto.OperationCompleteEvent subBuilder = new global::MxGateway.Contracts.Proto.OperationCompleteEvent(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.OperationCompleteEvent subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.OperationCompleteEvent(); if (bodyCase_ == BodyOneofCase.OperationComplete) { subBuilder.MergeFrom(OperationComplete); } @@ -18561,7 +23006,7 @@ namespace MxGateway.Contracts.Proto { break; } case 186: { - global::MxGateway.Contracts.Proto.OnBufferedDataChangeEvent subBuilder = new global::MxGateway.Contracts.Proto.OnBufferedDataChangeEvent(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnBufferedDataChangeEvent subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnBufferedDataChangeEvent(); if (bodyCase_ == BodyOneofCase.OnBufferedDataChange) { subBuilder.MergeFrom(OnBufferedDataChange); } @@ -18570,7 +23015,7 @@ namespace MxGateway.Contracts.Proto { break; } case 194: { - global::MxGateway.Contracts.Proto.OnAlarmTransitionEvent subBuilder = new global::MxGateway.Contracts.Proto.OnAlarmTransitionEvent(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnAlarmTransitionEvent subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnAlarmTransitionEvent(); if (bodyCase_ == BodyOneofCase.OnAlarmTransition) { subBuilder.MergeFrom(OnAlarmTransition); } @@ -18598,7 +23043,7 @@ namespace MxGateway.Contracts.Proto { _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); break; case 8: { - Family = (global::MxGateway.Contracts.Proto.MxEventFamily) input.ReadEnum(); + Family = (global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxEventFamily) input.ReadEnum(); break; } case 18: { @@ -18615,7 +23060,7 @@ namespace MxGateway.Contracts.Proto { } case 42: { if (value_ == null) { - Value = new global::MxGateway.Contracts.Proto.MxValue(); + Value = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue(); } input.ReadMessage(Value); break; @@ -18662,7 +23107,7 @@ namespace MxGateway.Contracts.Proto { break; } case 162: { - global::MxGateway.Contracts.Proto.OnDataChangeEvent subBuilder = new global::MxGateway.Contracts.Proto.OnDataChangeEvent(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnDataChangeEvent subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnDataChangeEvent(); if (bodyCase_ == BodyOneofCase.OnDataChange) { subBuilder.MergeFrom(OnDataChange); } @@ -18671,7 +23116,7 @@ namespace MxGateway.Contracts.Proto { break; } case 170: { - global::MxGateway.Contracts.Proto.OnWriteCompleteEvent subBuilder = new global::MxGateway.Contracts.Proto.OnWriteCompleteEvent(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnWriteCompleteEvent subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnWriteCompleteEvent(); if (bodyCase_ == BodyOneofCase.OnWriteComplete) { subBuilder.MergeFrom(OnWriteComplete); } @@ -18680,7 +23125,7 @@ namespace MxGateway.Contracts.Proto { break; } case 178: { - global::MxGateway.Contracts.Proto.OperationCompleteEvent subBuilder = new global::MxGateway.Contracts.Proto.OperationCompleteEvent(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.OperationCompleteEvent subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.OperationCompleteEvent(); if (bodyCase_ == BodyOneofCase.OperationComplete) { subBuilder.MergeFrom(OperationComplete); } @@ -18689,7 +23134,7 @@ namespace MxGateway.Contracts.Proto { break; } case 186: { - global::MxGateway.Contracts.Proto.OnBufferedDataChangeEvent subBuilder = new global::MxGateway.Contracts.Proto.OnBufferedDataChangeEvent(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnBufferedDataChangeEvent subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnBufferedDataChangeEvent(); if (bodyCase_ == BodyOneofCase.OnBufferedDataChange) { subBuilder.MergeFrom(OnBufferedDataChange); } @@ -18698,7 +23143,7 @@ namespace MxGateway.Contracts.Proto { break; } case 194: { - global::MxGateway.Contracts.Proto.OnAlarmTransitionEvent subBuilder = new global::MxGateway.Contracts.Proto.OnAlarmTransitionEvent(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnAlarmTransitionEvent subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnAlarmTransitionEvent(); if (bodyCase_ == BodyOneofCase.OnAlarmTransition) { subBuilder.MergeFrom(OnAlarmTransition); } @@ -18728,7 +23173,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[58]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[71]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -18889,7 +23334,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[59]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[72]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -19050,7 +23495,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[60]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[73]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -19211,7 +23656,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[61]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[74]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -19246,10 +23691,10 @@ namespace MxGateway.Contracts.Proto { /// Field number for the "data_type" field. public const int DataTypeFieldNumber = 1; - private global::MxGateway.Contracts.Proto.MxDataType dataType_ = global::MxGateway.Contracts.Proto.MxDataType.Unspecified; + private global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxDataType dataType_ = global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxDataType.Unspecified; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.MxDataType DataType { + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxDataType DataType { get { return dataType_; } set { dataType_ = value; @@ -19258,10 +23703,10 @@ namespace MxGateway.Contracts.Proto { /// Field number for the "quality_values" field. public const int QualityValuesFieldNumber = 2; - private global::MxGateway.Contracts.Proto.MxArray qualityValues_; + private global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxArray qualityValues_; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.MxArray QualityValues { + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxArray QualityValues { get { return qualityValues_; } set { qualityValues_ = value; @@ -19270,10 +23715,10 @@ namespace MxGateway.Contracts.Proto { /// Field number for the "timestamp_values" field. public const int TimestampValuesFieldNumber = 3; - private global::MxGateway.Contracts.Proto.MxArray timestampValues_; + private global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxArray timestampValues_; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.MxArray TimestampValues { + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxArray TimestampValues { get { return timestampValues_; } set { timestampValues_ = value; @@ -19318,7 +23763,7 @@ namespace MxGateway.Contracts.Proto { [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public override int GetHashCode() { int hash = 1; - if (DataType != global::MxGateway.Contracts.Proto.MxDataType.Unspecified) hash ^= DataType.GetHashCode(); + if (DataType != global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxDataType.Unspecified) hash ^= DataType.GetHashCode(); if (qualityValues_ != null) hash ^= QualityValues.GetHashCode(); if (timestampValues_ != null) hash ^= TimestampValues.GetHashCode(); if (RawDataType != 0) hash ^= RawDataType.GetHashCode(); @@ -19340,7 +23785,7 @@ namespace MxGateway.Contracts.Proto { #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE output.WriteRawMessage(this); #else - if (DataType != global::MxGateway.Contracts.Proto.MxDataType.Unspecified) { + if (DataType != global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxDataType.Unspecified) { output.WriteRawTag(8); output.WriteEnum((int) DataType); } @@ -19366,7 +23811,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { - if (DataType != global::MxGateway.Contracts.Proto.MxDataType.Unspecified) { + if (DataType != global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxDataType.Unspecified) { output.WriteRawTag(8); output.WriteEnum((int) DataType); } @@ -19392,7 +23837,7 @@ namespace MxGateway.Contracts.Proto { [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public int CalculateSize() { int size = 0; - if (DataType != global::MxGateway.Contracts.Proto.MxDataType.Unspecified) { + if (DataType != global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxDataType.Unspecified) { size += 1 + pb::CodedOutputStream.ComputeEnumSize((int) DataType); } if (qualityValues_ != null) { @@ -19416,18 +23861,18 @@ namespace MxGateway.Contracts.Proto { if (other == null) { return; } - if (other.DataType != global::MxGateway.Contracts.Proto.MxDataType.Unspecified) { + if (other.DataType != global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxDataType.Unspecified) { DataType = other.DataType; } if (other.qualityValues_ != null) { if (qualityValues_ == null) { - QualityValues = new global::MxGateway.Contracts.Proto.MxArray(); + QualityValues = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxArray(); } QualityValues.MergeFrom(other.QualityValues); } if (other.timestampValues_ != null) { if (timestampValues_ == null) { - TimestampValues = new global::MxGateway.Contracts.Proto.MxArray(); + TimestampValues = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxArray(); } TimestampValues.MergeFrom(other.TimestampValues); } @@ -19454,19 +23899,19 @@ namespace MxGateway.Contracts.Proto { _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); break; case 8: { - DataType = (global::MxGateway.Contracts.Proto.MxDataType) input.ReadEnum(); + DataType = (global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxDataType) input.ReadEnum(); break; } case 18: { if (qualityValues_ == null) { - QualityValues = new global::MxGateway.Contracts.Proto.MxArray(); + QualityValues = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxArray(); } input.ReadMessage(QualityValues); break; } case 26: { if (timestampValues_ == null) { - TimestampValues = new global::MxGateway.Contracts.Proto.MxArray(); + TimestampValues = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxArray(); } input.ReadMessage(TimestampValues); break; @@ -19495,19 +23940,19 @@ namespace MxGateway.Contracts.Proto { _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); break; case 8: { - DataType = (global::MxGateway.Contracts.Proto.MxDataType) input.ReadEnum(); + DataType = (global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxDataType) input.ReadEnum(); break; } case 18: { if (qualityValues_ == null) { - QualityValues = new global::MxGateway.Contracts.Proto.MxArray(); + QualityValues = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxArray(); } input.ReadMessage(QualityValues); break; } case 26: { if (timestampValues_ == null) { - TimestampValues = new global::MxGateway.Contracts.Proto.MxArray(); + TimestampValues = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxArray(); } input.ReadMessage(TimestampValues); break; @@ -19544,7 +23989,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[62]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[75]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -19636,13 +24081,13 @@ namespace MxGateway.Contracts.Proto { /// Field number for the "transition_kind" field. public const int TransitionKindFieldNumber = 4; - private global::MxGateway.Contracts.Proto.AlarmTransitionKind transitionKind_ = global::MxGateway.Contracts.Proto.AlarmTransitionKind.Unspecified; + private global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmTransitionKind transitionKind_ = global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmTransitionKind.Unspecified; /// /// What kind of state change this event represents. /// [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.AlarmTransitionKind TransitionKind { + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmTransitionKind TransitionKind { get { return transitionKind_; } set { transitionKind_ = value; @@ -19764,14 +24209,14 @@ namespace MxGateway.Contracts.Proto { /// Field number for the "current_value" field. public const int CurrentValueFieldNumber = 12; - private global::MxGateway.Contracts.Proto.MxValue currentValue_; + private global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue currentValue_; /// /// Current alarm value (the value of the source attribute at the moment of /// transition). Optional; populated when MxAccess surfaces it. /// [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.MxValue CurrentValue { + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue CurrentValue { get { return currentValue_; } set { currentValue_ = value; @@ -19780,14 +24225,14 @@ namespace MxGateway.Contracts.Proto { /// Field number for the "limit_value" field. public const int LimitValueFieldNumber = 13; - private global::MxGateway.Contracts.Proto.MxValue limitValue_; + private global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue limitValue_; /// /// Limit/threshold value that triggered the transition for limit alarms. /// Optional; populated for AnalogLimitAlarm-family transitions. /// [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.MxValue LimitValue { + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue LimitValue { get { return limitValue_; } set { limitValue_ = value; @@ -19832,7 +24277,7 @@ namespace MxGateway.Contracts.Proto { if (AlarmFullReference.Length != 0) hash ^= AlarmFullReference.GetHashCode(); if (SourceObjectReference.Length != 0) hash ^= SourceObjectReference.GetHashCode(); if (AlarmTypeName.Length != 0) hash ^= AlarmTypeName.GetHashCode(); - if (TransitionKind != global::MxGateway.Contracts.Proto.AlarmTransitionKind.Unspecified) hash ^= TransitionKind.GetHashCode(); + if (TransitionKind != global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmTransitionKind.Unspecified) hash ^= TransitionKind.GetHashCode(); if (Severity != 0) hash ^= Severity.GetHashCode(); if (originalRaiseTimestamp_ != null) hash ^= OriginalRaiseTimestamp.GetHashCode(); if (transitionTimestamp_ != null) hash ^= TransitionTimestamp.GetHashCode(); @@ -19872,7 +24317,7 @@ namespace MxGateway.Contracts.Proto { output.WriteRawTag(26); output.WriteString(AlarmTypeName); } - if (TransitionKind != global::MxGateway.Contracts.Proto.AlarmTransitionKind.Unspecified) { + if (TransitionKind != global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmTransitionKind.Unspecified) { output.WriteRawTag(32); output.WriteEnum((int) TransitionKind); } @@ -19934,7 +24379,7 @@ namespace MxGateway.Contracts.Proto { output.WriteRawTag(26); output.WriteString(AlarmTypeName); } - if (TransitionKind != global::MxGateway.Contracts.Proto.AlarmTransitionKind.Unspecified) { + if (TransitionKind != global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmTransitionKind.Unspecified) { output.WriteRawTag(32); output.WriteEnum((int) TransitionKind); } @@ -19993,7 +24438,7 @@ namespace MxGateway.Contracts.Proto { if (AlarmTypeName.Length != 0) { size += 1 + pb::CodedOutputStream.ComputeStringSize(AlarmTypeName); } - if (TransitionKind != global::MxGateway.Contracts.Proto.AlarmTransitionKind.Unspecified) { + if (TransitionKind != global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmTransitionKind.Unspecified) { size += 1 + pb::CodedOutputStream.ComputeEnumSize((int) TransitionKind); } if (Severity != 0) { @@ -20044,7 +24489,7 @@ namespace MxGateway.Contracts.Proto { if (other.AlarmTypeName.Length != 0) { AlarmTypeName = other.AlarmTypeName; } - if (other.TransitionKind != global::MxGateway.Contracts.Proto.AlarmTransitionKind.Unspecified) { + if (other.TransitionKind != global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmTransitionKind.Unspecified) { TransitionKind = other.TransitionKind; } if (other.Severity != 0) { @@ -20076,13 +24521,13 @@ namespace MxGateway.Contracts.Proto { } if (other.currentValue_ != null) { if (currentValue_ == null) { - CurrentValue = new global::MxGateway.Contracts.Proto.MxValue(); + CurrentValue = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue(); } CurrentValue.MergeFrom(other.CurrentValue); } if (other.limitValue_ != null) { if (limitValue_ == null) { - LimitValue = new global::MxGateway.Contracts.Proto.MxValue(); + LimitValue = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue(); } LimitValue.MergeFrom(other.LimitValue); } @@ -20118,7 +24563,7 @@ namespace MxGateway.Contracts.Proto { break; } case 32: { - TransitionKind = (global::MxGateway.Contracts.Proto.AlarmTransitionKind) input.ReadEnum(); + TransitionKind = (global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmTransitionKind) input.ReadEnum(); break; } case 40: { @@ -20157,14 +24602,14 @@ namespace MxGateway.Contracts.Proto { } case 98: { if (currentValue_ == null) { - CurrentValue = new global::MxGateway.Contracts.Proto.MxValue(); + CurrentValue = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue(); } input.ReadMessage(CurrentValue); break; } case 106: { if (limitValue_ == null) { - LimitValue = new global::MxGateway.Contracts.Proto.MxValue(); + LimitValue = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue(); } input.ReadMessage(LimitValue); break; @@ -20201,7 +24646,7 @@ namespace MxGateway.Contracts.Proto { break; } case 32: { - TransitionKind = (global::MxGateway.Contracts.Proto.AlarmTransitionKind) input.ReadEnum(); + TransitionKind = (global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmTransitionKind) input.ReadEnum(); break; } case 40: { @@ -20240,14 +24685,14 @@ namespace MxGateway.Contracts.Proto { } case 98: { if (currentValue_ == null) { - CurrentValue = new global::MxGateway.Contracts.Proto.MxValue(); + CurrentValue = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue(); } input.ReadMessage(CurrentValue); break; } case 106: { if (limitValue_ == null) { - LimitValue = new global::MxGateway.Contracts.Proto.MxValue(); + LimitValue = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue(); } input.ReadMessage(LimitValue); break; @@ -20278,7 +24723,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[63]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[76]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -20382,10 +24827,10 @@ namespace MxGateway.Contracts.Proto { /// Field number for the "current_state" field. public const int CurrentStateFieldNumber = 6; - private global::MxGateway.Contracts.Proto.AlarmConditionState currentState_ = global::MxGateway.Contracts.Proto.AlarmConditionState.Unspecified; + private global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmConditionState currentState_ = global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmConditionState.Unspecified; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.AlarmConditionState CurrentState { + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmConditionState CurrentState { get { return currentState_; } set { currentState_ = value; @@ -20466,10 +24911,10 @@ namespace MxGateway.Contracts.Proto { /// Field number for the "current_value" field. public const int CurrentValueFieldNumber = 12; - private global::MxGateway.Contracts.Proto.MxValue currentValue_; + private global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue currentValue_; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.MxValue CurrentValue { + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue CurrentValue { get { return currentValue_; } set { currentValue_ = value; @@ -20478,10 +24923,10 @@ namespace MxGateway.Contracts.Proto { /// Field number for the "limit_value" field. public const int LimitValueFieldNumber = 13; - private global::MxGateway.Contracts.Proto.MxValue limitValue_; + private global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue limitValue_; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.MxValue LimitValue { + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue LimitValue { get { return limitValue_; } set { limitValue_ = value; @@ -20528,7 +24973,7 @@ namespace MxGateway.Contracts.Proto { if (AlarmTypeName.Length != 0) hash ^= AlarmTypeName.GetHashCode(); if (Severity != 0) hash ^= Severity.GetHashCode(); if (originalRaiseTimestamp_ != null) hash ^= OriginalRaiseTimestamp.GetHashCode(); - if (CurrentState != global::MxGateway.Contracts.Proto.AlarmConditionState.Unspecified) hash ^= CurrentState.GetHashCode(); + if (CurrentState != global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmConditionState.Unspecified) hash ^= CurrentState.GetHashCode(); if (Category.Length != 0) hash ^= Category.GetHashCode(); if (Description.Length != 0) hash ^= Description.GetHashCode(); if (lastTransitionTimestamp_ != null) hash ^= LastTransitionTimestamp.GetHashCode(); @@ -20574,7 +25019,7 @@ namespace MxGateway.Contracts.Proto { output.WriteRawTag(42); output.WriteMessage(OriginalRaiseTimestamp); } - if (CurrentState != global::MxGateway.Contracts.Proto.AlarmConditionState.Unspecified) { + if (CurrentState != global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmConditionState.Unspecified) { output.WriteRawTag(48); output.WriteEnum((int) CurrentState); } @@ -20636,7 +25081,7 @@ namespace MxGateway.Contracts.Proto { output.WriteRawTag(42); output.WriteMessage(OriginalRaiseTimestamp); } - if (CurrentState != global::MxGateway.Contracts.Proto.AlarmConditionState.Unspecified) { + if (CurrentState != global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmConditionState.Unspecified) { output.WriteRawTag(48); output.WriteEnum((int) CurrentState); } @@ -20693,7 +25138,7 @@ namespace MxGateway.Contracts.Proto { if (originalRaiseTimestamp_ != null) { size += 1 + pb::CodedOutputStream.ComputeMessageSize(OriginalRaiseTimestamp); } - if (CurrentState != global::MxGateway.Contracts.Proto.AlarmConditionState.Unspecified) { + if (CurrentState != global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmConditionState.Unspecified) { size += 1 + pb::CodedOutputStream.ComputeEnumSize((int) CurrentState); } if (Category.Length != 0) { @@ -20747,7 +25192,7 @@ namespace MxGateway.Contracts.Proto { } OriginalRaiseTimestamp.MergeFrom(other.OriginalRaiseTimestamp); } - if (other.CurrentState != global::MxGateway.Contracts.Proto.AlarmConditionState.Unspecified) { + if (other.CurrentState != global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmConditionState.Unspecified) { CurrentState = other.CurrentState; } if (other.Category.Length != 0) { @@ -20770,13 +25215,13 @@ namespace MxGateway.Contracts.Proto { } if (other.currentValue_ != null) { if (currentValue_ == null) { - CurrentValue = new global::MxGateway.Contracts.Proto.MxValue(); + CurrentValue = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue(); } CurrentValue.MergeFrom(other.CurrentValue); } if (other.limitValue_ != null) { if (limitValue_ == null) { - LimitValue = new global::MxGateway.Contracts.Proto.MxValue(); + LimitValue = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue(); } LimitValue.MergeFrom(other.LimitValue); } @@ -20823,7 +25268,7 @@ namespace MxGateway.Contracts.Proto { break; } case 48: { - CurrentState = (global::MxGateway.Contracts.Proto.AlarmConditionState) input.ReadEnum(); + CurrentState = (global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmConditionState) input.ReadEnum(); break; } case 58: { @@ -20851,14 +25296,14 @@ namespace MxGateway.Contracts.Proto { } case 98: { if (currentValue_ == null) { - CurrentValue = new global::MxGateway.Contracts.Proto.MxValue(); + CurrentValue = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue(); } input.ReadMessage(CurrentValue); break; } case 106: { if (limitValue_ == null) { - LimitValue = new global::MxGateway.Contracts.Proto.MxValue(); + LimitValue = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue(); } input.ReadMessage(LimitValue); break; @@ -20906,7 +25351,7 @@ namespace MxGateway.Contracts.Proto { break; } case 48: { - CurrentState = (global::MxGateway.Contracts.Proto.AlarmConditionState) input.ReadEnum(); + CurrentState = (global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmConditionState) input.ReadEnum(); break; } case 58: { @@ -20934,14 +25379,14 @@ namespace MxGateway.Contracts.Proto { } case 98: { if (currentValue_ == null) { - CurrentValue = new global::MxGateway.Contracts.Proto.MxValue(); + CurrentValue = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue(); } input.ReadMessage(CurrentValue); break; } case 106: { if (limitValue_ == null) { - LimitValue = new global::MxGateway.Contracts.Proto.MxValue(); + LimitValue = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue(); } input.ReadMessage(LimitValue); break; @@ -20968,7 +25413,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[64]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[77]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -20988,7 +25433,6 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public AcknowledgeAlarmRequest(AcknowledgeAlarmRequest other) : this() { - sessionId_ = other.sessionId_; clientCorrelationId_ = other.clientCorrelationId_; alarmFullReference_ = other.alarmFullReference_; comment_ = other.comment_; @@ -21002,18 +25446,6 @@ namespace MxGateway.Contracts.Proto { return new AcknowledgeAlarmRequest(this); } - /// Field number for the "session_id" field. - public const int SessionIdFieldNumber = 1; - private string sessionId_ = ""; - [global::System.Diagnostics.DebuggerNonUserCodeAttribute] - [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public string SessionId { - get { return sessionId_; } - set { - sessionId_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); - } - } - /// Field number for the "client_correlation_id" field. public const int ClientCorrelationIdFieldNumber = 2; private string clientCorrelationId_ = ""; @@ -21087,7 +25519,6 @@ namespace MxGateway.Contracts.Proto { if (ReferenceEquals(other, this)) { return true; } - if (SessionId != other.SessionId) return false; if (ClientCorrelationId != other.ClientCorrelationId) return false; if (AlarmFullReference != other.AlarmFullReference) return false; if (Comment != other.Comment) return false; @@ -21099,7 +25530,6 @@ namespace MxGateway.Contracts.Proto { [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public override int GetHashCode() { int hash = 1; - if (SessionId.Length != 0) hash ^= SessionId.GetHashCode(); if (ClientCorrelationId.Length != 0) hash ^= ClientCorrelationId.GetHashCode(); if (AlarmFullReference.Length != 0) hash ^= AlarmFullReference.GetHashCode(); if (Comment.Length != 0) hash ^= Comment.GetHashCode(); @@ -21122,10 +25552,6 @@ namespace MxGateway.Contracts.Proto { #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE output.WriteRawMessage(this); #else - if (SessionId.Length != 0) { - output.WriteRawTag(10); - output.WriteString(SessionId); - } if (ClientCorrelationId.Length != 0) { output.WriteRawTag(18); output.WriteString(ClientCorrelationId); @@ -21152,10 +25578,6 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { - if (SessionId.Length != 0) { - output.WriteRawTag(10); - output.WriteString(SessionId); - } if (ClientCorrelationId.Length != 0) { output.WriteRawTag(18); output.WriteString(ClientCorrelationId); @@ -21182,9 +25604,6 @@ namespace MxGateway.Contracts.Proto { [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public int CalculateSize() { int size = 0; - if (SessionId.Length != 0) { - size += 1 + pb::CodedOutputStream.ComputeStringSize(SessionId); - } if (ClientCorrelationId.Length != 0) { size += 1 + pb::CodedOutputStream.ComputeStringSize(ClientCorrelationId); } @@ -21209,9 +25628,6 @@ namespace MxGateway.Contracts.Proto { if (other == null) { return; } - if (other.SessionId.Length != 0) { - SessionId = other.SessionId; - } if (other.ClientCorrelationId.Length != 0) { ClientCorrelationId = other.ClientCorrelationId; } @@ -21243,10 +25659,6 @@ namespace MxGateway.Contracts.Proto { default: _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); break; - case 10: { - SessionId = input.ReadString(); - break; - } case 18: { ClientCorrelationId = input.ReadString(); break; @@ -21282,10 +25694,6 @@ namespace MxGateway.Contracts.Proto { default: _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); break; - case 10: { - SessionId = input.ReadString(); - break; - } case 18: { ClientCorrelationId = input.ReadString(); break; @@ -21325,7 +25733,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[65]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[78]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -21346,7 +25754,6 @@ namespace MxGateway.Contracts.Proto { [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public AcknowledgeAlarmReply(AcknowledgeAlarmReply other) : this() { _hasBits0 = other._hasBits0; - sessionId_ = other.sessionId_; correlationId_ = other.correlationId_; protocolStatus_ = other.protocolStatus_ != null ? other.protocolStatus_.Clone() : null; hresult_ = other.hresult_; @@ -21361,18 +25768,6 @@ namespace MxGateway.Contracts.Proto { return new AcknowledgeAlarmReply(this); } - /// Field number for the "session_id" field. - public const int SessionIdFieldNumber = 1; - private string sessionId_ = ""; - [global::System.Diagnostics.DebuggerNonUserCodeAttribute] - [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public string SessionId { - get { return sessionId_; } - set { - sessionId_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); - } - } - /// Field number for the "correlation_id" field. public const int CorrelationIdFieldNumber = 2; private string correlationId_ = ""; @@ -21387,10 +25782,10 @@ namespace MxGateway.Contracts.Proto { /// Field number for the "protocol_status" field. public const int ProtocolStatusFieldNumber = 3; - private global::MxGateway.Contracts.Proto.ProtocolStatus protocolStatus_; + private global::ZB.MOM.WW.MxGateway.Contracts.Proto.ProtocolStatus protocolStatus_; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.ProtocolStatus ProtocolStatus { + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.ProtocolStatus ProtocolStatus { get { return protocolStatus_; } set { protocolStatus_ = value; @@ -21403,7 +25798,12 @@ namespace MxGateway.Contracts.Proto { private int hresult_; /// - /// HRESULT captured from MXAccess if the ack failed at the COM layer. + /// Native ack return code echoed from the worker. The worker carries the + /// ack outcome as a single int32 (AcknowledgeAlarmReplyPayload.native_status, + /// = AlarmAckByName / AlarmAckByGUID return code; 0 = success); the gateway's + /// WorkerAlarmRpcDispatcher copies that value here. This is the authoritative + /// ack-outcome field for the public RPC. Absent only when the worker reply + /// omitted the value entirely (a protocol violation). /// [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] @@ -21429,13 +25829,17 @@ namespace MxGateway.Contracts.Proto { /// Field number for the "status" field. public const int StatusFieldNumber = 5; - private global::MxGateway.Contracts.Proto.MxStatusProxy status_; + private global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusProxy status_; /// - /// Native MxAccess status describing the outcome of the ack. + /// Reserved for a structured MxStatusProxy view of the ack outcome. The + /// worker by-name/by-GUID ack path produces only the int32 return code + /// (see `hresult`), so the current gateway leaves this field UNSET on every + /// reply. Clients must read `hresult` (and `protocol_status`) for the ack + /// result and must not depend on `status` being populated. /// [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.MxStatusProxy Status { + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusProxy Status { get { return status_; } set { status_ = value; @@ -21469,7 +25873,6 @@ namespace MxGateway.Contracts.Proto { if (ReferenceEquals(other, this)) { return true; } - if (SessionId != other.SessionId) return false; if (CorrelationId != other.CorrelationId) return false; if (!object.Equals(ProtocolStatus, other.ProtocolStatus)) return false; if (Hresult != other.Hresult) return false; @@ -21482,7 +25885,6 @@ namespace MxGateway.Contracts.Proto { [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public override int GetHashCode() { int hash = 1; - if (SessionId.Length != 0) hash ^= SessionId.GetHashCode(); if (CorrelationId.Length != 0) hash ^= CorrelationId.GetHashCode(); if (protocolStatus_ != null) hash ^= ProtocolStatus.GetHashCode(); if (HasHresult) hash ^= Hresult.GetHashCode(); @@ -21506,10 +25908,6 @@ namespace MxGateway.Contracts.Proto { #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE output.WriteRawMessage(this); #else - if (SessionId.Length != 0) { - output.WriteRawTag(10); - output.WriteString(SessionId); - } if (CorrelationId.Length != 0) { output.WriteRawTag(18); output.WriteString(CorrelationId); @@ -21540,10 +25938,6 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { - if (SessionId.Length != 0) { - output.WriteRawTag(10); - output.WriteString(SessionId); - } if (CorrelationId.Length != 0) { output.WriteRawTag(18); output.WriteString(CorrelationId); @@ -21574,9 +25968,6 @@ namespace MxGateway.Contracts.Proto { [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public int CalculateSize() { int size = 0; - if (SessionId.Length != 0) { - size += 1 + pb::CodedOutputStream.ComputeStringSize(SessionId); - } if (CorrelationId.Length != 0) { size += 1 + pb::CodedOutputStream.ComputeStringSize(CorrelationId); } @@ -21604,15 +25995,12 @@ namespace MxGateway.Contracts.Proto { if (other == null) { return; } - if (other.SessionId.Length != 0) { - SessionId = other.SessionId; - } if (other.CorrelationId.Length != 0) { CorrelationId = other.CorrelationId; } if (other.protocolStatus_ != null) { if (protocolStatus_ == null) { - ProtocolStatus = new global::MxGateway.Contracts.Proto.ProtocolStatus(); + ProtocolStatus = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.ProtocolStatus(); } ProtocolStatus.MergeFrom(other.ProtocolStatus); } @@ -21621,7 +26009,7 @@ namespace MxGateway.Contracts.Proto { } if (other.status_ != null) { if (status_ == null) { - Status = new global::MxGateway.Contracts.Proto.MxStatusProxy(); + Status = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusProxy(); } Status.MergeFrom(other.Status); } @@ -21647,17 +26035,13 @@ namespace MxGateway.Contracts.Proto { default: _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); break; - case 10: { - SessionId = input.ReadString(); - break; - } case 18: { CorrelationId = input.ReadString(); break; } case 26: { if (protocolStatus_ == null) { - ProtocolStatus = new global::MxGateway.Contracts.Proto.ProtocolStatus(); + ProtocolStatus = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.ProtocolStatus(); } input.ReadMessage(ProtocolStatus); break; @@ -21668,7 +26052,7 @@ namespace MxGateway.Contracts.Proto { } case 42: { if (status_ == null) { - Status = new global::MxGateway.Contracts.Proto.MxStatusProxy(); + Status = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusProxy(); } input.ReadMessage(Status); break; @@ -21696,17 +26080,13 @@ namespace MxGateway.Contracts.Proto { default: _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); break; - case 10: { - SessionId = input.ReadString(); - break; - } case 18: { CorrelationId = input.ReadString(); break; } case 26: { if (protocolStatus_ == null) { - ProtocolStatus = new global::MxGateway.Contracts.Proto.ProtocolStatus(); + ProtocolStatus = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.ProtocolStatus(); } input.ReadMessage(ProtocolStatus); break; @@ -21717,7 +26097,7 @@ namespace MxGateway.Contracts.Proto { } case 42: { if (status_ == null) { - Status = new global::MxGateway.Contracts.Proto.MxStatusProxy(); + Status = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusProxy(); } input.ReadMessage(Status); break; @@ -21733,22 +26113,25 @@ namespace MxGateway.Contracts.Proto { } + /// + /// Request to attach to the gateway's central alarm feed (StreamAlarms). + /// [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] - public sealed partial class QueryActiveAlarmsRequest : pb::IMessage + public sealed partial class StreamAlarmsRequest : pb::IMessage #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE , pb::IBufferMessage #endif { - private static readonly pb::MessageParser _parser = new pb::MessageParser(() => new QueryActiveAlarmsRequest()); + private static readonly pb::MessageParser _parser = new pb::MessageParser(() => new StreamAlarmsRequest()); private pb::UnknownFieldSet _unknownFields; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public static pb::MessageParser Parser { get { return _parser; } } + public static pb::MessageParser Parser { get { return _parser; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[66]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[79]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -21759,7 +26142,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public QueryActiveAlarmsRequest() { + public StreamAlarmsRequest() { OnConstruction(); } @@ -21767,8 +26150,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public QueryActiveAlarmsRequest(QueryActiveAlarmsRequest other) : this() { - sessionId_ = other.sessionId_; + public StreamAlarmsRequest(StreamAlarmsRequest other) : this() { clientCorrelationId_ = other.clientCorrelationId_; alarmFilterPrefix_ = other.alarmFilterPrefix_; _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); @@ -21776,24 +26158,12 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public QueryActiveAlarmsRequest Clone() { - return new QueryActiveAlarmsRequest(this); - } - - /// Field number for the "session_id" field. - public const int SessionIdFieldNumber = 1; - private string sessionId_ = ""; - [global::System.Diagnostics.DebuggerNonUserCodeAttribute] - [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public string SessionId { - get { return sessionId_; } - set { - sessionId_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); - } + public StreamAlarmsRequest Clone() { + return new StreamAlarmsRequest(this); } /// Field number for the "client_correlation_id" field. - public const int ClientCorrelationIdFieldNumber = 2; + public const int ClientCorrelationIdFieldNumber = 1; private string clientCorrelationId_ = ""; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] @@ -21805,11 +26175,11 @@ namespace MxGateway.Contracts.Proto { } /// Field number for the "alarm_filter_prefix" field. - public const int AlarmFilterPrefixFieldNumber = 3; + public const int AlarmFilterPrefixFieldNumber = 2; private string alarmFilterPrefix_ = ""; /// - /// Optional alarm-reference prefix used to scope a partial ConditionRefresh - /// (e.g. equipment sub-tree). Empty means full refresh. + /// Optional alarm-reference prefix scoping the feed to an equipment + /// sub-tree. Empty streams every active alarm. /// [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] @@ -21823,19 +26193,18 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public override bool Equals(object other) { - return Equals(other as QueryActiveAlarmsRequest); + return Equals(other as StreamAlarmsRequest); } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public bool Equals(QueryActiveAlarmsRequest other) { + public bool Equals(StreamAlarmsRequest other) { if (ReferenceEquals(other, null)) { return false; } if (ReferenceEquals(other, this)) { return true; } - if (SessionId != other.SessionId) return false; if (ClientCorrelationId != other.ClientCorrelationId) return false; if (AlarmFilterPrefix != other.AlarmFilterPrefix) return false; return Equals(_unknownFields, other._unknownFields); @@ -21845,7 +26214,6 @@ namespace MxGateway.Contracts.Proto { [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public override int GetHashCode() { int hash = 1; - if (SessionId.Length != 0) hash ^= SessionId.GetHashCode(); if (ClientCorrelationId.Length != 0) hash ^= ClientCorrelationId.GetHashCode(); if (AlarmFilterPrefix.Length != 0) hash ^= AlarmFilterPrefix.GetHashCode(); if (_unknownFields != null) { @@ -21866,16 +26234,12 @@ namespace MxGateway.Contracts.Proto { #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE output.WriteRawMessage(this); #else - if (SessionId.Length != 0) { - output.WriteRawTag(10); - output.WriteString(SessionId); - } if (ClientCorrelationId.Length != 0) { - output.WriteRawTag(18); + output.WriteRawTag(10); output.WriteString(ClientCorrelationId); } if (AlarmFilterPrefix.Length != 0) { - output.WriteRawTag(26); + output.WriteRawTag(18); output.WriteString(AlarmFilterPrefix); } if (_unknownFields != null) { @@ -21888,16 +26252,12 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { - if (SessionId.Length != 0) { - output.WriteRawTag(10); - output.WriteString(SessionId); - } if (ClientCorrelationId.Length != 0) { - output.WriteRawTag(18); + output.WriteRawTag(10); output.WriteString(ClientCorrelationId); } if (AlarmFilterPrefix.Length != 0) { - output.WriteRawTag(26); + output.WriteRawTag(18); output.WriteString(AlarmFilterPrefix); } if (_unknownFields != null) { @@ -21910,9 +26270,6 @@ namespace MxGateway.Contracts.Proto { [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public int CalculateSize() { int size = 0; - if (SessionId.Length != 0) { - size += 1 + pb::CodedOutputStream.ComputeStringSize(SessionId); - } if (ClientCorrelationId.Length != 0) { size += 1 + pb::CodedOutputStream.ComputeStringSize(ClientCorrelationId); } @@ -21927,13 +26284,10 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public void MergeFrom(QueryActiveAlarmsRequest other) { + public void MergeFrom(StreamAlarmsRequest other) { if (other == null) { return; } - if (other.SessionId.Length != 0) { - SessionId = other.SessionId; - } if (other.ClientCorrelationId.Length != 0) { ClientCorrelationId = other.ClientCorrelationId; } @@ -21960,14 +26314,10 @@ namespace MxGateway.Contracts.Proto { _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); break; case 10: { - SessionId = input.ReadString(); - break; - } - case 18: { ClientCorrelationId = input.ReadString(); break; } - case 26: { + case 18: { AlarmFilterPrefix = input.ReadString(); break; } @@ -21991,14 +26341,10 @@ namespace MxGateway.Contracts.Proto { _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); break; case 10: { - SessionId = input.ReadString(); - break; - } - case 18: { ClientCorrelationId = input.ReadString(); break; } - case 26: { + case 18: { AlarmFilterPrefix = input.ReadString(); break; } @@ -22009,6 +26355,369 @@ namespace MxGateway.Contracts.Proto { } + /// + /// One message on the StreamAlarms feed. The stream opens with one + /// `active_alarm` per currently-active alarm, then a single + /// `snapshot_complete`, then a `transition` for every subsequent change. + /// + [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] + public sealed partial class AlarmFeedMessage : pb::IMessage + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + , pb::IBufferMessage + #endif + { + private static readonly pb::MessageParser _parser = new pb::MessageParser(() => new AlarmFeedMessage()); + private pb::UnknownFieldSet _unknownFields; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pb::MessageParser Parser { get { return _parser; } } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pbr::MessageDescriptor Descriptor { + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[80]; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + pbr::MessageDescriptor pb::IMessage.Descriptor { + get { return Descriptor; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public AlarmFeedMessage() { + OnConstruction(); + } + + partial void OnConstruction(); + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public AlarmFeedMessage(AlarmFeedMessage other) : this() { + switch (other.PayloadCase) { + case PayloadOneofCase.ActiveAlarm: + ActiveAlarm = other.ActiveAlarm.Clone(); + break; + case PayloadOneofCase.SnapshotComplete: + SnapshotComplete = other.SnapshotComplete; + break; + case PayloadOneofCase.Transition: + Transition = other.Transition.Clone(); + break; + } + + _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public AlarmFeedMessage Clone() { + return new AlarmFeedMessage(this); + } + + /// Field number for the "active_alarm" field. + public const int ActiveAlarmFieldNumber = 1; + /// + /// Part of the initial active-alarm snapshot (ConditionRefresh). + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.ActiveAlarmSnapshot ActiveAlarm { + get { return payloadCase_ == PayloadOneofCase.ActiveAlarm ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.ActiveAlarmSnapshot) payload_ : null; } + set { + payload_ = value; + payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.ActiveAlarm; + } + } + + /// Field number for the "snapshot_complete" field. + public const int SnapshotCompleteFieldNumber = 2; + /// + /// Sentinel: the initial snapshot is fully delivered and `transition` + /// messages follow. Always true when present. + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool SnapshotComplete { + get { return HasSnapshotComplete ? (bool) payload_ : false; } + set { + payload_ = value; + payloadCase_ = PayloadOneofCase.SnapshotComplete; + } + } + /// Gets whether the "snapshot_complete" field is set + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool HasSnapshotComplete { + get { return payloadCase_ == PayloadOneofCase.SnapshotComplete; } + } + /// Clears the value of the oneof if it's currently set to "snapshot_complete" + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void ClearSnapshotComplete() { + if (HasSnapshotComplete) { + ClearPayload(); + } + } + + /// Field number for the "transition" field. + public const int TransitionFieldNumber = 3; + /// + /// A live alarm state change (raise / acknowledge / clear). + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnAlarmTransitionEvent Transition { + get { return payloadCase_ == PayloadOneofCase.Transition ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnAlarmTransitionEvent) payload_ : null; } + set { + payload_ = value; + payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.Transition; + } + } + + private object payload_; + /// Enum of possible cases for the "payload" oneof. + public enum PayloadOneofCase { + None = 0, + ActiveAlarm = 1, + SnapshotComplete = 2, + Transition = 3, + } + private PayloadOneofCase payloadCase_ = PayloadOneofCase.None; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public PayloadOneofCase PayloadCase { + get { return payloadCase_; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void ClearPayload() { + payloadCase_ = PayloadOneofCase.None; + payload_ = null; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override bool Equals(object other) { + return Equals(other as AlarmFeedMessage); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool Equals(AlarmFeedMessage other) { + if (ReferenceEquals(other, null)) { + return false; + } + if (ReferenceEquals(other, this)) { + return true; + } + if (!object.Equals(ActiveAlarm, other.ActiveAlarm)) return false; + if (SnapshotComplete != other.SnapshotComplete) return false; + if (!object.Equals(Transition, other.Transition)) return false; + if (PayloadCase != other.PayloadCase) return false; + return Equals(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override int GetHashCode() { + int hash = 1; + if (payloadCase_ == PayloadOneofCase.ActiveAlarm) hash ^= ActiveAlarm.GetHashCode(); + if (HasSnapshotComplete) hash ^= SnapshotComplete.GetHashCode(); + if (payloadCase_ == PayloadOneofCase.Transition) hash ^= Transition.GetHashCode(); + hash ^= (int) payloadCase_; + if (_unknownFields != null) { + hash ^= _unknownFields.GetHashCode(); + } + return hash; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override string ToString() { + return pb::JsonFormatter.ToDiagnosticString(this); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void WriteTo(pb::CodedOutputStream output) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + output.WriteRawMessage(this); + #else + if (payloadCase_ == PayloadOneofCase.ActiveAlarm) { + output.WriteRawTag(10); + output.WriteMessage(ActiveAlarm); + } + if (HasSnapshotComplete) { + output.WriteRawTag(16); + output.WriteBool(SnapshotComplete); + } + if (payloadCase_ == PayloadOneofCase.Transition) { + output.WriteRawTag(26); + output.WriteMessage(Transition); + } + if (_unknownFields != null) { + _unknownFields.WriteTo(output); + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { + if (payloadCase_ == PayloadOneofCase.ActiveAlarm) { + output.WriteRawTag(10); + output.WriteMessage(ActiveAlarm); + } + if (HasSnapshotComplete) { + output.WriteRawTag(16); + output.WriteBool(SnapshotComplete); + } + if (payloadCase_ == PayloadOneofCase.Transition) { + output.WriteRawTag(26); + output.WriteMessage(Transition); + } + if (_unknownFields != null) { + _unknownFields.WriteTo(ref output); + } + } + #endif + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int CalculateSize() { + int size = 0; + if (payloadCase_ == PayloadOneofCase.ActiveAlarm) { + size += 1 + pb::CodedOutputStream.ComputeMessageSize(ActiveAlarm); + } + if (HasSnapshotComplete) { + size += 1 + 1; + } + if (payloadCase_ == PayloadOneofCase.Transition) { + size += 1 + pb::CodedOutputStream.ComputeMessageSize(Transition); + } + if (_unknownFields != null) { + size += _unknownFields.CalculateSize(); + } + return size; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(AlarmFeedMessage other) { + if (other == null) { + return; + } + switch (other.PayloadCase) { + case PayloadOneofCase.ActiveAlarm: + if (ActiveAlarm == null) { + ActiveAlarm = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.ActiveAlarmSnapshot(); + } + ActiveAlarm.MergeFrom(other.ActiveAlarm); + break; + case PayloadOneofCase.SnapshotComplete: + SnapshotComplete = other.SnapshotComplete; + break; + case PayloadOneofCase.Transition: + if (Transition == null) { + Transition = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnAlarmTransitionEvent(); + } + Transition.MergeFrom(other.Transition); + break; + } + + _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(pb::CodedInputStream input) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + input.ReadRawMessage(this); + #else + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); + break; + case 10: { + global::ZB.MOM.WW.MxGateway.Contracts.Proto.ActiveAlarmSnapshot subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.ActiveAlarmSnapshot(); + if (payloadCase_ == PayloadOneofCase.ActiveAlarm) { + subBuilder.MergeFrom(ActiveAlarm); + } + input.ReadMessage(subBuilder); + ActiveAlarm = subBuilder; + break; + } + case 16: { + SnapshotComplete = input.ReadBool(); + break; + } + case 26: { + global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnAlarmTransitionEvent subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnAlarmTransitionEvent(); + if (payloadCase_ == PayloadOneofCase.Transition) { + subBuilder.MergeFrom(Transition); + } + input.ReadMessage(subBuilder); + Transition = subBuilder; + break; + } + } + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalMergeFrom(ref pb::ParseContext input) { + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); + break; + case 10: { + global::ZB.MOM.WW.MxGateway.Contracts.Proto.ActiveAlarmSnapshot subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.ActiveAlarmSnapshot(); + if (payloadCase_ == PayloadOneofCase.ActiveAlarm) { + subBuilder.MergeFrom(ActiveAlarm); + } + input.ReadMessage(subBuilder); + ActiveAlarm = subBuilder; + break; + } + case 16: { + SnapshotComplete = input.ReadBool(); + break; + } + case 26: { + global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnAlarmTransitionEvent subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnAlarmTransitionEvent(); + if (payloadCase_ == PayloadOneofCase.Transition) { + subBuilder.MergeFrom(Transition); + } + input.ReadMessage(subBuilder); + Transition = subBuilder; + break; + } + } + } + } + #endif + + } + [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] public sealed partial class MxStatusProxy : pb::IMessage #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE @@ -22024,7 +26733,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[67]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[81]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -22063,6 +26772,17 @@ namespace MxGateway.Contracts.Proto { /// Field number for the "success" field. public const int SuccessFieldNumber = 1; private int success_; + /// + /// Mirrors the `success` member of the MXAccess MXSTATUS_PROXY struct + /// (a 16-bit signed value in the COM struct, widened to int32 on the + /// wire). Despite the name it is NOT a boolean — it is the raw numeric + /// indicator the worker reads off the COM struct without reinterpretation. + /// It is carried verbatim for diagnostics; the authoritative success/ + /// failure of the operation is `category` (MX_STATUS_CATEGORY_OK marks + /// success), with `detail`, `diagnostic_text`, `raw_category`, and + /// `raw_detected_by` describing any non-OK outcome. Clients should branch + /// on `category`, not on a specific `success` value. + /// [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public int Success { @@ -22074,10 +26794,10 @@ namespace MxGateway.Contracts.Proto { /// Field number for the "category" field. public const int CategoryFieldNumber = 2; - private global::MxGateway.Contracts.Proto.MxStatusCategory category_ = global::MxGateway.Contracts.Proto.MxStatusCategory.Unspecified; + private global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusCategory category_ = global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusCategory.Unspecified; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.MxStatusCategory Category { + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusCategory Category { get { return category_; } set { category_ = value; @@ -22086,10 +26806,10 @@ namespace MxGateway.Contracts.Proto { /// Field number for the "detected_by" field. public const int DetectedByFieldNumber = 3; - private global::MxGateway.Contracts.Proto.MxStatusSource detectedBy_ = global::MxGateway.Contracts.Proto.MxStatusSource.Unspecified; + private global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusSource detectedBy_ = global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusSource.Unspecified; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.MxStatusSource DetectedBy { + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusSource DetectedBy { get { return detectedBy_; } set { detectedBy_ = value; @@ -22174,8 +26894,8 @@ namespace MxGateway.Contracts.Proto { public override int GetHashCode() { int hash = 1; if (Success != 0) hash ^= Success.GetHashCode(); - if (Category != global::MxGateway.Contracts.Proto.MxStatusCategory.Unspecified) hash ^= Category.GetHashCode(); - if (DetectedBy != global::MxGateway.Contracts.Proto.MxStatusSource.Unspecified) hash ^= DetectedBy.GetHashCode(); + if (Category != global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusCategory.Unspecified) hash ^= Category.GetHashCode(); + if (DetectedBy != global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusSource.Unspecified) hash ^= DetectedBy.GetHashCode(); if (Detail != 0) hash ^= Detail.GetHashCode(); if (RawCategory != 0) hash ^= RawCategory.GetHashCode(); if (RawDetectedBy != 0) hash ^= RawDetectedBy.GetHashCode(); @@ -22202,11 +26922,11 @@ namespace MxGateway.Contracts.Proto { output.WriteRawTag(8); output.WriteInt32(Success); } - if (Category != global::MxGateway.Contracts.Proto.MxStatusCategory.Unspecified) { + if (Category != global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusCategory.Unspecified) { output.WriteRawTag(16); output.WriteEnum((int) Category); } - if (DetectedBy != global::MxGateway.Contracts.Proto.MxStatusSource.Unspecified) { + if (DetectedBy != global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusSource.Unspecified) { output.WriteRawTag(24); output.WriteEnum((int) DetectedBy); } @@ -22240,11 +26960,11 @@ namespace MxGateway.Contracts.Proto { output.WriteRawTag(8); output.WriteInt32(Success); } - if (Category != global::MxGateway.Contracts.Proto.MxStatusCategory.Unspecified) { + if (Category != global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusCategory.Unspecified) { output.WriteRawTag(16); output.WriteEnum((int) Category); } - if (DetectedBy != global::MxGateway.Contracts.Proto.MxStatusSource.Unspecified) { + if (DetectedBy != global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusSource.Unspecified) { output.WriteRawTag(24); output.WriteEnum((int) DetectedBy); } @@ -22277,10 +26997,10 @@ namespace MxGateway.Contracts.Proto { if (Success != 0) { size += 1 + pb::CodedOutputStream.ComputeInt32Size(Success); } - if (Category != global::MxGateway.Contracts.Proto.MxStatusCategory.Unspecified) { + if (Category != global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusCategory.Unspecified) { size += 1 + pb::CodedOutputStream.ComputeEnumSize((int) Category); } - if (DetectedBy != global::MxGateway.Contracts.Proto.MxStatusSource.Unspecified) { + if (DetectedBy != global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusSource.Unspecified) { size += 1 + pb::CodedOutputStream.ComputeEnumSize((int) DetectedBy); } if (Detail != 0) { @@ -22310,10 +27030,10 @@ namespace MxGateway.Contracts.Proto { if (other.Success != 0) { Success = other.Success; } - if (other.Category != global::MxGateway.Contracts.Proto.MxStatusCategory.Unspecified) { + if (other.Category != global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusCategory.Unspecified) { Category = other.Category; } - if (other.DetectedBy != global::MxGateway.Contracts.Proto.MxStatusSource.Unspecified) { + if (other.DetectedBy != global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusSource.Unspecified) { DetectedBy = other.DetectedBy; } if (other.Detail != 0) { @@ -22352,11 +27072,11 @@ namespace MxGateway.Contracts.Proto { break; } case 16: { - Category = (global::MxGateway.Contracts.Proto.MxStatusCategory) input.ReadEnum(); + Category = (global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusCategory) input.ReadEnum(); break; } case 24: { - DetectedBy = (global::MxGateway.Contracts.Proto.MxStatusSource) input.ReadEnum(); + DetectedBy = (global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusSource) input.ReadEnum(); break; } case 32: { @@ -22399,11 +27119,11 @@ namespace MxGateway.Contracts.Proto { break; } case 16: { - Category = (global::MxGateway.Contracts.Proto.MxStatusCategory) input.ReadEnum(); + Category = (global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusCategory) input.ReadEnum(); break; } case 24: { - DetectedBy = (global::MxGateway.Contracts.Proto.MxStatusSource) input.ReadEnum(); + DetectedBy = (global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusSource) input.ReadEnum(); break; } case 32: { @@ -22444,7 +27164,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[68]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[82]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -22510,10 +27230,10 @@ namespace MxGateway.Contracts.Proto { /// Field number for the "data_type" field. public const int DataTypeFieldNumber = 1; - private global::MxGateway.Contracts.Proto.MxDataType dataType_ = global::MxGateway.Contracts.Proto.MxDataType.Unspecified; + private global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxDataType dataType_ = global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxDataType.Unspecified; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.MxDataType DataType { + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxDataType DataType { get { return dataType_; } set { dataType_ = value; @@ -22740,8 +27460,8 @@ namespace MxGateway.Contracts.Proto { public const int ArrayValueFieldNumber = 17; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.MxArray ArrayValue { - get { return kindCase_ == KindOneofCase.ArrayValue ? (global::MxGateway.Contracts.Proto.MxArray) kind_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxArray ArrayValue { + get { return kindCase_ == KindOneofCase.ArrayValue ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxArray) kind_ : null; } set { kind_ = value; kindCase_ = value == null ? KindOneofCase.None : KindOneofCase.ArrayValue; @@ -22839,7 +27559,7 @@ namespace MxGateway.Contracts.Proto { [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public override int GetHashCode() { int hash = 1; - if (DataType != global::MxGateway.Contracts.Proto.MxDataType.Unspecified) hash ^= DataType.GetHashCode(); + if (DataType != global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxDataType.Unspecified) hash ^= DataType.GetHashCode(); if (VariantType.Length != 0) hash ^= VariantType.GetHashCode(); if (IsNull != false) hash ^= IsNull.GetHashCode(); if (RawDiagnostic.Length != 0) hash ^= RawDiagnostic.GetHashCode(); @@ -22872,7 +27592,7 @@ namespace MxGateway.Contracts.Proto { #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE output.WriteRawMessage(this); #else - if (DataType != global::MxGateway.Contracts.Proto.MxDataType.Unspecified) { + if (DataType != global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxDataType.Unspecified) { output.WriteRawTag(8); output.WriteEnum((int) DataType); } @@ -22938,7 +27658,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { - if (DataType != global::MxGateway.Contracts.Proto.MxDataType.Unspecified) { + if (DataType != global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxDataType.Unspecified) { output.WriteRawTag(8); output.WriteEnum((int) DataType); } @@ -23004,7 +27724,7 @@ namespace MxGateway.Contracts.Proto { [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public int CalculateSize() { int size = 0; - if (DataType != global::MxGateway.Contracts.Proto.MxDataType.Unspecified) { + if (DataType != global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxDataType.Unspecified) { size += 1 + pb::CodedOutputStream.ComputeEnumSize((int) DataType); } if (VariantType.Length != 0) { @@ -23058,7 +27778,7 @@ namespace MxGateway.Contracts.Proto { if (other == null) { return; } - if (other.DataType != global::MxGateway.Contracts.Proto.MxDataType.Unspecified) { + if (other.DataType != global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxDataType.Unspecified) { DataType = other.DataType; } if (other.VariantType.Length != 0) { @@ -23100,7 +27820,7 @@ namespace MxGateway.Contracts.Proto { break; case KindOneofCase.ArrayValue: if (ArrayValue == null) { - ArrayValue = new global::MxGateway.Contracts.Proto.MxArray(); + ArrayValue = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxArray(); } ArrayValue.MergeFrom(other.ArrayValue); break; @@ -23129,7 +27849,7 @@ namespace MxGateway.Contracts.Proto { _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); break; case 8: { - DataType = (global::MxGateway.Contracts.Proto.MxDataType) input.ReadEnum(); + DataType = (global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxDataType) input.ReadEnum(); break; } case 18: { @@ -23182,7 +27902,7 @@ namespace MxGateway.Contracts.Proto { break; } case 138: { - global::MxGateway.Contracts.Proto.MxArray subBuilder = new global::MxGateway.Contracts.Proto.MxArray(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxArray subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxArray(); if (kindCase_ == KindOneofCase.ArrayValue) { subBuilder.MergeFrom(ArrayValue); } @@ -23214,7 +27934,7 @@ namespace MxGateway.Contracts.Proto { _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); break; case 8: { - DataType = (global::MxGateway.Contracts.Proto.MxDataType) input.ReadEnum(); + DataType = (global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxDataType) input.ReadEnum(); break; } case 18: { @@ -23267,7 +27987,7 @@ namespace MxGateway.Contracts.Proto { break; } case 138: { - global::MxGateway.Contracts.Proto.MxArray subBuilder = new global::MxGateway.Contracts.Proto.MxArray(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxArray subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxArray(); if (kindCase_ == KindOneofCase.ArrayValue) { subBuilder.MergeFrom(ArrayValue); } @@ -23301,7 +28021,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[69]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[83]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -23364,10 +28084,10 @@ namespace MxGateway.Contracts.Proto { /// Field number for the "element_data_type" field. public const int ElementDataTypeFieldNumber = 1; - private global::MxGateway.Contracts.Proto.MxDataType elementDataType_ = global::MxGateway.Contracts.Proto.MxDataType.Unspecified; + private global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxDataType elementDataType_ = global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxDataType.Unspecified; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.MxDataType ElementDataType { + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxDataType ElementDataType { get { return elementDataType_; } set { elementDataType_ = value; @@ -23425,8 +28145,8 @@ namespace MxGateway.Contracts.Proto { public const int BoolValuesFieldNumber = 10; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.BoolArray BoolValues { - get { return valuesCase_ == ValuesOneofCase.BoolValues ? (global::MxGateway.Contracts.Proto.BoolArray) values_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.BoolArray BoolValues { + get { return valuesCase_ == ValuesOneofCase.BoolValues ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.BoolArray) values_ : null; } set { values_ = value; valuesCase_ = value == null ? ValuesOneofCase.None : ValuesOneofCase.BoolValues; @@ -23437,8 +28157,8 @@ namespace MxGateway.Contracts.Proto { public const int Int32ValuesFieldNumber = 11; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.Int32Array Int32Values { - get { return valuesCase_ == ValuesOneofCase.Int32Values ? (global::MxGateway.Contracts.Proto.Int32Array) values_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.Int32Array Int32Values { + get { return valuesCase_ == ValuesOneofCase.Int32Values ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.Int32Array) values_ : null; } set { values_ = value; valuesCase_ = value == null ? ValuesOneofCase.None : ValuesOneofCase.Int32Values; @@ -23449,8 +28169,8 @@ namespace MxGateway.Contracts.Proto { public const int Int64ValuesFieldNumber = 12; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.Int64Array Int64Values { - get { return valuesCase_ == ValuesOneofCase.Int64Values ? (global::MxGateway.Contracts.Proto.Int64Array) values_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.Int64Array Int64Values { + get { return valuesCase_ == ValuesOneofCase.Int64Values ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.Int64Array) values_ : null; } set { values_ = value; valuesCase_ = value == null ? ValuesOneofCase.None : ValuesOneofCase.Int64Values; @@ -23461,8 +28181,8 @@ namespace MxGateway.Contracts.Proto { public const int FloatValuesFieldNumber = 13; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.FloatArray FloatValues { - get { return valuesCase_ == ValuesOneofCase.FloatValues ? (global::MxGateway.Contracts.Proto.FloatArray) values_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.FloatArray FloatValues { + get { return valuesCase_ == ValuesOneofCase.FloatValues ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.FloatArray) values_ : null; } set { values_ = value; valuesCase_ = value == null ? ValuesOneofCase.None : ValuesOneofCase.FloatValues; @@ -23473,8 +28193,8 @@ namespace MxGateway.Contracts.Proto { public const int DoubleValuesFieldNumber = 14; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.DoubleArray DoubleValues { - get { return valuesCase_ == ValuesOneofCase.DoubleValues ? (global::MxGateway.Contracts.Proto.DoubleArray) values_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.DoubleArray DoubleValues { + get { return valuesCase_ == ValuesOneofCase.DoubleValues ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.DoubleArray) values_ : null; } set { values_ = value; valuesCase_ = value == null ? ValuesOneofCase.None : ValuesOneofCase.DoubleValues; @@ -23485,8 +28205,8 @@ namespace MxGateway.Contracts.Proto { public const int StringValuesFieldNumber = 15; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.StringArray StringValues { - get { return valuesCase_ == ValuesOneofCase.StringValues ? (global::MxGateway.Contracts.Proto.StringArray) values_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.StringArray StringValues { + get { return valuesCase_ == ValuesOneofCase.StringValues ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.StringArray) values_ : null; } set { values_ = value; valuesCase_ = value == null ? ValuesOneofCase.None : ValuesOneofCase.StringValues; @@ -23497,8 +28217,8 @@ namespace MxGateway.Contracts.Proto { public const int TimestampValuesFieldNumber = 16; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.TimestampArray TimestampValues { - get { return valuesCase_ == ValuesOneofCase.TimestampValues ? (global::MxGateway.Contracts.Proto.TimestampArray) values_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.TimestampArray TimestampValues { + get { return valuesCase_ == ValuesOneofCase.TimestampValues ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.TimestampArray) values_ : null; } set { values_ = value; valuesCase_ = value == null ? ValuesOneofCase.None : ValuesOneofCase.TimestampValues; @@ -23509,8 +28229,8 @@ namespace MxGateway.Contracts.Proto { public const int RawValuesFieldNumber = 17; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.RawArray RawValues { - get { return valuesCase_ == ValuesOneofCase.RawValues ? (global::MxGateway.Contracts.Proto.RawArray) values_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.RawArray RawValues { + get { return valuesCase_ == ValuesOneofCase.RawValues ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.RawArray) values_ : null; } set { values_ = value; valuesCase_ = value == null ? ValuesOneofCase.None : ValuesOneofCase.RawValues; @@ -23580,7 +28300,7 @@ namespace MxGateway.Contracts.Proto { [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public override int GetHashCode() { int hash = 1; - if (ElementDataType != global::MxGateway.Contracts.Proto.MxDataType.Unspecified) hash ^= ElementDataType.GetHashCode(); + if (ElementDataType != global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxDataType.Unspecified) hash ^= ElementDataType.GetHashCode(); if (VariantType.Length != 0) hash ^= VariantType.GetHashCode(); hash ^= dimensions_.GetHashCode(); if (RawDiagnostic.Length != 0) hash ^= RawDiagnostic.GetHashCode(); @@ -23612,7 +28332,7 @@ namespace MxGateway.Contracts.Proto { #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE output.WriteRawMessage(this); #else - if (ElementDataType != global::MxGateway.Contracts.Proto.MxDataType.Unspecified) { + if (ElementDataType != global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxDataType.Unspecified) { output.WriteRawTag(8); output.WriteEnum((int) ElementDataType); } @@ -23671,7 +28391,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { - if (ElementDataType != global::MxGateway.Contracts.Proto.MxDataType.Unspecified) { + if (ElementDataType != global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxDataType.Unspecified) { output.WriteRawTag(8); output.WriteEnum((int) ElementDataType); } @@ -23730,7 +28450,7 @@ namespace MxGateway.Contracts.Proto { [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public int CalculateSize() { int size = 0; - if (ElementDataType != global::MxGateway.Contracts.Proto.MxDataType.Unspecified) { + if (ElementDataType != global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxDataType.Unspecified) { size += 1 + pb::CodedOutputStream.ComputeEnumSize((int) ElementDataType); } if (VariantType.Length != 0) { @@ -23779,7 +28499,7 @@ namespace MxGateway.Contracts.Proto { if (other == null) { return; } - if (other.ElementDataType != global::MxGateway.Contracts.Proto.MxDataType.Unspecified) { + if (other.ElementDataType != global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxDataType.Unspecified) { ElementDataType = other.ElementDataType; } if (other.VariantType.Length != 0) { @@ -23795,49 +28515,49 @@ namespace MxGateway.Contracts.Proto { switch (other.ValuesCase) { case ValuesOneofCase.BoolValues: if (BoolValues == null) { - BoolValues = new global::MxGateway.Contracts.Proto.BoolArray(); + BoolValues = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.BoolArray(); } BoolValues.MergeFrom(other.BoolValues); break; case ValuesOneofCase.Int32Values: if (Int32Values == null) { - Int32Values = new global::MxGateway.Contracts.Proto.Int32Array(); + Int32Values = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.Int32Array(); } Int32Values.MergeFrom(other.Int32Values); break; case ValuesOneofCase.Int64Values: if (Int64Values == null) { - Int64Values = new global::MxGateway.Contracts.Proto.Int64Array(); + Int64Values = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.Int64Array(); } Int64Values.MergeFrom(other.Int64Values); break; case ValuesOneofCase.FloatValues: if (FloatValues == null) { - FloatValues = new global::MxGateway.Contracts.Proto.FloatArray(); + FloatValues = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.FloatArray(); } FloatValues.MergeFrom(other.FloatValues); break; case ValuesOneofCase.DoubleValues: if (DoubleValues == null) { - DoubleValues = new global::MxGateway.Contracts.Proto.DoubleArray(); + DoubleValues = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.DoubleArray(); } DoubleValues.MergeFrom(other.DoubleValues); break; case ValuesOneofCase.StringValues: if (StringValues == null) { - StringValues = new global::MxGateway.Contracts.Proto.StringArray(); + StringValues = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.StringArray(); } StringValues.MergeFrom(other.StringValues); break; case ValuesOneofCase.TimestampValues: if (TimestampValues == null) { - TimestampValues = new global::MxGateway.Contracts.Proto.TimestampArray(); + TimestampValues = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.TimestampArray(); } TimestampValues.MergeFrom(other.TimestampValues); break; case ValuesOneofCase.RawValues: if (RawValues == null) { - RawValues = new global::MxGateway.Contracts.Proto.RawArray(); + RawValues = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.RawArray(); } RawValues.MergeFrom(other.RawValues); break; @@ -23863,7 +28583,7 @@ namespace MxGateway.Contracts.Proto { _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); break; case 8: { - ElementDataType = (global::MxGateway.Contracts.Proto.MxDataType) input.ReadEnum(); + ElementDataType = (global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxDataType) input.ReadEnum(); break; } case 18: { @@ -23884,7 +28604,7 @@ namespace MxGateway.Contracts.Proto { break; } case 82: { - global::MxGateway.Contracts.Proto.BoolArray subBuilder = new global::MxGateway.Contracts.Proto.BoolArray(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.BoolArray subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.BoolArray(); if (valuesCase_ == ValuesOneofCase.BoolValues) { subBuilder.MergeFrom(BoolValues); } @@ -23893,7 +28613,7 @@ namespace MxGateway.Contracts.Proto { break; } case 90: { - global::MxGateway.Contracts.Proto.Int32Array subBuilder = new global::MxGateway.Contracts.Proto.Int32Array(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.Int32Array subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.Int32Array(); if (valuesCase_ == ValuesOneofCase.Int32Values) { subBuilder.MergeFrom(Int32Values); } @@ -23902,7 +28622,7 @@ namespace MxGateway.Contracts.Proto { break; } case 98: { - global::MxGateway.Contracts.Proto.Int64Array subBuilder = new global::MxGateway.Contracts.Proto.Int64Array(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.Int64Array subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.Int64Array(); if (valuesCase_ == ValuesOneofCase.Int64Values) { subBuilder.MergeFrom(Int64Values); } @@ -23911,7 +28631,7 @@ namespace MxGateway.Contracts.Proto { break; } case 106: { - global::MxGateway.Contracts.Proto.FloatArray subBuilder = new global::MxGateway.Contracts.Proto.FloatArray(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.FloatArray subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.FloatArray(); if (valuesCase_ == ValuesOneofCase.FloatValues) { subBuilder.MergeFrom(FloatValues); } @@ -23920,7 +28640,7 @@ namespace MxGateway.Contracts.Proto { break; } case 114: { - global::MxGateway.Contracts.Proto.DoubleArray subBuilder = new global::MxGateway.Contracts.Proto.DoubleArray(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.DoubleArray subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.DoubleArray(); if (valuesCase_ == ValuesOneofCase.DoubleValues) { subBuilder.MergeFrom(DoubleValues); } @@ -23929,7 +28649,7 @@ namespace MxGateway.Contracts.Proto { break; } case 122: { - global::MxGateway.Contracts.Proto.StringArray subBuilder = new global::MxGateway.Contracts.Proto.StringArray(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.StringArray subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.StringArray(); if (valuesCase_ == ValuesOneofCase.StringValues) { subBuilder.MergeFrom(StringValues); } @@ -23938,7 +28658,7 @@ namespace MxGateway.Contracts.Proto { break; } case 130: { - global::MxGateway.Contracts.Proto.TimestampArray subBuilder = new global::MxGateway.Contracts.Proto.TimestampArray(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.TimestampArray subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.TimestampArray(); if (valuesCase_ == ValuesOneofCase.TimestampValues) { subBuilder.MergeFrom(TimestampValues); } @@ -23947,7 +28667,7 @@ namespace MxGateway.Contracts.Proto { break; } case 138: { - global::MxGateway.Contracts.Proto.RawArray subBuilder = new global::MxGateway.Contracts.Proto.RawArray(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.RawArray subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.RawArray(); if (valuesCase_ == ValuesOneofCase.RawValues) { subBuilder.MergeFrom(RawValues); } @@ -23975,7 +28695,7 @@ namespace MxGateway.Contracts.Proto { _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); break; case 8: { - ElementDataType = (global::MxGateway.Contracts.Proto.MxDataType) input.ReadEnum(); + ElementDataType = (global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxDataType) input.ReadEnum(); break; } case 18: { @@ -23996,7 +28716,7 @@ namespace MxGateway.Contracts.Proto { break; } case 82: { - global::MxGateway.Contracts.Proto.BoolArray subBuilder = new global::MxGateway.Contracts.Proto.BoolArray(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.BoolArray subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.BoolArray(); if (valuesCase_ == ValuesOneofCase.BoolValues) { subBuilder.MergeFrom(BoolValues); } @@ -24005,7 +28725,7 @@ namespace MxGateway.Contracts.Proto { break; } case 90: { - global::MxGateway.Contracts.Proto.Int32Array subBuilder = new global::MxGateway.Contracts.Proto.Int32Array(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.Int32Array subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.Int32Array(); if (valuesCase_ == ValuesOneofCase.Int32Values) { subBuilder.MergeFrom(Int32Values); } @@ -24014,7 +28734,7 @@ namespace MxGateway.Contracts.Proto { break; } case 98: { - global::MxGateway.Contracts.Proto.Int64Array subBuilder = new global::MxGateway.Contracts.Proto.Int64Array(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.Int64Array subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.Int64Array(); if (valuesCase_ == ValuesOneofCase.Int64Values) { subBuilder.MergeFrom(Int64Values); } @@ -24023,7 +28743,7 @@ namespace MxGateway.Contracts.Proto { break; } case 106: { - global::MxGateway.Contracts.Proto.FloatArray subBuilder = new global::MxGateway.Contracts.Proto.FloatArray(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.FloatArray subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.FloatArray(); if (valuesCase_ == ValuesOneofCase.FloatValues) { subBuilder.MergeFrom(FloatValues); } @@ -24032,7 +28752,7 @@ namespace MxGateway.Contracts.Proto { break; } case 114: { - global::MxGateway.Contracts.Proto.DoubleArray subBuilder = new global::MxGateway.Contracts.Proto.DoubleArray(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.DoubleArray subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.DoubleArray(); if (valuesCase_ == ValuesOneofCase.DoubleValues) { subBuilder.MergeFrom(DoubleValues); } @@ -24041,7 +28761,7 @@ namespace MxGateway.Contracts.Proto { break; } case 122: { - global::MxGateway.Contracts.Proto.StringArray subBuilder = new global::MxGateway.Contracts.Proto.StringArray(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.StringArray subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.StringArray(); if (valuesCase_ == ValuesOneofCase.StringValues) { subBuilder.MergeFrom(StringValues); } @@ -24050,7 +28770,7 @@ namespace MxGateway.Contracts.Proto { break; } case 130: { - global::MxGateway.Contracts.Proto.TimestampArray subBuilder = new global::MxGateway.Contracts.Proto.TimestampArray(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.TimestampArray subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.TimestampArray(); if (valuesCase_ == ValuesOneofCase.TimestampValues) { subBuilder.MergeFrom(TimestampValues); } @@ -24059,7 +28779,7 @@ namespace MxGateway.Contracts.Proto { break; } case 138: { - global::MxGateway.Contracts.Proto.RawArray subBuilder = new global::MxGateway.Contracts.Proto.RawArray(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.RawArray subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.RawArray(); if (valuesCase_ == ValuesOneofCase.RawValues) { subBuilder.MergeFrom(RawValues); } @@ -24089,7 +28809,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[70]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[84]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -24278,7 +28998,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[71]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[85]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -24467,7 +29187,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[72]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[86]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -24656,7 +29376,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[73]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[87]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -24845,7 +29565,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[74]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[88]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -25034,7 +29754,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[75]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[89]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -25221,7 +29941,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[76]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[90]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -25408,7 +30128,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[77]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[91]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -25595,7 +30315,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[78]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[92]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -25628,10 +30348,10 @@ namespace MxGateway.Contracts.Proto { /// Field number for the "code" field. public const int CodeFieldNumber = 1; - private global::MxGateway.Contracts.Proto.ProtocolStatusCode code_ = global::MxGateway.Contracts.Proto.ProtocolStatusCode.Unspecified; + private global::ZB.MOM.WW.MxGateway.Contracts.Proto.ProtocolStatusCode code_ = global::ZB.MOM.WW.MxGateway.Contracts.Proto.ProtocolStatusCode.Unspecified; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.ProtocolStatusCode Code { + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.ProtocolStatusCode Code { get { return code_; } set { code_ = value; @@ -25674,7 +30394,7 @@ namespace MxGateway.Contracts.Proto { [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public override int GetHashCode() { int hash = 1; - if (Code != global::MxGateway.Contracts.Proto.ProtocolStatusCode.Unspecified) hash ^= Code.GetHashCode(); + if (Code != global::ZB.MOM.WW.MxGateway.Contracts.Proto.ProtocolStatusCode.Unspecified) hash ^= Code.GetHashCode(); if (Message.Length != 0) hash ^= Message.GetHashCode(); if (_unknownFields != null) { hash ^= _unknownFields.GetHashCode(); @@ -25694,7 +30414,7 @@ namespace MxGateway.Contracts.Proto { #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE output.WriteRawMessage(this); #else - if (Code != global::MxGateway.Contracts.Proto.ProtocolStatusCode.Unspecified) { + if (Code != global::ZB.MOM.WW.MxGateway.Contracts.Proto.ProtocolStatusCode.Unspecified) { output.WriteRawTag(8); output.WriteEnum((int) Code); } @@ -25712,7 +30432,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { - if (Code != global::MxGateway.Contracts.Proto.ProtocolStatusCode.Unspecified) { + if (Code != global::ZB.MOM.WW.MxGateway.Contracts.Proto.ProtocolStatusCode.Unspecified) { output.WriteRawTag(8); output.WriteEnum((int) Code); } @@ -25730,7 +30450,7 @@ namespace MxGateway.Contracts.Proto { [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public int CalculateSize() { int size = 0; - if (Code != global::MxGateway.Contracts.Proto.ProtocolStatusCode.Unspecified) { + if (Code != global::ZB.MOM.WW.MxGateway.Contracts.Proto.ProtocolStatusCode.Unspecified) { size += 1 + pb::CodedOutputStream.ComputeEnumSize((int) Code); } if (Message.Length != 0) { @@ -25748,7 +30468,7 @@ namespace MxGateway.Contracts.Proto { if (other == null) { return; } - if (other.Code != global::MxGateway.Contracts.Proto.ProtocolStatusCode.Unspecified) { + if (other.Code != global::ZB.MOM.WW.MxGateway.Contracts.Proto.ProtocolStatusCode.Unspecified) { Code = other.Code; } if (other.Message.Length != 0) { @@ -25774,7 +30494,7 @@ namespace MxGateway.Contracts.Proto { _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); break; case 8: { - Code = (global::MxGateway.Contracts.Proto.ProtocolStatusCode) input.ReadEnum(); + Code = (global::ZB.MOM.WW.MxGateway.Contracts.Proto.ProtocolStatusCode) input.ReadEnum(); break; } case 18: { @@ -25801,7 +30521,7 @@ namespace MxGateway.Contracts.Proto { _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); break; case 8: { - Code = (global::MxGateway.Contracts.Proto.ProtocolStatusCode) input.ReadEnum(); + Code = (global::ZB.MOM.WW.MxGateway.Contracts.Proto.ProtocolStatusCode) input.ReadEnum(); break; } case 18: { diff --git a/src/ZB.MOM.WW.MxGateway.Contracts/Generated/MxaccessGatewayGrpc.cs b/src/ZB.MOM.WW.MxGateway.Contracts/Generated/MxaccessGatewayGrpc.cs new file mode 100644 index 0000000..d279939 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Contracts/Generated/MxaccessGatewayGrpc.cs @@ -0,0 +1,371 @@ +// +// Generated by the protocol buffer compiler. DO NOT EDIT! +// source: mxaccess_gateway.proto +// +#pragma warning disable 0414, 1591, 8981, 0612 +#region Designer generated code + +using grpc = global::Grpc.Core; + +namespace ZB.MOM.WW.MxGateway.Contracts.Proto { + /// + /// Public client API for MXAccess sessions hosted by the gateway. + /// + public static partial class MxAccessGateway + { + static readonly string __ServiceName = "mxaccess_gateway.v1.MxAccessGateway"; + + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + static void __Helper_SerializeMessage(global::Google.Protobuf.IMessage message, grpc::SerializationContext context) + { + #if !GRPC_DISABLE_PROTOBUF_BUFFER_SERIALIZATION + if (message is global::Google.Protobuf.IBufferMessage) + { + context.SetPayloadLength(message.CalculateSize()); + global::Google.Protobuf.MessageExtensions.WriteTo(message, context.GetBufferWriter()); + context.Complete(); + return; + } + #endif + context.Complete(global::Google.Protobuf.MessageExtensions.ToByteArray(message)); + } + + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + static class __Helper_MessageCache + { + public static readonly bool IsBufferMessage = global::System.Reflection.IntrospectionExtensions.GetTypeInfo(typeof(global::Google.Protobuf.IBufferMessage)).IsAssignableFrom(typeof(T)); + } + + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + static T __Helper_DeserializeMessage(grpc::DeserializationContext context, global::Google.Protobuf.MessageParser parser) where T : global::Google.Protobuf.IMessage + { + #if !GRPC_DISABLE_PROTOBUF_BUFFER_SERIALIZATION + if (__Helper_MessageCache.IsBufferMessage) + { + return parser.ParseFrom(context.PayloadAsReadOnlySequence()); + } + #endif + return parser.ParseFrom(context.PayloadAsNewBuffer()); + } + + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + static readonly grpc::Marshaller __Marshaller_mxaccess_gateway_v1_OpenSessionRequest = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ZB.MOM.WW.MxGateway.Contracts.Proto.OpenSessionRequest.Parser)); + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + static readonly grpc::Marshaller __Marshaller_mxaccess_gateway_v1_OpenSessionReply = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ZB.MOM.WW.MxGateway.Contracts.Proto.OpenSessionReply.Parser)); + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + static readonly grpc::Marshaller __Marshaller_mxaccess_gateway_v1_CloseSessionRequest = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ZB.MOM.WW.MxGateway.Contracts.Proto.CloseSessionRequest.Parser)); + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + static readonly grpc::Marshaller __Marshaller_mxaccess_gateway_v1_CloseSessionReply = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ZB.MOM.WW.MxGateway.Contracts.Proto.CloseSessionReply.Parser)); + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + static readonly grpc::Marshaller __Marshaller_mxaccess_gateway_v1_MxCommandRequest = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxCommandRequest.Parser)); + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + static readonly grpc::Marshaller __Marshaller_mxaccess_gateway_v1_MxCommandReply = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxCommandReply.Parser)); + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + static readonly grpc::Marshaller __Marshaller_mxaccess_gateway_v1_StreamEventsRequest = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ZB.MOM.WW.MxGateway.Contracts.Proto.StreamEventsRequest.Parser)); + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + static readonly grpc::Marshaller __Marshaller_mxaccess_gateway_v1_MxEvent = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxEvent.Parser)); + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + static readonly grpc::Marshaller __Marshaller_mxaccess_gateway_v1_AcknowledgeAlarmRequest = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmRequest.Parser)); + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + static readonly grpc::Marshaller __Marshaller_mxaccess_gateway_v1_AcknowledgeAlarmReply = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmReply.Parser)); + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + static readonly grpc::Marshaller __Marshaller_mxaccess_gateway_v1_StreamAlarmsRequest = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ZB.MOM.WW.MxGateway.Contracts.Proto.StreamAlarmsRequest.Parser)); + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + static readonly grpc::Marshaller __Marshaller_mxaccess_gateway_v1_AlarmFeedMessage = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmFeedMessage.Parser)); + + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + static readonly grpc::Method __Method_OpenSession = new grpc::Method( + grpc::MethodType.Unary, + __ServiceName, + "OpenSession", + __Marshaller_mxaccess_gateway_v1_OpenSessionRequest, + __Marshaller_mxaccess_gateway_v1_OpenSessionReply); + + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + static readonly grpc::Method __Method_CloseSession = new grpc::Method( + grpc::MethodType.Unary, + __ServiceName, + "CloseSession", + __Marshaller_mxaccess_gateway_v1_CloseSessionRequest, + __Marshaller_mxaccess_gateway_v1_CloseSessionReply); + + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + static readonly grpc::Method __Method_Invoke = new grpc::Method( + grpc::MethodType.Unary, + __ServiceName, + "Invoke", + __Marshaller_mxaccess_gateway_v1_MxCommandRequest, + __Marshaller_mxaccess_gateway_v1_MxCommandReply); + + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + static readonly grpc::Method __Method_StreamEvents = new grpc::Method( + grpc::MethodType.ServerStreaming, + __ServiceName, + "StreamEvents", + __Marshaller_mxaccess_gateway_v1_StreamEventsRequest, + __Marshaller_mxaccess_gateway_v1_MxEvent); + + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + static readonly grpc::Method __Method_AcknowledgeAlarm = new grpc::Method( + grpc::MethodType.Unary, + __ServiceName, + "AcknowledgeAlarm", + __Marshaller_mxaccess_gateway_v1_AcknowledgeAlarmRequest, + __Marshaller_mxaccess_gateway_v1_AcknowledgeAlarmReply); + + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + static readonly grpc::Method __Method_StreamAlarms = new grpc::Method( + grpc::MethodType.ServerStreaming, + __ServiceName, + "StreamAlarms", + __Marshaller_mxaccess_gateway_v1_StreamAlarmsRequest, + __Marshaller_mxaccess_gateway_v1_AlarmFeedMessage); + + /// Service descriptor + public static global::Google.Protobuf.Reflection.ServiceDescriptor Descriptor + { + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.Services[0]; } + } + + /// Base class for server-side implementations of MxAccessGateway + [grpc::BindServiceMethod(typeof(MxAccessGateway), "BindService")] + public abstract partial class MxAccessGatewayBase + { + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual global::System.Threading.Tasks.Task OpenSession(global::ZB.MOM.WW.MxGateway.Contracts.Proto.OpenSessionRequest request, grpc::ServerCallContext context) + { + throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, "")); + } + + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual global::System.Threading.Tasks.Task CloseSession(global::ZB.MOM.WW.MxGateway.Contracts.Proto.CloseSessionRequest request, grpc::ServerCallContext context) + { + throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, "")); + } + + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual global::System.Threading.Tasks.Task Invoke(global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxCommandRequest request, grpc::ServerCallContext context) + { + throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, "")); + } + + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual global::System.Threading.Tasks.Task StreamEvents(global::ZB.MOM.WW.MxGateway.Contracts.Proto.StreamEventsRequest request, grpc::IServerStreamWriter responseStream, grpc::ServerCallContext context) + { + throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, "")); + } + + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual global::System.Threading.Tasks.Task AcknowledgeAlarm(global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmRequest request, grpc::ServerCallContext context) + { + throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, "")); + } + + /// + /// Session-less central alarm feed. The stream opens with the current + /// active-alarm snapshot (one `active_alarm` per alarm), then a single + /// `snapshot_complete`, then a `transition` for every subsequent change. + /// Served by the gateway's always-on alarm monitor; any number of clients + /// fan out from the single monitor without opening a worker session. + /// + /// The request received from the client. + /// Used for sending responses back to the client. + /// The context of the server-side call handler being invoked. + /// A task indicating completion of the handler. + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual global::System.Threading.Tasks.Task StreamAlarms(global::ZB.MOM.WW.MxGateway.Contracts.Proto.StreamAlarmsRequest request, grpc::IServerStreamWriter responseStream, grpc::ServerCallContext context) + { + throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, "")); + } + + } + + /// Client for MxAccessGateway + public partial class MxAccessGatewayClient : grpc::ClientBase + { + /// Creates a new client for MxAccessGateway + /// The channel to use to make remote calls. + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public MxAccessGatewayClient(grpc::ChannelBase channel) : base(channel) + { + } + /// Creates a new client for MxAccessGateway that uses a custom CallInvoker. + /// The callInvoker to use to make remote calls. + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public MxAccessGatewayClient(grpc::CallInvoker callInvoker) : base(callInvoker) + { + } + /// Protected parameterless constructor to allow creation of test doubles. + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + protected MxAccessGatewayClient() : base() + { + } + /// Protected constructor to allow creation of configured clients. + /// The client configuration. + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + protected MxAccessGatewayClient(ClientBaseConfiguration configuration) : base(configuration) + { + } + + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual global::ZB.MOM.WW.MxGateway.Contracts.Proto.OpenSessionReply OpenSession(global::ZB.MOM.WW.MxGateway.Contracts.Proto.OpenSessionRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) + { + return OpenSession(request, new grpc::CallOptions(headers, deadline, cancellationToken)); + } + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual global::ZB.MOM.WW.MxGateway.Contracts.Proto.OpenSessionReply OpenSession(global::ZB.MOM.WW.MxGateway.Contracts.Proto.OpenSessionRequest request, grpc::CallOptions options) + { + return CallInvoker.BlockingUnaryCall(__Method_OpenSession, null, options, request); + } + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual grpc::AsyncUnaryCall OpenSessionAsync(global::ZB.MOM.WW.MxGateway.Contracts.Proto.OpenSessionRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) + { + return OpenSessionAsync(request, new grpc::CallOptions(headers, deadline, cancellationToken)); + } + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual grpc::AsyncUnaryCall OpenSessionAsync(global::ZB.MOM.WW.MxGateway.Contracts.Proto.OpenSessionRequest request, grpc::CallOptions options) + { + return CallInvoker.AsyncUnaryCall(__Method_OpenSession, null, options, request); + } + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual global::ZB.MOM.WW.MxGateway.Contracts.Proto.CloseSessionReply CloseSession(global::ZB.MOM.WW.MxGateway.Contracts.Proto.CloseSessionRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) + { + return CloseSession(request, new grpc::CallOptions(headers, deadline, cancellationToken)); + } + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual global::ZB.MOM.WW.MxGateway.Contracts.Proto.CloseSessionReply CloseSession(global::ZB.MOM.WW.MxGateway.Contracts.Proto.CloseSessionRequest request, grpc::CallOptions options) + { + return CallInvoker.BlockingUnaryCall(__Method_CloseSession, null, options, request); + } + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual grpc::AsyncUnaryCall CloseSessionAsync(global::ZB.MOM.WW.MxGateway.Contracts.Proto.CloseSessionRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) + { + return CloseSessionAsync(request, new grpc::CallOptions(headers, deadline, cancellationToken)); + } + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual grpc::AsyncUnaryCall CloseSessionAsync(global::ZB.MOM.WW.MxGateway.Contracts.Proto.CloseSessionRequest request, grpc::CallOptions options) + { + return CallInvoker.AsyncUnaryCall(__Method_CloseSession, null, options, request); + } + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxCommandReply Invoke(global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxCommandRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) + { + return Invoke(request, new grpc::CallOptions(headers, deadline, cancellationToken)); + } + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxCommandReply Invoke(global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxCommandRequest request, grpc::CallOptions options) + { + return CallInvoker.BlockingUnaryCall(__Method_Invoke, null, options, request); + } + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual grpc::AsyncUnaryCall InvokeAsync(global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxCommandRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) + { + return InvokeAsync(request, new grpc::CallOptions(headers, deadline, cancellationToken)); + } + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual grpc::AsyncUnaryCall InvokeAsync(global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxCommandRequest request, grpc::CallOptions options) + { + return CallInvoker.AsyncUnaryCall(__Method_Invoke, null, options, request); + } + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual grpc::AsyncServerStreamingCall StreamEvents(global::ZB.MOM.WW.MxGateway.Contracts.Proto.StreamEventsRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) + { + return StreamEvents(request, new grpc::CallOptions(headers, deadline, cancellationToken)); + } + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual grpc::AsyncServerStreamingCall StreamEvents(global::ZB.MOM.WW.MxGateway.Contracts.Proto.StreamEventsRequest request, grpc::CallOptions options) + { + return CallInvoker.AsyncServerStreamingCall(__Method_StreamEvents, null, options, request); + } + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmReply AcknowledgeAlarm(global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) + { + return AcknowledgeAlarm(request, new grpc::CallOptions(headers, deadline, cancellationToken)); + } + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmReply AcknowledgeAlarm(global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmRequest request, grpc::CallOptions options) + { + return CallInvoker.BlockingUnaryCall(__Method_AcknowledgeAlarm, null, options, request); + } + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual grpc::AsyncUnaryCall AcknowledgeAlarmAsync(global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) + { + return AcknowledgeAlarmAsync(request, new grpc::CallOptions(headers, deadline, cancellationToken)); + } + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual grpc::AsyncUnaryCall AcknowledgeAlarmAsync(global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmRequest request, grpc::CallOptions options) + { + return CallInvoker.AsyncUnaryCall(__Method_AcknowledgeAlarm, null, options, request); + } + /// + /// Session-less central alarm feed. The stream opens with the current + /// active-alarm snapshot (one `active_alarm` per alarm), then a single + /// `snapshot_complete`, then a `transition` for every subsequent change. + /// Served by the gateway's always-on alarm monitor; any number of clients + /// fan out from the single monitor without opening a worker session. + /// + /// The request to send to the server. + /// The initial metadata to send with the call. This parameter is optional. + /// An optional deadline for the call. The call will be cancelled if deadline is hit. + /// An optional token for canceling the call. + /// The call object. + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual grpc::AsyncServerStreamingCall StreamAlarms(global::ZB.MOM.WW.MxGateway.Contracts.Proto.StreamAlarmsRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) + { + return StreamAlarms(request, new grpc::CallOptions(headers, deadline, cancellationToken)); + } + /// + /// Session-less central alarm feed. The stream opens with the current + /// active-alarm snapshot (one `active_alarm` per alarm), then a single + /// `snapshot_complete`, then a `transition` for every subsequent change. + /// Served by the gateway's always-on alarm monitor; any number of clients + /// fan out from the single monitor without opening a worker session. + /// + /// The request to send to the server. + /// The options for the call. + /// The call object. + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual grpc::AsyncServerStreamingCall StreamAlarms(global::ZB.MOM.WW.MxGateway.Contracts.Proto.StreamAlarmsRequest request, grpc::CallOptions options) + { + return CallInvoker.AsyncServerStreamingCall(__Method_StreamAlarms, null, options, request); + } + /// Creates a new instance of client from given ClientBaseConfiguration. + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + protected override MxAccessGatewayClient NewInstance(ClientBaseConfiguration configuration) + { + return new MxAccessGatewayClient(configuration); + } + } + + /// Creates service definition that can be registered with a server + /// An object implementing the server-side handling logic. + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public static grpc::ServerServiceDefinition BindService(MxAccessGatewayBase serviceImpl) + { + return grpc::ServerServiceDefinition.CreateBuilder() + .AddMethod(__Method_OpenSession, serviceImpl.OpenSession) + .AddMethod(__Method_CloseSession, serviceImpl.CloseSession) + .AddMethod(__Method_Invoke, serviceImpl.Invoke) + .AddMethod(__Method_StreamEvents, serviceImpl.StreamEvents) + .AddMethod(__Method_AcknowledgeAlarm, serviceImpl.AcknowledgeAlarm) + .AddMethod(__Method_StreamAlarms, serviceImpl.StreamAlarms).Build(); + } + + /// Register service method with a service binder with or without implementation. Useful when customizing the service binding logic. + /// Note: this method is part of an experimental API that can change or be removed without any prior notice. + /// Service methods will be bound by calling AddMethod on this object. + /// An object implementing the server-side handling logic. + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public static void BindService(grpc::ServiceBinderBase serviceBinder, MxAccessGatewayBase serviceImpl) + { + serviceBinder.AddMethod(__Method_OpenSession, serviceImpl == null ? null : new grpc::UnaryServerMethod(serviceImpl.OpenSession)); + serviceBinder.AddMethod(__Method_CloseSession, serviceImpl == null ? null : new grpc::UnaryServerMethod(serviceImpl.CloseSession)); + serviceBinder.AddMethod(__Method_Invoke, serviceImpl == null ? null : new grpc::UnaryServerMethod(serviceImpl.Invoke)); + serviceBinder.AddMethod(__Method_StreamEvents, serviceImpl == null ? null : new grpc::ServerStreamingServerMethod(serviceImpl.StreamEvents)); + serviceBinder.AddMethod(__Method_AcknowledgeAlarm, serviceImpl == null ? null : new grpc::UnaryServerMethod(serviceImpl.AcknowledgeAlarm)); + serviceBinder.AddMethod(__Method_StreamAlarms, serviceImpl == null ? null : new grpc::ServerStreamingServerMethod(serviceImpl.StreamAlarms)); + } + + } +} +#endregion diff --git a/src/MxGateway.Contracts/Generated/MxaccessWorker.cs b/src/ZB.MOM.WW.MxGateway.Contracts/Generated/MxaccessWorker.cs similarity index 90% rename from src/MxGateway.Contracts/Generated/MxaccessWorker.cs rename to src/ZB.MOM.WW.MxGateway.Contracts/Generated/MxaccessWorker.cs index 010e4eb..268af42 100644 --- a/src/MxGateway.Contracts/Generated/MxaccessWorker.cs +++ b/src/ZB.MOM.WW.MxGateway.Contracts/Generated/MxaccessWorker.cs @@ -9,7 +9,7 @@ using pb = global::Google.Protobuf; using pbc = global::Google.Protobuf.Collections; using pbr = global::Google.Protobuf.Reflection; using scg = global::System.Collections.Generic; -namespace MxGateway.Contracts.Proto { +namespace ZB.MOM.WW.MxGateway.Contracts.Proto { /// Holder for reflection information generated from mxaccess_worker.proto public static partial class MxaccessWorkerReflection { @@ -94,23 +94,23 @@ namespace MxGateway.Contracts.Proto { "X0NBVEVHT1JZX01YQUNDRVNTX0VWRU5UX0NPTlZFUlNJT05fRkFJTEVEEAgS", "IgoeV09SS0VSX0ZBVUxUX0NBVEVHT1JZX1NUQV9IVU5HEAkSKAokV09SS0VS", "X0ZBVUxUX0NBVEVHT1JZX1FVRVVFX09WRVJGTE9XEAoSKgomV09SS0VSX0ZB", - "VUxUX0NBVEVHT1JZX1NIVVRET1dOX1RJTUVPVVQQC0IcqgIZTXhHYXRld2F5", - "LkNvbnRyYWN0cy5Qcm90b2IGcHJvdG8z")); + "VUxUX0NBVEVHT1JZX1NIVVRET1dOX1RJTUVPVVQQC0ImqgIjWkIuTU9NLldX", + "Lk14R2F0ZXdheS5Db250cmFjdHMuUHJvdG9iBnByb3RvMw==")); descriptor = pbr::FileDescriptor.FromGeneratedCode(descriptorData, - new pbr::FileDescriptor[] { global::Google.Protobuf.WellKnownTypes.DurationReflection.Descriptor, global::Google.Protobuf.WellKnownTypes.TimestampReflection.Descriptor, global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor, }, - new pbr::GeneratedClrTypeInfo(new[] {typeof(global::MxGateway.Contracts.Proto.WorkerState), typeof(global::MxGateway.Contracts.Proto.WorkerFaultCategory), }, null, new pbr::GeneratedClrTypeInfo[] { - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.WorkerEnvelope), global::MxGateway.Contracts.Proto.WorkerEnvelope.Parser, new[]{ "ProtocolVersion", "SessionId", "Sequence", "CorrelationId", "GatewayHello", "WorkerHello", "WorkerReady", "WorkerCommand", "WorkerCommandReply", "WorkerCancel", "WorkerShutdown", "WorkerShutdownAck", "WorkerEvent", "WorkerHeartbeat", "WorkerFault" }, new[]{ "Body" }, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.GatewayHello), global::MxGateway.Contracts.Proto.GatewayHello.Parser, new[]{ "SupportedProtocolVersion", "Nonce", "GatewayVersion" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.WorkerHello), global::MxGateway.Contracts.Proto.WorkerHello.Parser, new[]{ "ProtocolVersion", "Nonce", "WorkerProcessId", "WorkerVersion" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.WorkerReady), global::MxGateway.Contracts.Proto.WorkerReady.Parser, new[]{ "WorkerProcessId", "MxaccessProgid", "MxaccessClsid", "ReadyTimestamp" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.WorkerCommand), global::MxGateway.Contracts.Proto.WorkerCommand.Parser, new[]{ "Command", "EnqueueTimestamp" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.WorkerCommandReply), global::MxGateway.Contracts.Proto.WorkerCommandReply.Parser, new[]{ "Reply", "CompletedTimestamp" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.WorkerCancel), global::MxGateway.Contracts.Proto.WorkerCancel.Parser, new[]{ "Reason" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.WorkerShutdown), global::MxGateway.Contracts.Proto.WorkerShutdown.Parser, new[]{ "GracePeriod", "Reason" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.WorkerShutdownAck), global::MxGateway.Contracts.Proto.WorkerShutdownAck.Parser, new[]{ "Status" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.WorkerEvent), global::MxGateway.Contracts.Proto.WorkerEvent.Parser, new[]{ "Event" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.WorkerHeartbeat), global::MxGateway.Contracts.Proto.WorkerHeartbeat.Parser, new[]{ "WorkerProcessId", "State", "LastStaActivityTimestamp", "PendingCommandCount", "OutboundEventQueueDepth", "LastEventSequence", "CurrentCommandCorrelationId" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.WorkerFault), global::MxGateway.Contracts.Proto.WorkerFault.Parser, new[]{ "Category", "CommandMethod", "Hresult", "ExceptionType", "DiagnosticMessage", "ProtocolStatus" }, new[]{ "Hresult" }, null, null, null) + new pbr::FileDescriptor[] { global::Google.Protobuf.WellKnownTypes.DurationReflection.Descriptor, global::Google.Protobuf.WellKnownTypes.TimestampReflection.Descriptor, global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor, }, + new pbr::GeneratedClrTypeInfo(new[] {typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerState), typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerFaultCategory), }, null, new pbr::GeneratedClrTypeInfo[] { + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerEnvelope), global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerEnvelope.Parser, new[]{ "ProtocolVersion", "SessionId", "Sequence", "CorrelationId", "GatewayHello", "WorkerHello", "WorkerReady", "WorkerCommand", "WorkerCommandReply", "WorkerCancel", "WorkerShutdown", "WorkerShutdownAck", "WorkerEvent", "WorkerHeartbeat", "WorkerFault" }, new[]{ "Body" }, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.GatewayHello), global::ZB.MOM.WW.MxGateway.Contracts.Proto.GatewayHello.Parser, new[]{ "SupportedProtocolVersion", "Nonce", "GatewayVersion" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerHello), global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerHello.Parser, new[]{ "ProtocolVersion", "Nonce", "WorkerProcessId", "WorkerVersion" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerReady), global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerReady.Parser, new[]{ "WorkerProcessId", "MxaccessProgid", "MxaccessClsid", "ReadyTimestamp" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerCommand), global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerCommand.Parser, new[]{ "Command", "EnqueueTimestamp" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerCommandReply), global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerCommandReply.Parser, new[]{ "Reply", "CompletedTimestamp" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerCancel), global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerCancel.Parser, new[]{ "Reason" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerShutdown), global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerShutdown.Parser, new[]{ "GracePeriod", "Reason" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerShutdownAck), global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerShutdownAck.Parser, new[]{ "Status" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerEvent), global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerEvent.Parser, new[]{ "Event" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerHeartbeat), global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerHeartbeat.Parser, new[]{ "WorkerProcessId", "State", "LastStaActivityTimestamp", "PendingCommandCount", "OutboundEventQueueDepth", "LastEventSequence", "CurrentCommandCorrelationId" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerFault), global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerFault.Parser, new[]{ "Category", "CommandMethod", "Hresult", "ExceptionType", "DiagnosticMessage", "ProtocolStatus" }, new[]{ "Hresult" }, null, null, null) })); } #endregion @@ -166,7 +166,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessWorkerReflection.Descriptor.MessageTypes[0]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessWorkerReflection.Descriptor.MessageTypes[0]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -287,8 +287,8 @@ namespace MxGateway.Contracts.Proto { public const int GatewayHelloFieldNumber = 10; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.GatewayHello GatewayHello { - get { return bodyCase_ == BodyOneofCase.GatewayHello ? (global::MxGateway.Contracts.Proto.GatewayHello) body_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.GatewayHello GatewayHello { + get { return bodyCase_ == BodyOneofCase.GatewayHello ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.GatewayHello) body_ : null; } set { body_ = value; bodyCase_ = value == null ? BodyOneofCase.None : BodyOneofCase.GatewayHello; @@ -299,8 +299,8 @@ namespace MxGateway.Contracts.Proto { public const int WorkerHelloFieldNumber = 11; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.WorkerHello WorkerHello { - get { return bodyCase_ == BodyOneofCase.WorkerHello ? (global::MxGateway.Contracts.Proto.WorkerHello) body_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerHello WorkerHello { + get { return bodyCase_ == BodyOneofCase.WorkerHello ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerHello) body_ : null; } set { body_ = value; bodyCase_ = value == null ? BodyOneofCase.None : BodyOneofCase.WorkerHello; @@ -311,8 +311,8 @@ namespace MxGateway.Contracts.Proto { public const int WorkerReadyFieldNumber = 12; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.WorkerReady WorkerReady { - get { return bodyCase_ == BodyOneofCase.WorkerReady ? (global::MxGateway.Contracts.Proto.WorkerReady) body_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerReady WorkerReady { + get { return bodyCase_ == BodyOneofCase.WorkerReady ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerReady) body_ : null; } set { body_ = value; bodyCase_ = value == null ? BodyOneofCase.None : BodyOneofCase.WorkerReady; @@ -323,8 +323,8 @@ namespace MxGateway.Contracts.Proto { public const int WorkerCommandFieldNumber = 13; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.WorkerCommand WorkerCommand { - get { return bodyCase_ == BodyOneofCase.WorkerCommand ? (global::MxGateway.Contracts.Proto.WorkerCommand) body_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerCommand WorkerCommand { + get { return bodyCase_ == BodyOneofCase.WorkerCommand ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerCommand) body_ : null; } set { body_ = value; bodyCase_ = value == null ? BodyOneofCase.None : BodyOneofCase.WorkerCommand; @@ -335,8 +335,8 @@ namespace MxGateway.Contracts.Proto { public const int WorkerCommandReplyFieldNumber = 14; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.WorkerCommandReply WorkerCommandReply { - get { return bodyCase_ == BodyOneofCase.WorkerCommandReply ? (global::MxGateway.Contracts.Proto.WorkerCommandReply) body_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerCommandReply WorkerCommandReply { + get { return bodyCase_ == BodyOneofCase.WorkerCommandReply ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerCommandReply) body_ : null; } set { body_ = value; bodyCase_ = value == null ? BodyOneofCase.None : BodyOneofCase.WorkerCommandReply; @@ -347,8 +347,8 @@ namespace MxGateway.Contracts.Proto { public const int WorkerCancelFieldNumber = 15; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.WorkerCancel WorkerCancel { - get { return bodyCase_ == BodyOneofCase.WorkerCancel ? (global::MxGateway.Contracts.Proto.WorkerCancel) body_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerCancel WorkerCancel { + get { return bodyCase_ == BodyOneofCase.WorkerCancel ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerCancel) body_ : null; } set { body_ = value; bodyCase_ = value == null ? BodyOneofCase.None : BodyOneofCase.WorkerCancel; @@ -359,8 +359,8 @@ namespace MxGateway.Contracts.Proto { public const int WorkerShutdownFieldNumber = 16; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.WorkerShutdown WorkerShutdown { - get { return bodyCase_ == BodyOneofCase.WorkerShutdown ? (global::MxGateway.Contracts.Proto.WorkerShutdown) body_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerShutdown WorkerShutdown { + get { return bodyCase_ == BodyOneofCase.WorkerShutdown ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerShutdown) body_ : null; } set { body_ = value; bodyCase_ = value == null ? BodyOneofCase.None : BodyOneofCase.WorkerShutdown; @@ -371,8 +371,8 @@ namespace MxGateway.Contracts.Proto { public const int WorkerShutdownAckFieldNumber = 17; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.WorkerShutdownAck WorkerShutdownAck { - get { return bodyCase_ == BodyOneofCase.WorkerShutdownAck ? (global::MxGateway.Contracts.Proto.WorkerShutdownAck) body_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerShutdownAck WorkerShutdownAck { + get { return bodyCase_ == BodyOneofCase.WorkerShutdownAck ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerShutdownAck) body_ : null; } set { body_ = value; bodyCase_ = value == null ? BodyOneofCase.None : BodyOneofCase.WorkerShutdownAck; @@ -383,8 +383,8 @@ namespace MxGateway.Contracts.Proto { public const int WorkerEventFieldNumber = 18; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.WorkerEvent WorkerEvent { - get { return bodyCase_ == BodyOneofCase.WorkerEvent ? (global::MxGateway.Contracts.Proto.WorkerEvent) body_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerEvent WorkerEvent { + get { return bodyCase_ == BodyOneofCase.WorkerEvent ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerEvent) body_ : null; } set { body_ = value; bodyCase_ = value == null ? BodyOneofCase.None : BodyOneofCase.WorkerEvent; @@ -395,8 +395,8 @@ namespace MxGateway.Contracts.Proto { public const int WorkerHeartbeatFieldNumber = 19; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.WorkerHeartbeat WorkerHeartbeat { - get { return bodyCase_ == BodyOneofCase.WorkerHeartbeat ? (global::MxGateway.Contracts.Proto.WorkerHeartbeat) body_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerHeartbeat WorkerHeartbeat { + get { return bodyCase_ == BodyOneofCase.WorkerHeartbeat ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerHeartbeat) body_ : null; } set { body_ = value; bodyCase_ = value == null ? BodyOneofCase.None : BodyOneofCase.WorkerHeartbeat; @@ -407,8 +407,8 @@ namespace MxGateway.Contracts.Proto { public const int WorkerFaultFieldNumber = 20; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.WorkerFault WorkerFault { - get { return bodyCase_ == BodyOneofCase.WorkerFault ? (global::MxGateway.Contracts.Proto.WorkerFault) body_ : null; } + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerFault WorkerFault { + get { return bodyCase_ == BodyOneofCase.WorkerFault ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerFault) body_ : null; } set { body_ = value; bodyCase_ = value == null ? BodyOneofCase.None : BodyOneofCase.WorkerFault; @@ -729,67 +729,67 @@ namespace MxGateway.Contracts.Proto { switch (other.BodyCase) { case BodyOneofCase.GatewayHello: if (GatewayHello == null) { - GatewayHello = new global::MxGateway.Contracts.Proto.GatewayHello(); + GatewayHello = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.GatewayHello(); } GatewayHello.MergeFrom(other.GatewayHello); break; case BodyOneofCase.WorkerHello: if (WorkerHello == null) { - WorkerHello = new global::MxGateway.Contracts.Proto.WorkerHello(); + WorkerHello = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerHello(); } WorkerHello.MergeFrom(other.WorkerHello); break; case BodyOneofCase.WorkerReady: if (WorkerReady == null) { - WorkerReady = new global::MxGateway.Contracts.Proto.WorkerReady(); + WorkerReady = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerReady(); } WorkerReady.MergeFrom(other.WorkerReady); break; case BodyOneofCase.WorkerCommand: if (WorkerCommand == null) { - WorkerCommand = new global::MxGateway.Contracts.Proto.WorkerCommand(); + WorkerCommand = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerCommand(); } WorkerCommand.MergeFrom(other.WorkerCommand); break; case BodyOneofCase.WorkerCommandReply: if (WorkerCommandReply == null) { - WorkerCommandReply = new global::MxGateway.Contracts.Proto.WorkerCommandReply(); + WorkerCommandReply = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerCommandReply(); } WorkerCommandReply.MergeFrom(other.WorkerCommandReply); break; case BodyOneofCase.WorkerCancel: if (WorkerCancel == null) { - WorkerCancel = new global::MxGateway.Contracts.Proto.WorkerCancel(); + WorkerCancel = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerCancel(); } WorkerCancel.MergeFrom(other.WorkerCancel); break; case BodyOneofCase.WorkerShutdown: if (WorkerShutdown == null) { - WorkerShutdown = new global::MxGateway.Contracts.Proto.WorkerShutdown(); + WorkerShutdown = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerShutdown(); } WorkerShutdown.MergeFrom(other.WorkerShutdown); break; case BodyOneofCase.WorkerShutdownAck: if (WorkerShutdownAck == null) { - WorkerShutdownAck = new global::MxGateway.Contracts.Proto.WorkerShutdownAck(); + WorkerShutdownAck = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerShutdownAck(); } WorkerShutdownAck.MergeFrom(other.WorkerShutdownAck); break; case BodyOneofCase.WorkerEvent: if (WorkerEvent == null) { - WorkerEvent = new global::MxGateway.Contracts.Proto.WorkerEvent(); + WorkerEvent = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerEvent(); } WorkerEvent.MergeFrom(other.WorkerEvent); break; case BodyOneofCase.WorkerHeartbeat: if (WorkerHeartbeat == null) { - WorkerHeartbeat = new global::MxGateway.Contracts.Proto.WorkerHeartbeat(); + WorkerHeartbeat = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerHeartbeat(); } WorkerHeartbeat.MergeFrom(other.WorkerHeartbeat); break; case BodyOneofCase.WorkerFault: if (WorkerFault == null) { - WorkerFault = new global::MxGateway.Contracts.Proto.WorkerFault(); + WorkerFault = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerFault(); } WorkerFault.MergeFrom(other.WorkerFault); break; @@ -831,7 +831,7 @@ namespace MxGateway.Contracts.Proto { break; } case 82: { - global::MxGateway.Contracts.Proto.GatewayHello subBuilder = new global::MxGateway.Contracts.Proto.GatewayHello(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.GatewayHello subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.GatewayHello(); if (bodyCase_ == BodyOneofCase.GatewayHello) { subBuilder.MergeFrom(GatewayHello); } @@ -840,7 +840,7 @@ namespace MxGateway.Contracts.Proto { break; } case 90: { - global::MxGateway.Contracts.Proto.WorkerHello subBuilder = new global::MxGateway.Contracts.Proto.WorkerHello(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerHello subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerHello(); if (bodyCase_ == BodyOneofCase.WorkerHello) { subBuilder.MergeFrom(WorkerHello); } @@ -849,7 +849,7 @@ namespace MxGateway.Contracts.Proto { break; } case 98: { - global::MxGateway.Contracts.Proto.WorkerReady subBuilder = new global::MxGateway.Contracts.Proto.WorkerReady(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerReady subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerReady(); if (bodyCase_ == BodyOneofCase.WorkerReady) { subBuilder.MergeFrom(WorkerReady); } @@ -858,7 +858,7 @@ namespace MxGateway.Contracts.Proto { break; } case 106: { - global::MxGateway.Contracts.Proto.WorkerCommand subBuilder = new global::MxGateway.Contracts.Proto.WorkerCommand(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerCommand(); if (bodyCase_ == BodyOneofCase.WorkerCommand) { subBuilder.MergeFrom(WorkerCommand); } @@ -867,7 +867,7 @@ namespace MxGateway.Contracts.Proto { break; } case 114: { - global::MxGateway.Contracts.Proto.WorkerCommandReply subBuilder = new global::MxGateway.Contracts.Proto.WorkerCommandReply(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerCommandReply subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerCommandReply(); if (bodyCase_ == BodyOneofCase.WorkerCommandReply) { subBuilder.MergeFrom(WorkerCommandReply); } @@ -876,7 +876,7 @@ namespace MxGateway.Contracts.Proto { break; } case 122: { - global::MxGateway.Contracts.Proto.WorkerCancel subBuilder = new global::MxGateway.Contracts.Proto.WorkerCancel(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerCancel subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerCancel(); if (bodyCase_ == BodyOneofCase.WorkerCancel) { subBuilder.MergeFrom(WorkerCancel); } @@ -885,7 +885,7 @@ namespace MxGateway.Contracts.Proto { break; } case 130: { - global::MxGateway.Contracts.Proto.WorkerShutdown subBuilder = new global::MxGateway.Contracts.Proto.WorkerShutdown(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerShutdown subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerShutdown(); if (bodyCase_ == BodyOneofCase.WorkerShutdown) { subBuilder.MergeFrom(WorkerShutdown); } @@ -894,7 +894,7 @@ namespace MxGateway.Contracts.Proto { break; } case 138: { - global::MxGateway.Contracts.Proto.WorkerShutdownAck subBuilder = new global::MxGateway.Contracts.Proto.WorkerShutdownAck(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerShutdownAck subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerShutdownAck(); if (bodyCase_ == BodyOneofCase.WorkerShutdownAck) { subBuilder.MergeFrom(WorkerShutdownAck); } @@ -903,7 +903,7 @@ namespace MxGateway.Contracts.Proto { break; } case 146: { - global::MxGateway.Contracts.Proto.WorkerEvent subBuilder = new global::MxGateway.Contracts.Proto.WorkerEvent(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerEvent subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerEvent(); if (bodyCase_ == BodyOneofCase.WorkerEvent) { subBuilder.MergeFrom(WorkerEvent); } @@ -912,7 +912,7 @@ namespace MxGateway.Contracts.Proto { break; } case 154: { - global::MxGateway.Contracts.Proto.WorkerHeartbeat subBuilder = new global::MxGateway.Contracts.Proto.WorkerHeartbeat(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerHeartbeat subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerHeartbeat(); if (bodyCase_ == BodyOneofCase.WorkerHeartbeat) { subBuilder.MergeFrom(WorkerHeartbeat); } @@ -921,7 +921,7 @@ namespace MxGateway.Contracts.Proto { break; } case 162: { - global::MxGateway.Contracts.Proto.WorkerFault subBuilder = new global::MxGateway.Contracts.Proto.WorkerFault(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerFault subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerFault(); if (bodyCase_ == BodyOneofCase.WorkerFault) { subBuilder.MergeFrom(WorkerFault); } @@ -965,7 +965,7 @@ namespace MxGateway.Contracts.Proto { break; } case 82: { - global::MxGateway.Contracts.Proto.GatewayHello subBuilder = new global::MxGateway.Contracts.Proto.GatewayHello(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.GatewayHello subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.GatewayHello(); if (bodyCase_ == BodyOneofCase.GatewayHello) { subBuilder.MergeFrom(GatewayHello); } @@ -974,7 +974,7 @@ namespace MxGateway.Contracts.Proto { break; } case 90: { - global::MxGateway.Contracts.Proto.WorkerHello subBuilder = new global::MxGateway.Contracts.Proto.WorkerHello(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerHello subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerHello(); if (bodyCase_ == BodyOneofCase.WorkerHello) { subBuilder.MergeFrom(WorkerHello); } @@ -983,7 +983,7 @@ namespace MxGateway.Contracts.Proto { break; } case 98: { - global::MxGateway.Contracts.Proto.WorkerReady subBuilder = new global::MxGateway.Contracts.Proto.WorkerReady(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerReady subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerReady(); if (bodyCase_ == BodyOneofCase.WorkerReady) { subBuilder.MergeFrom(WorkerReady); } @@ -992,7 +992,7 @@ namespace MxGateway.Contracts.Proto { break; } case 106: { - global::MxGateway.Contracts.Proto.WorkerCommand subBuilder = new global::MxGateway.Contracts.Proto.WorkerCommand(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerCommand subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerCommand(); if (bodyCase_ == BodyOneofCase.WorkerCommand) { subBuilder.MergeFrom(WorkerCommand); } @@ -1001,7 +1001,7 @@ namespace MxGateway.Contracts.Proto { break; } case 114: { - global::MxGateway.Contracts.Proto.WorkerCommandReply subBuilder = new global::MxGateway.Contracts.Proto.WorkerCommandReply(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerCommandReply subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerCommandReply(); if (bodyCase_ == BodyOneofCase.WorkerCommandReply) { subBuilder.MergeFrom(WorkerCommandReply); } @@ -1010,7 +1010,7 @@ namespace MxGateway.Contracts.Proto { break; } case 122: { - global::MxGateway.Contracts.Proto.WorkerCancel subBuilder = new global::MxGateway.Contracts.Proto.WorkerCancel(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerCancel subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerCancel(); if (bodyCase_ == BodyOneofCase.WorkerCancel) { subBuilder.MergeFrom(WorkerCancel); } @@ -1019,7 +1019,7 @@ namespace MxGateway.Contracts.Proto { break; } case 130: { - global::MxGateway.Contracts.Proto.WorkerShutdown subBuilder = new global::MxGateway.Contracts.Proto.WorkerShutdown(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerShutdown subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerShutdown(); if (bodyCase_ == BodyOneofCase.WorkerShutdown) { subBuilder.MergeFrom(WorkerShutdown); } @@ -1028,7 +1028,7 @@ namespace MxGateway.Contracts.Proto { break; } case 138: { - global::MxGateway.Contracts.Proto.WorkerShutdownAck subBuilder = new global::MxGateway.Contracts.Proto.WorkerShutdownAck(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerShutdownAck subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerShutdownAck(); if (bodyCase_ == BodyOneofCase.WorkerShutdownAck) { subBuilder.MergeFrom(WorkerShutdownAck); } @@ -1037,7 +1037,7 @@ namespace MxGateway.Contracts.Proto { break; } case 146: { - global::MxGateway.Contracts.Proto.WorkerEvent subBuilder = new global::MxGateway.Contracts.Proto.WorkerEvent(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerEvent subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerEvent(); if (bodyCase_ == BodyOneofCase.WorkerEvent) { subBuilder.MergeFrom(WorkerEvent); } @@ -1046,7 +1046,7 @@ namespace MxGateway.Contracts.Proto { break; } case 154: { - global::MxGateway.Contracts.Proto.WorkerHeartbeat subBuilder = new global::MxGateway.Contracts.Proto.WorkerHeartbeat(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerHeartbeat subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerHeartbeat(); if (bodyCase_ == BodyOneofCase.WorkerHeartbeat) { subBuilder.MergeFrom(WorkerHeartbeat); } @@ -1055,7 +1055,7 @@ namespace MxGateway.Contracts.Proto { break; } case 162: { - global::MxGateway.Contracts.Proto.WorkerFault subBuilder = new global::MxGateway.Contracts.Proto.WorkerFault(); + global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerFault subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerFault(); if (bodyCase_ == BodyOneofCase.WorkerFault) { subBuilder.MergeFrom(WorkerFault); } @@ -1085,7 +1085,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessWorkerReflection.Descriptor.MessageTypes[1]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessWorkerReflection.Descriptor.MessageTypes[1]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -1357,7 +1357,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessWorkerReflection.Descriptor.MessageTypes[2]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessWorkerReflection.Descriptor.MessageTypes[2]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -1666,7 +1666,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessWorkerReflection.Descriptor.MessageTypes[3]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessWorkerReflection.Descriptor.MessageTypes[3]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -1984,7 +1984,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessWorkerReflection.Descriptor.MessageTypes[4]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessWorkerReflection.Descriptor.MessageTypes[4]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -2017,10 +2017,10 @@ namespace MxGateway.Contracts.Proto { /// Field number for the "command" field. public const int CommandFieldNumber = 1; - private global::MxGateway.Contracts.Proto.MxCommand command_; + private global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxCommand command_; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.MxCommand Command { + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxCommand Command { get { return command_; } set { command_ = value; @@ -2139,7 +2139,7 @@ namespace MxGateway.Contracts.Proto { } if (other.command_ != null) { if (command_ == null) { - Command = new global::MxGateway.Contracts.Proto.MxCommand(); + Command = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxCommand(); } Command.MergeFrom(other.Command); } @@ -2170,7 +2170,7 @@ namespace MxGateway.Contracts.Proto { break; case 10: { if (command_ == null) { - Command = new global::MxGateway.Contracts.Proto.MxCommand(); + Command = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxCommand(); } input.ReadMessage(Command); break; @@ -2203,7 +2203,7 @@ namespace MxGateway.Contracts.Proto { break; case 10: { if (command_ == null) { - Command = new global::MxGateway.Contracts.Proto.MxCommand(); + Command = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxCommand(); } input.ReadMessage(Command); break; @@ -2237,7 +2237,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessWorkerReflection.Descriptor.MessageTypes[5]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessWorkerReflection.Descriptor.MessageTypes[5]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -2270,10 +2270,10 @@ namespace MxGateway.Contracts.Proto { /// Field number for the "reply" field. public const int ReplyFieldNumber = 1; - private global::MxGateway.Contracts.Proto.MxCommandReply reply_; + private global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxCommandReply reply_; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.MxCommandReply Reply { + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxCommandReply Reply { get { return reply_; } set { reply_ = value; @@ -2392,7 +2392,7 @@ namespace MxGateway.Contracts.Proto { } if (other.reply_ != null) { if (reply_ == null) { - Reply = new global::MxGateway.Contracts.Proto.MxCommandReply(); + Reply = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxCommandReply(); } Reply.MergeFrom(other.Reply); } @@ -2423,7 +2423,7 @@ namespace MxGateway.Contracts.Proto { break; case 10: { if (reply_ == null) { - Reply = new global::MxGateway.Contracts.Proto.MxCommandReply(); + Reply = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxCommandReply(); } input.ReadMessage(Reply); break; @@ -2456,7 +2456,7 @@ namespace MxGateway.Contracts.Proto { break; case 10: { if (reply_ == null) { - Reply = new global::MxGateway.Contracts.Proto.MxCommandReply(); + Reply = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxCommandReply(); } input.ReadMessage(Reply); break; @@ -2490,7 +2490,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessWorkerReflection.Descriptor.MessageTypes[6]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessWorkerReflection.Descriptor.MessageTypes[6]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -2688,7 +2688,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessWorkerReflection.Descriptor.MessageTypes[7]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessWorkerReflection.Descriptor.MessageTypes[7]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -2932,7 +2932,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessWorkerReflection.Descriptor.MessageTypes[8]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessWorkerReflection.Descriptor.MessageTypes[8]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -2964,10 +2964,10 @@ namespace MxGateway.Contracts.Proto { /// Field number for the "status" field. public const int StatusFieldNumber = 1; - private global::MxGateway.Contracts.Proto.ProtocolStatus status_; + private global::ZB.MOM.WW.MxGateway.Contracts.Proto.ProtocolStatus status_; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.ProtocolStatus Status { + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.ProtocolStatus Status { get { return status_; } set { status_ = value; @@ -3061,7 +3061,7 @@ namespace MxGateway.Contracts.Proto { } if (other.status_ != null) { if (status_ == null) { - Status = new global::MxGateway.Contracts.Proto.ProtocolStatus(); + Status = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.ProtocolStatus(); } Status.MergeFrom(other.Status); } @@ -3086,7 +3086,7 @@ namespace MxGateway.Contracts.Proto { break; case 10: { if (status_ == null) { - Status = new global::MxGateway.Contracts.Proto.ProtocolStatus(); + Status = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.ProtocolStatus(); } input.ReadMessage(Status); break; @@ -3112,7 +3112,7 @@ namespace MxGateway.Contracts.Proto { break; case 10: { if (status_ == null) { - Status = new global::MxGateway.Contracts.Proto.ProtocolStatus(); + Status = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.ProtocolStatus(); } input.ReadMessage(Status); break; @@ -3139,7 +3139,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessWorkerReflection.Descriptor.MessageTypes[9]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessWorkerReflection.Descriptor.MessageTypes[9]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -3171,10 +3171,10 @@ namespace MxGateway.Contracts.Proto { /// Field number for the "event" field. public const int EventFieldNumber = 1; - private global::MxGateway.Contracts.Proto.MxEvent event_; + private global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxEvent event_; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.MxEvent Event { + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxEvent Event { get { return event_; } set { event_ = value; @@ -3268,7 +3268,7 @@ namespace MxGateway.Contracts.Proto { } if (other.event_ != null) { if (event_ == null) { - Event = new global::MxGateway.Contracts.Proto.MxEvent(); + Event = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxEvent(); } Event.MergeFrom(other.Event); } @@ -3293,7 +3293,7 @@ namespace MxGateway.Contracts.Proto { break; case 10: { if (event_ == null) { - Event = new global::MxGateway.Contracts.Proto.MxEvent(); + Event = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxEvent(); } input.ReadMessage(Event); break; @@ -3319,7 +3319,7 @@ namespace MxGateway.Contracts.Proto { break; case 10: { if (event_ == null) { - Event = new global::MxGateway.Contracts.Proto.MxEvent(); + Event = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxEvent(); } input.ReadMessage(Event); break; @@ -3346,7 +3346,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessWorkerReflection.Descriptor.MessageTypes[10]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessWorkerReflection.Descriptor.MessageTypes[10]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -3396,10 +3396,10 @@ namespace MxGateway.Contracts.Proto { /// Field number for the "state" field. public const int StateFieldNumber = 2; - private global::MxGateway.Contracts.Proto.WorkerState state_ = global::MxGateway.Contracts.Proto.WorkerState.Unspecified; + private global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerState state_ = global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerState.Unspecified; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.WorkerState State { + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerState State { get { return state_; } set { state_ = value; @@ -3496,7 +3496,7 @@ namespace MxGateway.Contracts.Proto { public override int GetHashCode() { int hash = 1; if (WorkerProcessId != 0) hash ^= WorkerProcessId.GetHashCode(); - if (State != global::MxGateway.Contracts.Proto.WorkerState.Unspecified) hash ^= State.GetHashCode(); + if (State != global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerState.Unspecified) hash ^= State.GetHashCode(); if (lastStaActivityTimestamp_ != null) hash ^= LastStaActivityTimestamp.GetHashCode(); if (PendingCommandCount != 0) hash ^= PendingCommandCount.GetHashCode(); if (OutboundEventQueueDepth != 0) hash ^= OutboundEventQueueDepth.GetHashCode(); @@ -3524,7 +3524,7 @@ namespace MxGateway.Contracts.Proto { output.WriteRawTag(8); output.WriteInt32(WorkerProcessId); } - if (State != global::MxGateway.Contracts.Proto.WorkerState.Unspecified) { + if (State != global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerState.Unspecified) { output.WriteRawTag(16); output.WriteEnum((int) State); } @@ -3562,7 +3562,7 @@ namespace MxGateway.Contracts.Proto { output.WriteRawTag(8); output.WriteInt32(WorkerProcessId); } - if (State != global::MxGateway.Contracts.Proto.WorkerState.Unspecified) { + if (State != global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerState.Unspecified) { output.WriteRawTag(16); output.WriteEnum((int) State); } @@ -3599,7 +3599,7 @@ namespace MxGateway.Contracts.Proto { if (WorkerProcessId != 0) { size += 1 + pb::CodedOutputStream.ComputeInt32Size(WorkerProcessId); } - if (State != global::MxGateway.Contracts.Proto.WorkerState.Unspecified) { + if (State != global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerState.Unspecified) { size += 1 + pb::CodedOutputStream.ComputeEnumSize((int) State); } if (lastStaActivityTimestamp_ != null) { @@ -3632,7 +3632,7 @@ namespace MxGateway.Contracts.Proto { if (other.WorkerProcessId != 0) { WorkerProcessId = other.WorkerProcessId; } - if (other.State != global::MxGateway.Contracts.Proto.WorkerState.Unspecified) { + if (other.State != global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerState.Unspecified) { State = other.State; } if (other.lastStaActivityTimestamp_ != null) { @@ -3677,7 +3677,7 @@ namespace MxGateway.Contracts.Proto { break; } case 16: { - State = (global::MxGateway.Contracts.Proto.WorkerState) input.ReadEnum(); + State = (global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerState) input.ReadEnum(); break; } case 26: { @@ -3727,7 +3727,7 @@ namespace MxGateway.Contracts.Proto { break; } case 16: { - State = (global::MxGateway.Contracts.Proto.WorkerState) input.ReadEnum(); + State = (global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerState) input.ReadEnum(); break; } case 26: { @@ -3776,7 +3776,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessWorkerReflection.Descriptor.MessageTypes[11]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessWorkerReflection.Descriptor.MessageTypes[11]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -3814,10 +3814,10 @@ namespace MxGateway.Contracts.Proto { /// Field number for the "category" field. public const int CategoryFieldNumber = 1; - private global::MxGateway.Contracts.Proto.WorkerFaultCategory category_ = global::MxGateway.Contracts.Proto.WorkerFaultCategory.Unspecified; + private global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerFaultCategory category_ = global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerFaultCategory.Unspecified; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.WorkerFaultCategory Category { + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerFaultCategory Category { get { return category_; } set { category_ = value; @@ -3889,10 +3889,10 @@ namespace MxGateway.Contracts.Proto { /// Field number for the "protocol_status" field. public const int ProtocolStatusFieldNumber = 6; - private global::MxGateway.Contracts.Proto.ProtocolStatus protocolStatus_; + private global::ZB.MOM.WW.MxGateway.Contracts.Proto.ProtocolStatus protocolStatus_; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::MxGateway.Contracts.Proto.ProtocolStatus ProtocolStatus { + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.ProtocolStatus ProtocolStatus { get { return protocolStatus_; } set { protocolStatus_ = value; @@ -3927,7 +3927,7 @@ namespace MxGateway.Contracts.Proto { [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public override int GetHashCode() { int hash = 1; - if (Category != global::MxGateway.Contracts.Proto.WorkerFaultCategory.Unspecified) hash ^= Category.GetHashCode(); + if (Category != global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerFaultCategory.Unspecified) hash ^= Category.GetHashCode(); if (CommandMethod.Length != 0) hash ^= CommandMethod.GetHashCode(); if (HasHresult) hash ^= Hresult.GetHashCode(); if (ExceptionType.Length != 0) hash ^= ExceptionType.GetHashCode(); @@ -3951,7 +3951,7 @@ namespace MxGateway.Contracts.Proto { #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE output.WriteRawMessage(this); #else - if (Category != global::MxGateway.Contracts.Proto.WorkerFaultCategory.Unspecified) { + if (Category != global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerFaultCategory.Unspecified) { output.WriteRawTag(8); output.WriteEnum((int) Category); } @@ -3985,7 +3985,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { - if (Category != global::MxGateway.Contracts.Proto.WorkerFaultCategory.Unspecified) { + if (Category != global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerFaultCategory.Unspecified) { output.WriteRawTag(8); output.WriteEnum((int) Category); } @@ -4019,7 +4019,7 @@ namespace MxGateway.Contracts.Proto { [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public int CalculateSize() { int size = 0; - if (Category != global::MxGateway.Contracts.Proto.WorkerFaultCategory.Unspecified) { + if (Category != global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerFaultCategory.Unspecified) { size += 1 + pb::CodedOutputStream.ComputeEnumSize((int) Category); } if (CommandMethod.Length != 0) { @@ -4049,7 +4049,7 @@ namespace MxGateway.Contracts.Proto { if (other == null) { return; } - if (other.Category != global::MxGateway.Contracts.Proto.WorkerFaultCategory.Unspecified) { + if (other.Category != global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerFaultCategory.Unspecified) { Category = other.Category; } if (other.CommandMethod.Length != 0) { @@ -4066,7 +4066,7 @@ namespace MxGateway.Contracts.Proto { } if (other.protocolStatus_ != null) { if (protocolStatus_ == null) { - ProtocolStatus = new global::MxGateway.Contracts.Proto.ProtocolStatus(); + ProtocolStatus = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.ProtocolStatus(); } ProtocolStatus.MergeFrom(other.ProtocolStatus); } @@ -4090,7 +4090,7 @@ namespace MxGateway.Contracts.Proto { _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); break; case 8: { - Category = (global::MxGateway.Contracts.Proto.WorkerFaultCategory) input.ReadEnum(); + Category = (global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerFaultCategory) input.ReadEnum(); break; } case 18: { @@ -4111,7 +4111,7 @@ namespace MxGateway.Contracts.Proto { } case 50: { if (protocolStatus_ == null) { - ProtocolStatus = new global::MxGateway.Contracts.Proto.ProtocolStatus(); + ProtocolStatus = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.ProtocolStatus(); } input.ReadMessage(ProtocolStatus); break; @@ -4136,7 +4136,7 @@ namespace MxGateway.Contracts.Proto { _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); break; case 8: { - Category = (global::MxGateway.Contracts.Proto.WorkerFaultCategory) input.ReadEnum(); + Category = (global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerFaultCategory) input.ReadEnum(); break; } case 18: { @@ -4157,7 +4157,7 @@ namespace MxGateway.Contracts.Proto { } case 50: { if (protocolStatus_ == null) { - ProtocolStatus = new global::MxGateway.Contracts.Proto.ProtocolStatus(); + ProtocolStatus = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.ProtocolStatus(); } input.ReadMessage(ProtocolStatus); break; diff --git a/src/MxGateway.Contracts/Protos/galaxy_repository.proto b/src/ZB.MOM.WW.MxGateway.Contracts/Protos/galaxy_repository.proto similarity index 75% rename from src/MxGateway.Contracts/Protos/galaxy_repository.proto rename to src/ZB.MOM.WW.MxGateway.Contracts/Protos/galaxy_repository.proto index 3701f04..bf10d35 100644 --- a/src/MxGateway.Contracts/Protos/galaxy_repository.proto +++ b/src/ZB.MOM.WW.MxGateway.Contracts/Protos/galaxy_repository.proto @@ -2,11 +2,18 @@ syntax = "proto3"; package galaxy_repository.v1; -option csharp_namespace = "MxGateway.Contracts.Proto.Galaxy"; +option csharp_namespace = "ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy"; import "google/protobuf/timestamp.proto"; import "google/protobuf/wrappers.proto"; +// Wire-compatibility policy (ProtobufStyleGuide): this contract evolves +// additively only. Never renumber or repurpose an existing field number or +// enum value. When a field or enum value is removed, add a `reserved` range +// (and `reserved` name) covering it in the same change so a future editor +// cannot accidentally reuse the retired tag. There are no `reserved` +// declarations today because no field or enum value has ever been removed. + // Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL // database). Lets clients enumerate the deployed object hierarchy and each // object's dynamic attributes so they know what tag references to subscribe @@ -110,12 +117,26 @@ message GalaxyObject { message GalaxyAttribute { string attribute_name = 1; string full_tag_reference = 2; + // Raw Galaxy SQL `dbo.data_type` identifier, passed through unchanged. + // This is NOT a member of `mxaccess_gateway.v1.MxDataType` — Galaxy's + // type enumeration is distinct from MXAccess's wire data-type enum and + // the two must not be cast or compared. The GalaxyRepository service is + // metadata-only and deliberately does not share types with + // mxaccess_gateway.proto. See docs/GalaxyRepository.md. int32 mx_data_type = 3; + // Human-readable name from Galaxy's `dbo.data_type` table (e.g. "Float", + // "Integer", "Boolean"). Free-form Galaxy text; not a stable enum. string data_type_name = 4; bool is_array = 5; int32 array_dimension = 6; bool array_dimension_present = 7; + // Raw Galaxy SQL attribute-category identifier, passed through unchanged. + // Galaxy-specific; not mapped to any gateway enum. See + // docs/GalaxyRepository.md. int32 mx_attribute_category = 8; + // Raw Galaxy SQL security-classification identifier, passed through + // unchanged. Galaxy-specific; not mapped to any gateway enum. See + // docs/GalaxyRepository.md. int32 security_classification = 9; bool is_historized = 10; bool is_alarm = 11; diff --git a/src/MxGateway.Contracts/Protos/mxaccess_gateway.proto b/src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto similarity index 67% rename from src/MxGateway.Contracts/Protos/mxaccess_gateway.proto rename to src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto index fa71b6b..6358149 100644 --- a/src/MxGateway.Contracts/Protos/mxaccess_gateway.proto +++ b/src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto @@ -2,11 +2,17 @@ syntax = "proto3"; package mxaccess_gateway.v1; -option csharp_namespace = "MxGateway.Contracts.Proto"; +option csharp_namespace = "ZB.MOM.WW.MxGateway.Contracts.Proto"; import "google/protobuf/duration.proto"; import "google/protobuf/timestamp.proto"; +// Wire-compatibility policy (ProtobufStyleGuide): this contract evolves +// additively only. Never renumber or repurpose an existing field number or +// enum value. When a field or enum value is removed, add a `reserved` range +// (and `reserved` name) covering it in the same change so a future editor +// cannot accidentally reuse the retired tag. + // Public client API for MXAccess sessions hosted by the gateway. service MxAccessGateway { rpc OpenSession(OpenSessionRequest) returns (OpenSessionReply); @@ -14,7 +20,12 @@ service MxAccessGateway { rpc Invoke(MxCommandRequest) returns (MxCommandReply); rpc StreamEvents(StreamEventsRequest) returns (stream MxEvent); rpc AcknowledgeAlarm(AcknowledgeAlarmRequest) returns (AcknowledgeAlarmReply); - rpc QueryActiveAlarms(QueryActiveAlarmsRequest) returns (stream ActiveAlarmSnapshot); + // Session-less central alarm feed. The stream opens with the current + // active-alarm snapshot (one `active_alarm` per alarm), then a single + // `snapshot_complete`, then a `transition` for every subsequent change. + // Served by the gateway's always-on alarm monitor; any number of clients + // fan out from the single monitor without opening a worker session. + rpc StreamAlarms(StreamAlarmsRequest) returns (stream AlarmFeedMessage); } message OpenSessionRequest { @@ -93,6 +104,11 @@ message MxCommand { AcknowledgeAlarmCommand acknowledge_alarm_command = 36; QueryActiveAlarmsCommand query_active_alarms_command = 37; AcknowledgeAlarmByNameCommand acknowledge_alarm_by_name_command = 38; + WriteBulkCommand write_bulk = 39; + Write2BulkCommand write2_bulk = 40; + WriteSecuredBulkCommand write_secured_bulk = 41; + WriteSecured2BulkCommand write_secured2_bulk = 42; + ReadBulkCommand read_bulk = 43; PingCommand ping = 100; GetSessionStateCommand get_session_state = 101; GetWorkerInfoCommand get_worker_info = 102; @@ -132,6 +148,11 @@ enum MxCommandKind { MX_COMMAND_KIND_ACKNOWLEDGE_ALARM = 27; MX_COMMAND_KIND_QUERY_ACTIVE_ALARMS = 28; MX_COMMAND_KIND_ACKNOWLEDGE_ALARM_BY_NAME = 29; + MX_COMMAND_KIND_WRITE_BULK = 30; + MX_COMMAND_KIND_WRITE2_BULK = 31; + MX_COMMAND_KIND_WRITE_SECURED_BULK = 32; + MX_COMMAND_KIND_WRITE_SECURED2_BULK = 33; + MX_COMMAND_KIND_READ_BULK = 34; MX_COMMAND_KIND_PING = 100; MX_COMMAND_KIND_GET_SESSION_STATE = 101; MX_COMMAND_KIND_GET_WORKER_INFO = 102; @@ -335,6 +356,88 @@ message UnsubscribeBulkCommand { repeated int32 item_handles = 2; } +// Bulk Write — sequential MXAccess Write per entry, on the worker's STA. +// MXAccess has no native bulk write; each entry round-trips through the same +// single-item Write path the gateway uses today. Per-item failures appear as +// BulkWriteResult entries with `was_successful = false` and never throw. +message WriteBulkCommand { + int32 server_handle = 1; + repeated WriteBulkEntry entries = 2; +} + +message WriteBulkEntry { + int32 item_handle = 1; + MxValue value = 2; + int32 user_id = 3; +} + +// Bulk Write2 — sequential MXAccess Write2 (timestamped) per entry. +message Write2BulkCommand { + int32 server_handle = 1; + repeated Write2BulkEntry entries = 2; +} + +message Write2BulkEntry { + int32 item_handle = 1; + MxValue value = 2; + MxValue timestamp_value = 3; + int32 user_id = 4; +} + +// Bulk WriteSecured — sequential MXAccess WriteSecured per entry. +// Credential-sensitive values (`value`) MUST be kept out of logs, metrics +// labels, command lines, and diagnostics — same redaction rules as the +// single-item WriteSecured contract. +message WriteSecuredBulkCommand { + int32 server_handle = 1; + repeated WriteSecuredBulkEntry entries = 2; +} + +message WriteSecuredBulkEntry { + int32 item_handle = 1; + int32 current_user_id = 2; + int32 verifier_user_id = 3; + // Credential-sensitive write value. Implementations must not log this field + // unless an explicit redacted value-logging path is enabled. + MxValue value = 4; +} + +// Bulk WriteSecured2 — sequential MXAccess WriteSecured2 (timestamped) per +// entry. Same redaction rules apply. +message WriteSecured2BulkCommand { + int32 server_handle = 1; + repeated WriteSecured2BulkEntry entries = 2; +} + +message WriteSecured2BulkEntry { + int32 item_handle = 1; + int32 current_user_id = 2; + int32 verifier_user_id = 3; + // Credential-sensitive write value. Implementations must not log this field + // unless an explicit redacted value-logging path is enabled. + MxValue value = 4; + MxValue timestamp_value = 5; +} + +// Bulk Read — snapshot the current value for each requested tag. MXAccess COM +// has no synchronous Read; the worker implements ReadBulk as: +// +// - If the tag is already in the session's item registry AND that item is +// currently advised AND the worker has a cached OnDataChange for it, the +// reply returns the cached value WITHOUT modifying the existing +// subscription (was_cached = true). +// - Otherwise the worker takes the snapshot lifecycle itself: AddItem + +// Advise, wait up to `timeout_ms` for the first OnDataChange, then +// UnAdvise + RemoveItem before returning. The session is left exactly +// as it was before the call (was_cached = false). +// +// `timeout_ms == 0` uses the gateway-configured default (1000 ms). +message ReadBulkCommand { + int32 server_handle = 1; + repeated string tag_addresses = 2; + uint32 timeout_ms = 3; +} + message PingCommand { string message = 1; } @@ -381,8 +484,22 @@ message MxCommandReply { BulkSubscribeReply un_advise_item_bulk = 31; BulkSubscribeReply subscribe_bulk = 32; BulkSubscribeReply unsubscribe_bulk = 33; + // Reply payload for BOTH MX_COMMAND_KIND_ACKNOWLEDGE_ALARM (by GUID) + // and MX_COMMAND_KIND_ACKNOWLEDGE_ALARM_BY_NAME. There is intentionally + // no by-name-specific reply case: the by-name ack carries no outcome + // detail beyond the native ack return code, so the worker reuses this + // `acknowledge_alarm` payload for both command kinds (the worker's + // MxAccessCommandExecutor sets `acknowledge_alarm` for the by-name arm + // too). Consumers must dispatch on MxCommandReply.kind, not on the + // payload case, to tell the two acks apart. The top-level `hresult` + // mirrors AcknowledgeAlarmReplyPayload.native_status and is preferred. AcknowledgeAlarmReplyPayload acknowledge_alarm = 34; QueryActiveAlarmsReplyPayload query_active_alarms = 35; + BulkWriteReply write_bulk = 36; + BulkWriteReply write2_bulk = 37; + BulkWriteReply write_secured_bulk = 38; + BulkWriteReply write_secured2_bulk = 39; + BulkReadReply read_bulk = 40; SessionStateReply session_state = 100; WorkerInfoReply worker_info = 101; DrainEventsReply drain_events = 102; @@ -433,6 +550,61 @@ message BulkSubscribeReply { repeated SubscribeResult results = 1; } +// Per-item result for the four bulk write families. `item_handle` mirrors the +// request entry's item_handle so callers can correlate inputs to outputs even +// when the gateway's per-entry `IConstraintEnforcer.CheckWriteHandleAsync` +// filter (see `MxAccessGatewayService.ReplaceWriteBulkEntries` and +// `docs/Authorization.md`) dropped some entries before reaching the worker. +// Per-item failures populate `error_message` + `hresult` and never raise — +// callers iterate and inspect each entry. +message BulkWriteResult { + int32 server_handle = 1; + int32 item_handle = 2; + bool was_successful = 3; + optional int32 hresult = 4; + repeated MxStatusProxy statuses = 5; + string error_message = 6; +} + +message BulkWriteReply { + repeated BulkWriteResult results = 1; +} + +// Per-tag result for ReadBulk. `was_cached` is true when the value came from +// an existing live subscription's last OnDataChange (the worker did not touch +// the subscription); false when the worker took the AddItem + Advise + wait + +// UnAdvise + RemoveItem snapshot lifecycle itself. +// +// On `was_successful = true`, `value`, `quality`, `source_timestamp`, and +// `statuses` carry the read data (from the cached subscription or the snapshot +// lifecycle, depending on `was_cached`) and `error_message` is empty. On +// `was_successful = false`, only `server_handle`, `tag_address`, `item_handle` +// (when allocated), `was_cached`, and `error_message` are populated; `value`, +// `quality`, `source_timestamp`, and `statuses` are left at their proto3 +// defaults (null / 0 / null / empty) and must not be read as data — they are +// wire-indistinguishable from "value is null with quality bad" data and serve +// only as absent markers. ReadBulk has no `hresult` field by design (its +// outcomes are timeout / cache / lifecycle states, not MXAccess COM return +// codes — see `docs/DesignDecisions.md` "Bulk Command Family"). Per-tag +// failures populate `error_message` and never raise — callers iterate and +// inspect each entry. +message BulkReadResult { + int32 server_handle = 1; + string tag_address = 2; + int32 item_handle = 3; + bool was_successful = 4; + bool was_cached = 5; + MxValue value = 6; + int32 quality = 7; + google.protobuf.Timestamp source_timestamp = 8; + repeated MxStatusProxy statuses = 9; + string error_message = 10; +} + +message BulkReadReply { + repeated BulkReadResult results = 1; +} + message SessionStateReply { SessionState state = 1; } @@ -448,12 +620,16 @@ message DrainEventsReply { repeated MxEvent events = 1; } -// Reply payload for AcknowledgeAlarmCommand. Surfaces AVEVA's native -// AlarmAckByGUID return code; 0 means success. The MxCommandReply's -// hresult field carries the same value and is preferred for protocol -// consumers — this payload exists so the gateway-side -// WorkerAlarmRpcDispatcher can echo native_status into -// AcknowledgeAlarmReply.hresult without unpacking the outer envelope. +// Reply payload for AcknowledgeAlarmCommand AND +// AcknowledgeAlarmByNameCommand — both ack command kinds reuse this +// payload case (`MxCommandReply.acknowledge_alarm`); there is no +// dedicated by-name reply case. Surfaces AVEVA's native ack return +// code (AlarmAckByGUID for the GUID arm, AlarmAckByName for the +// by-name arm); 0 means success. The MxCommandReply's hresult field +// carries the same value and is preferred for protocol consumers — +// this payload exists so the gateway-side WorkerAlarmRpcDispatcher +// can echo native_status into AcknowledgeAlarmReply.hresult without +// unpacking the outer envelope. message AcknowledgeAlarmReplyPayload { int32 native_status = 1; } @@ -613,7 +789,10 @@ enum AlarmConditionState { } message AcknowledgeAlarmRequest { - string session_id = 1; + // Retired: acknowledgement is session-less — it routes to the gateway's + // central alarm monitor, not a client worker session. + reserved 1; + reserved "session_id"; string client_correlation_id = 2; // Fully-qualified alarm reference matching OnAlarmTransitionEvent.alarm_full_reference. string alarm_full_reference = 3; @@ -625,25 +804,60 @@ message AcknowledgeAlarmRequest { } message AcknowledgeAlarmReply { - string session_id = 1; + // Retired: see AcknowledgeAlarmRequest — acknowledgement is session-less. + reserved 1; + reserved "session_id"; string correlation_id = 2; ProtocolStatus protocol_status = 3; - // HRESULT captured from MXAccess if the ack failed at the COM layer. + // Native ack return code echoed from the worker. The worker carries the + // ack outcome as a single int32 (AcknowledgeAlarmReplyPayload.native_status, + // = AlarmAckByName / AlarmAckByGUID return code; 0 = success); the gateway's + // WorkerAlarmRpcDispatcher copies that value here. This is the authoritative + // ack-outcome field for the public RPC. Absent only when the worker reply + // omitted the value entirely (a protocol violation). optional int32 hresult = 4; - // Native MxAccess status describing the outcome of the ack. + // Reserved for a structured MxStatusProxy view of the ack outcome. The + // worker by-name/by-GUID ack path produces only the int32 return code + // (see `hresult`), so the current gateway leaves this field UNSET on every + // reply. Clients must read `hresult` (and `protocol_status`) for the ack + // result and must not depend on `status` being populated. MxStatusProxy status = 5; string diagnostic_message = 6; } -message QueryActiveAlarmsRequest { - string session_id = 1; - string client_correlation_id = 2; - // Optional alarm-reference prefix used to scope a partial ConditionRefresh - // (e.g. equipment sub-tree). Empty means full refresh. - string alarm_filter_prefix = 3; +// Request to attach to the gateway's central alarm feed (StreamAlarms). +message StreamAlarmsRequest { + string client_correlation_id = 1; + // Optional alarm-reference prefix scoping the feed to an equipment + // sub-tree. Empty streams every active alarm. + string alarm_filter_prefix = 2; +} + +// One message on the StreamAlarms feed. The stream opens with one +// `active_alarm` per currently-active alarm, then a single +// `snapshot_complete`, then a `transition` for every subsequent change. +message AlarmFeedMessage { + oneof payload { + // Part of the initial active-alarm snapshot (ConditionRefresh). + ActiveAlarmSnapshot active_alarm = 1; + // Sentinel: the initial snapshot is fully delivered and `transition` + // messages follow. Always true when present. + bool snapshot_complete = 2; + // A live alarm state change (raise / acknowledge / clear). + OnAlarmTransitionEvent transition = 3; + } } message MxStatusProxy { + // Mirrors the `success` member of the MXAccess MXSTATUS_PROXY struct + // (a 16-bit signed value in the COM struct, widened to int32 on the + // wire). Despite the name it is NOT a boolean — it is the raw numeric + // indicator the worker reads off the COM struct without reinterpretation. + // It is carried verbatim for diagnostics; the authoritative success/ + // failure of the operation is `category` (MX_STATUS_CATEGORY_OK marks + // success), with `detail`, `diagnostic_text`, `raw_category`, and + // `raw_detected_by` describing any non-OK outcome. Clients should branch + // on `category`, not on a specific `success` value. int32 success = 1; MxStatusCategory category = 2; MxStatusSource detected_by = 3; diff --git a/src/MxGateway.Contracts/Protos/mxaccess_worker.proto b/src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_worker.proto similarity index 86% rename from src/MxGateway.Contracts/Protos/mxaccess_worker.proto rename to src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_worker.proto index f12e7ed..06a22de 100644 --- a/src/MxGateway.Contracts/Protos/mxaccess_worker.proto +++ b/src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_worker.proto @@ -2,12 +2,19 @@ syntax = "proto3"; package mxaccess_worker.v1; -option csharp_namespace = "MxGateway.Contracts.Proto"; +option csharp_namespace = "ZB.MOM.WW.MxGateway.Contracts.Proto"; import "google/protobuf/duration.proto"; import "google/protobuf/timestamp.proto"; import "mxaccess_gateway.proto"; +// Wire-compatibility policy (ProtobufStyleGuide): this contract evolves +// additively only. Never renumber or repurpose an existing field number or +// enum value. When a field or enum value is removed, add a `reserved` range +// (and `reserved` name) covering it in the same change so a future editor +// cannot accidentally reuse the retired tag. There are no `reserved` +// declarations today because no field or enum value has ever been removed. + // Gateway-to-worker IPC envelope. Named-pipe framing prepends a little-endian // uint32 payload length to this protobuf payload. message WorkerEnvelope { diff --git a/src/MxGateway.Contracts/MxGateway.Contracts.csproj b/src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj similarity index 100% rename from src/MxGateway.Contracts/MxGateway.Contracts.csproj rename to src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj diff --git a/src/ZB.MOM.WW.MxGateway.IntegrationTests/DashboardLdapLiveTests.cs b/src/ZB.MOM.WW.MxGateway.IntegrationTests/DashboardLdapLiveTests.cs new file mode 100644 index 0000000..2baeb2d --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.IntegrationTests/DashboardLdapLiveTests.cs @@ -0,0 +1,112 @@ +using System.Security.Claims; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using ZB.MOM.WW.MxGateway.Server.Configuration; +using ZB.MOM.WW.MxGateway.Server.Dashboard; + +namespace ZB.MOM.WW.MxGateway.IntegrationTests; + +[Collection(LiveResourcesCollection.Name)] +[Trait("Category", "LiveLdap")] +public sealed class DashboardLdapLiveTests +{ + [LiveLdapFact] + public async Task AuthenticateAsync_AdminInGwAdminGroup_Succeeds() + { + DashboardAuthenticator authenticator = CreateAuthenticator(); + + DashboardAuthenticationResult result = await authenticator.AuthenticateAsync( + "admin", + "admin123", + CancellationToken.None); + + Assert.True(result.Succeeded); + Assert.NotNull(result.Principal); + Assert.Equal("admin", result.Principal.FindFirst(ClaimTypes.NameIdentifier)?.Value); + Assert.Contains(result.Principal.Claims, claim => + claim.Type == DashboardAuthenticationDefaults.LdapGroupClaimType + && claim.Value.Contains("GwAdmin", StringComparison.OrdinalIgnoreCase)); + } + + [LiveLdapFact] + public async Task AuthenticateAsync_ReadOnlyUserMissingGwAdminGroup_Fails() + { + DashboardAuthenticator authenticator = CreateAuthenticator(); + + DashboardAuthenticationResult result = await authenticator.AuthenticateAsync( + "readonly", + "readonly123", + CancellationToken.None); + + Assert.False(result.Succeeded); + Assert.Null(result.Principal); + Assert.DoesNotContain("readonly123", result.FailureMessage, StringComparison.Ordinal); + } + + [LiveLdapFact] + public async Task AuthenticateAsync_AdminWithWrongPassword_FailsWithoutLeakingPassword() + { + // Exercises the LdapException branch: the user exists and the service + // account search succeeds, but the candidate bind is rejected. + const string wrongPassword = "definitely-not-the-admin-password"; + DashboardAuthenticator authenticator = CreateAuthenticator(); + + DashboardAuthenticationResult result = await authenticator.AuthenticateAsync( + "admin", + wrongPassword, + CancellationToken.None); + + Assert.False(result.Succeeded); + Assert.Null(result.Principal); + Assert.DoesNotContain(wrongPassword, result.FailureMessage, StringComparison.Ordinal); + } + + [LiveLdapFact] + public async Task AuthenticateAsync_UnknownUsername_Fails() + { + // Exercises the `candidate is null` branch: the service-account search + // returns no entry, so no candidate bind is attempted. + DashboardAuthenticator authenticator = CreateAuthenticator(); + + DashboardAuthenticationResult result = await authenticator.AuthenticateAsync( + "no-such-user-9f3c1", + "irrelevant-password", + CancellationToken.None); + + Assert.False(result.Succeeded); + Assert.Null(result.Principal); + } + + [LiveLdapFact] + public async Task AuthenticateAsync_ServerUnreachable_FailsWithoutThrowing() + { + // Exercises the connect-failure path: a closed loopback port produces a + // connection error that DashboardAuthenticator must absorb into a Fail + // result rather than propagating an exception to the dashboard. + DashboardAuthenticator authenticator = new( + Options.Create(new GatewayOptions + { + Ldap = new LdapOptions + { + // 1 is a reserved port number that no LDAP server listens on. + Port = 1, + }, + }), + NullLogger.Instance); + + DashboardAuthenticationResult result = await authenticator.AuthenticateAsync( + "admin", + "admin123", + CancellationToken.None); + + Assert.False(result.Succeeded); + Assert.Null(result.Principal); + } + + private static DashboardAuthenticator CreateAuthenticator() + { + return new DashboardAuthenticator( + Options.Create(new GatewayOptions()), + NullLogger.Instance); + } +} diff --git a/src/MxGateway.IntegrationTests/Galaxy/GalaxyRepositoryLiveTests.cs b/src/ZB.MOM.WW.MxGateway.IntegrationTests/Galaxy/GalaxyRepositoryLiveTests.cs similarity index 91% rename from src/MxGateway.IntegrationTests/Galaxy/GalaxyRepositoryLiveTests.cs rename to src/ZB.MOM.WW.MxGateway.IntegrationTests/Galaxy/GalaxyRepositoryLiveTests.cs index 731d252..9ffc4bd 100644 --- a/src/MxGateway.IntegrationTests/Galaxy/GalaxyRepositoryLiveTests.cs +++ b/src/ZB.MOM.WW.MxGateway.IntegrationTests/Galaxy/GalaxyRepositoryLiveTests.cs @@ -1,12 +1,13 @@ -using MxGateway.Server.Galaxy; +using ZB.MOM.WW.MxGateway.Server.Galaxy; -namespace MxGateway.IntegrationTests.Galaxy; +namespace ZB.MOM.WW.MxGateway.IntegrationTests.Galaxy; +[Collection(LiveResourcesCollection.Name)] +[Trait("Category", "LiveGalaxy")] public sealed class GalaxyRepositoryLiveTests { /// Verifies that the Galaxy Repository can establish a live connection to the ZB database. [LiveGalaxyRepositoryFact] - [Trait("Category", "LiveGalaxy")] public async Task TestConnection_AgainstZb_Succeeds() { GalaxyRepository repository = CreateRepository(); @@ -18,7 +19,6 @@ public sealed class GalaxyRepositoryLiveTests /// Verifies that the last deploy time can be retrieved from the ZB database. [LiveGalaxyRepositoryFact] - [Trait("Category", "LiveGalaxy")] public async Task GetLastDeployTime_AgainstZb_ReturnsTimestamp() { GalaxyRepository repository = CreateRepository(); @@ -30,7 +30,6 @@ public sealed class GalaxyRepositoryLiveTests /// Verifies that the hierarchy can be retrieved from the ZB database. [LiveGalaxyRepositoryFact] - [Trait("Category", "LiveGalaxy")] public async Task GetHierarchy_AgainstZb_ReturnsObjects() { GalaxyRepository repository = CreateRepository(); @@ -48,7 +47,6 @@ public sealed class GalaxyRepositoryLiveTests /// Verifies that object attributes can be retrieved from the ZB database. [LiveGalaxyRepositoryFact] - [Trait("Category", "LiveGalaxy")] public async Task GetAttributes_AgainstZb_ReturnsAtLeastOneAttribute() { GalaxyRepository repository = CreateRepository(); diff --git a/src/MxGateway.IntegrationTests/Galaxy/LiveGalaxyRepositoryFactAttribute.cs b/src/ZB.MOM.WW.MxGateway.IntegrationTests/Galaxy/LiveGalaxyRepositoryFactAttribute.cs similarity index 64% rename from src/MxGateway.IntegrationTests/Galaxy/LiveGalaxyRepositoryFactAttribute.cs rename to src/ZB.MOM.WW.MxGateway.IntegrationTests/Galaxy/LiveGalaxyRepositoryFactAttribute.cs index 19896c1..3d2f5f4 100644 --- a/src/MxGateway.IntegrationTests/Galaxy/LiveGalaxyRepositoryFactAttribute.cs +++ b/src/ZB.MOM.WW.MxGateway.IntegrationTests/Galaxy/LiveGalaxyRepositoryFactAttribute.cs @@ -1,4 +1,6 @@ -namespace MxGateway.IntegrationTests.Galaxy; +using ZB.MOM.WW.MxGateway.Server.Galaxy; + +namespace ZB.MOM.WW.MxGateway.IntegrationTests.Galaxy; /// Fact attribute that skips tests unless live Galaxy Repository tests are explicitly enabled. public sealed class LiveGalaxyRepositoryFactAttribute : FactAttribute @@ -18,14 +20,14 @@ public sealed class LiveGalaxyRepositoryFactAttribute : FactAttribute } /// Gets a value indicating whether live Galaxy Repository tests are enabled. - public static bool Enabled => - string.Equals( - Environment.GetEnvironmentVariable(EnableVariableName), - "1", - StringComparison.Ordinal); + public static bool Enabled => IntegrationTestEnvironment.IsEnabled(EnableVariableName); - /// Gets the Galaxy Repository connection string from environment or default. + /// + /// Gets the Galaxy Repository connection string from environment or the production + /// default. The default is sourced from + /// so the live-test fallback cannot drift away from the production default. + /// public static string ConnectionString => Environment.GetEnvironmentVariable(ConnectionStringVariableName) - ?? "Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;"; + ?? GalaxyRepositoryOptions.DefaultConnectionString; } diff --git a/src/MxGateway.IntegrationTests/IntegrationTestEnvironment.cs b/src/ZB.MOM.WW.MxGateway.IntegrationTests/IntegrationTestEnvironment.cs similarity index 52% rename from src/MxGateway.IntegrationTests/IntegrationTestEnvironment.cs rename to src/ZB.MOM.WW.MxGateway.IntegrationTests/IntegrationTestEnvironment.cs index 0f2c470..5ab53fd 100644 --- a/src/MxGateway.IntegrationTests/IntegrationTestEnvironment.cs +++ b/src/ZB.MOM.WW.MxGateway.IntegrationTests/IntegrationTestEnvironment.cs @@ -1,17 +1,34 @@ -namespace MxGateway.IntegrationTests; +using ZB.MOM.WW.MxGateway.Contracts; + +namespace ZB.MOM.WW.MxGateway.IntegrationTests; public static class IntegrationTestEnvironment { - public const string LiveMxAccessVariableName = "MXGATEWAY_RUN_LIVE_MXACCESS_TESTS"; + /// + /// Sourced from + /// so the env-var literal is shared with + /// ZB.MOM.WW.MxGateway.Worker.Tests.TestSupport.LiveMxAccessFactAttribute + /// (Worker.Tests-025). + /// + public const string LiveMxAccessVariableName = GatewayContractInfo.LiveMxAccessOptInVariableName; public const string LiveMxAccessWorkerExecutableVariableName = "MXGATEWAY_LIVE_MXACCESS_WORKER_EXE"; public const string LiveMxAccessItemVariableName = "MXGATEWAY_LIVE_MXACCESS_ITEM"; public const string LiveMxAccessClientNameVariableName = "MXGATEWAY_LIVE_MXACCESS_CLIENT_NAME"; public const string LiveMxAccessEventTimeoutSecondsVariableName = "MXGATEWAY_LIVE_MXACCESS_EVENT_TIMEOUT_SECONDS"; /// Gets whether live MXAccess tests are enabled. - public static bool LiveMxAccessTestsEnabled => + public static bool LiveMxAccessTestsEnabled => IsEnabled(LiveMxAccessVariableName); + + /// + /// Gets whether an opt-in live-test suite is enabled, by comparing the named + /// environment variable to 1. Shared by every Live*FactAttribute + /// so the opt-in check has a single implementation. + /// + /// The environment variable that gates the suite. + /// when the variable is exactly 1. + public static bool IsEnabled(string variableName) => string.Equals( - Environment.GetEnvironmentVariable(LiveMxAccessVariableName), + Environment.GetEnvironmentVariable(variableName), "1", StringComparison.Ordinal); @@ -25,7 +42,7 @@ public static class IntegrationTestEnvironment public static string LiveMxAccessClientName => GetOptionalEnvironmentVariable( LiveMxAccessClientNameVariableName, - "MxGateway.IntegrationTests"); + "ZB.MOM.WW.MxGateway.IntegrationTests"); /// Gets the timeout for waiting on events in live tests. public static TimeSpan LiveMxAccessEventTimeout => @@ -34,7 +51,7 @@ public static class IntegrationTestEnvironment defaultValue: 15)); /// Resolves the path to the worker executable for live tests. - /// Path to MxGateway.Worker.exe. + /// Path to ZB.MOM.WW.MxGateway.Worker.exe. public static string ResolveLiveMxAccessWorkerExecutablePath() { string? configuredPath = Environment.GetEnvironmentVariable(LiveMxAccessWorkerExecutableVariableName); @@ -46,11 +63,11 @@ public static class IntegrationTestEnvironment string repositoryRoot = ResolveRepositoryRoot(AppContext.BaseDirectory); string[] candidatePaths = [ - Path.Combine(repositoryRoot, "src", "MxGateway.Worker", "bin", "x86", "Debug", "net48", "MxGateway.Worker.exe"), - Path.Combine(repositoryRoot, "src", "MxGateway.Worker", "bin", "Debug", "net48", "MxGateway.Worker.exe"), - Path.Combine(repositoryRoot, "src", "MxGateway.Worker", "bin", "x86", "Release", "net48", "MxGateway.Worker.exe"), - Path.Combine(repositoryRoot, "src", "MxGateway.Worker", "bin", "Release", "net48", "MxGateway.Worker.exe"), - Path.Combine(repositoryRoot, "src", "MxGateway.Worker", "bin", "x86", "Release", "MxGateway.Worker.exe"), + Path.Combine(repositoryRoot, "src", "ZB.MOM.WW.MxGateway.Worker", "bin", "x86", "Debug", "net48", "ZB.MOM.WW.MxGateway.Worker.exe"), + Path.Combine(repositoryRoot, "src", "ZB.MOM.WW.MxGateway.Worker", "bin", "Debug", "net48", "ZB.MOM.WW.MxGateway.Worker.exe"), + Path.Combine(repositoryRoot, "src", "ZB.MOM.WW.MxGateway.Worker", "bin", "x86", "Release", "net48", "ZB.MOM.WW.MxGateway.Worker.exe"), + Path.Combine(repositoryRoot, "src", "ZB.MOM.WW.MxGateway.Worker", "bin", "Release", "net48", "ZB.MOM.WW.MxGateway.Worker.exe"), + Path.Combine(repositoryRoot, "src", "ZB.MOM.WW.MxGateway.Worker", "bin", "x86", "Release", "ZB.MOM.WW.MxGateway.Worker.exe"), ]; return candidatePaths.FirstOrDefault(File.Exists) @@ -80,7 +97,7 @@ public static class IntegrationTestEnvironment return defaultValue; } - /// Resolves the root directory of the repository by searching for .git and src directories. + /// Resolves the root directory of the repository by walking parents for a src/ directory next to either a .git marker or a .sln/.slnx file. /// Starting directory to search from. /// The repository root path, or the start directory if not found. internal static string ResolveRepositoryRoot(string startDirectory) @@ -88,9 +105,7 @@ public static class IntegrationTestEnvironment DirectoryInfo? directory = new(startDirectory); while (directory is not null) { - if ((Directory.Exists(Path.Combine(directory.FullName, ".git")) - || File.Exists(Path.Combine(directory.FullName, ".git"))) - && Directory.Exists(Path.Combine(directory.FullName, "src"))) + if (IsRepositoryRoot(directory)) { return directory.FullName; } @@ -100,4 +115,25 @@ public static class IntegrationTestEnvironment return Directory.GetCurrentDirectory(); } + + private static bool IsRepositoryRoot(DirectoryInfo directory) + { + string srcPath = Path.Combine(directory.FullName, "src"); + if (!Directory.Exists(srcPath)) + { + return false; + } + + // Accept a checked-out git repo OR an unpacked working tree that ships a + // .sln/.slnx alongside src/. The .sln/.slnx fallback lets the integration + // tests run in copies that have no .git folder (e.g. an extracted zip). + if (Directory.Exists(Path.Combine(directory.FullName, ".git")) + || File.Exists(Path.Combine(directory.FullName, ".git"))) + { + return true; + } + + return Directory.EnumerateFiles(srcPath, "*.slnx").Any() + || Directory.EnumerateFiles(srcPath, "*.sln").Any(); + } } diff --git a/src/MxGateway.IntegrationTests/IntegrationTestEnvironmentTests.cs b/src/ZB.MOM.WW.MxGateway.IntegrationTests/IntegrationTestEnvironmentTests.cs similarity index 97% rename from src/MxGateway.IntegrationTests/IntegrationTestEnvironmentTests.cs rename to src/ZB.MOM.WW.MxGateway.IntegrationTests/IntegrationTestEnvironmentTests.cs index 871bf57..de66d4e 100644 --- a/src/MxGateway.IntegrationTests/IntegrationTestEnvironmentTests.cs +++ b/src/ZB.MOM.WW.MxGateway.IntegrationTests/IntegrationTestEnvironmentTests.cs @@ -1,4 +1,4 @@ -namespace MxGateway.IntegrationTests; +namespace ZB.MOM.WW.MxGateway.IntegrationTests; public sealed class IntegrationTestEnvironmentTests { diff --git a/src/ZB.MOM.WW.MxGateway.IntegrationTests/LiveLdapFactAttribute.cs b/src/ZB.MOM.WW.MxGateway.IntegrationTests/LiveLdapFactAttribute.cs new file mode 100644 index 0000000..f5ec851 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.IntegrationTests/LiveLdapFactAttribute.cs @@ -0,0 +1,16 @@ +namespace ZB.MOM.WW.MxGateway.IntegrationTests; + +public sealed class LiveLdapFactAttribute : FactAttribute +{ + public const string EnableVariableName = "MXGATEWAY_RUN_LIVE_LDAP_TESTS"; + + public LiveLdapFactAttribute() + { + if (!Enabled) + { + Skip = $"Set {EnableVariableName}=1 to run live LDAP tests."; + } + } + + public static bool Enabled => IntegrationTestEnvironment.IsEnabled(EnableVariableName); +} diff --git a/src/MxGateway.IntegrationTests/LiveMxAccessFactAttribute.cs b/src/ZB.MOM.WW.MxGateway.IntegrationTests/LiveMxAccessFactAttribute.cs similarity index 91% rename from src/MxGateway.IntegrationTests/LiveMxAccessFactAttribute.cs rename to src/ZB.MOM.WW.MxGateway.IntegrationTests/LiveMxAccessFactAttribute.cs index 85263c8..93af0e7 100644 --- a/src/MxGateway.IntegrationTests/LiveMxAccessFactAttribute.cs +++ b/src/ZB.MOM.WW.MxGateway.IntegrationTests/LiveMxAccessFactAttribute.cs @@ -1,4 +1,4 @@ -namespace MxGateway.IntegrationTests; +namespace ZB.MOM.WW.MxGateway.IntegrationTests; /// Marks an xUnit test as requiring installed MXAccess COM and live provider state. public sealed class LiveMxAccessFactAttribute : FactAttribute diff --git a/src/ZB.MOM.WW.MxGateway.IntegrationTests/LiveResourcesCollection.cs b/src/ZB.MOM.WW.MxGateway.IntegrationTests/LiveResourcesCollection.cs new file mode 100644 index 0000000..31f91ba --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.IntegrationTests/LiveResourcesCollection.cs @@ -0,0 +1,16 @@ +namespace ZB.MOM.WW.MxGateway.IntegrationTests; + +/// +/// xUnit collection that serializes every live integration-test class. The live +/// suites contend for genuinely shared singletons — one MXAccess COM provider, +/// one ZB SQL database, and one GLAuth instance with a per-IP failure +/// lockout — so they must not run in parallel with one another. Placing each +/// live class in this collection disables xUnit's default cross-class +/// parallelism for them while leaving non-live tests free to parallelize. +/// +[CollectionDefinition(Name, DisableParallelization = true)] +public sealed class LiveResourcesCollection +{ + /// The collection name applied via [Collection] on live test classes. + public const string Name = "Live external resources"; +} diff --git a/src/ZB.MOM.WW.MxGateway.IntegrationTests/WorkerLiveMxAccessSmokeTests.cs b/src/ZB.MOM.WW.MxGateway.IntegrationTests/WorkerLiveMxAccessSmokeTests.cs new file mode 100644 index 0000000..e7c8f17 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.IntegrationTests/WorkerLiveMxAccessSmokeTests.cs @@ -0,0 +1,1596 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Text; +using Google.Protobuf.WellKnownTypes; +using Grpc.Core; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using ZB.MOM.WW.MxGateway.Contracts; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Server.Configuration; +using ZB.MOM.WW.MxGateway.Server.Grpc; +using ZB.MOM.WW.MxGateway.Server.Metrics; +using ZB.MOM.WW.MxGateway.Server.Security.Authentication; +using ZB.MOM.WW.MxGateway.Server.Security.Authorization; +using ZB.MOM.WW.MxGateway.Server.Sessions; +using ZB.MOM.WW.MxGateway.Server.Workers; +using Xunit.Abstractions; + +namespace ZB.MOM.WW.MxGateway.IntegrationTests; + +[Collection(LiveResourcesCollection.Name)] +[Trait("Category", "LiveMxAccess")] +public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output) +{ + private static readonly TimeSpan CommandTimeout = TimeSpan.FromSeconds(15); + private static readonly TimeSpan StreamShutdownTimeout = TimeSpan.FromSeconds(10); + + /// + /// Verifies that a gateway session can register, add item, advise, and stream events from live MXAccess. + /// + [LiveMxAccessFact] + public async Task GatewaySession_WithLiveWorker_RegistersAdvisesStreamsDataAndCloses() + { + string workerExecutablePath = IntegrationTestEnvironment.ResolveLiveMxAccessWorkerExecutablePath(); + Assert.True( + File.Exists(workerExecutablePath), + $"Live MXAccess worker executable was not found at {workerExecutablePath}. Build the worker or set {IntegrationTestEnvironment.LiveMxAccessWorkerExecutableVariableName}."); + + TestWorkerProcessFactory processFactory = new(output); + await using GatewayServiceFixture fixture = new(workerExecutablePath, processFactory, output); + using RecordingServerStreamWriter eventWriter = new(); + + string? sessionId = null; + Task? streamTask = null; + using CancellationTokenSource streamCancellation = new(); + + try + { + OpenSessionReply openReply = await fixture.Service.OpenSession( + new OpenSessionRequest + { + ClientSessionName = "live-mxaccess-smoke", + ClientCorrelationId = "live-open", + CommandTimeout = Duration.FromTimeSpan(CommandTimeout), + }, + new TestServerCallContext()).ConfigureAwait(false); + + sessionId = openReply.SessionId; + output.WriteLine($"OpenSession status={openReply.ProtocolStatus.Code} session={sessionId} worker_pid={openReply.WorkerProcessId}"); + Assert.Equal(ProtocolStatusCode.Ok, openReply.ProtocolStatus.Code); + Assert.True(openReply.WorkerProcessId > 0); + + streamTask = fixture.Service.StreamEvents( + new StreamEventsRequest { SessionId = sessionId }, + eventWriter, + new TestServerCallContext(streamCancellation.Token)); + + MxCommandReply registerReply = await fixture.Service.Invoke( + CreateRegisterRequest(sessionId), + new TestServerCallContext()).ConfigureAwait(false); + LogReply("Register", registerReply); + Assert.Equal(ProtocolStatusCode.Ok, registerReply.ProtocolStatus.Code); + Assert.True(registerReply.Register.ServerHandle > 0); + + MxCommandReply addItemReply = await fixture.Service.Invoke( + CreateAddItemRequest(sessionId, registerReply.Register.ServerHandle), + new TestServerCallContext()).ConfigureAwait(false); + LogReply("AddItem", addItemReply); + Assert.Equal(ProtocolStatusCode.Ok, addItemReply.ProtocolStatus.Code); + Assert.True(addItemReply.AddItem.ItemHandle > 0); + + MxCommandReply adviseReply = await fixture.Service.Invoke( + CreateAdviseRequest( + sessionId, + registerReply.Register.ServerHandle, + addItemReply.AddItem.ItemHandle), + new TestServerCallContext()).ConfigureAwait(false); + LogReply("Advise", adviseReply); + Assert.Equal(ProtocolStatusCode.Ok, adviseReply.ProtocolStatus.Code); + + // A live MXAccess provider can deliver an initial registration-state + // or bad-quality bootstrap event before the OnDataChange the worker + // is contracted to emit. Match on the family rather than trusting + // whatever event arrives first so a genuine ordering defect cannot + // pass spuriously or leave a later wrong event unverified. + MxEvent dataChange = await eventWriter + .WaitForMessageAsync( + candidate => candidate.Family == MxEventFamily.OnDataChange, + IntegrationTestEnvironment.LiveMxAccessEventTimeout, + streamCancellation.Token) + .ConfigureAwait(false); + LogEvent(dataChange); + + Assert.Equal(MxEventFamily.OnDataChange, dataChange.Family); + Assert.Equal(sessionId, dataChange.SessionId); + Assert.Equal(registerReply.Register.ServerHandle, dataChange.ServerHandle); + Assert.Equal(addItemReply.AddItem.ItemHandle, dataChange.ItemHandle); + } + finally + { + await ShutDownAsync(fixture, processFactory, sessionId, streamTask).ConfigureAwait(false); + } + } + + /// + /// Verifies that a Write command round-trips through live MXAccess against an advised item + /// and that the worker emits a matching event + /// — the proof of round-trip the cross-language client e2e runner relies on. + /// + [LiveMxAccessFact] + public async Task GatewaySession_WithLiveWorker_WritesValueToAdvisedItem() + { + string workerExecutablePath = IntegrationTestEnvironment.ResolveLiveMxAccessWorkerExecutablePath(); + Assert.True( + File.Exists(workerExecutablePath), + $"Live MXAccess worker executable was not found at {workerExecutablePath}. Build the worker or set {IntegrationTestEnvironment.LiveMxAccessWorkerExecutableVariableName}."); + + TestWorkerProcessFactory processFactory = new(output); + await using GatewayServiceFixture fixture = new(workerExecutablePath, processFactory, output); + using RecordingServerStreamWriter eventWriter = new(); + + string? sessionId = null; + Task? streamTask = null; + using CancellationTokenSource streamCancellation = new(); + + try + { + OpenSessionReply openReply = await fixture.Service.OpenSession( + new OpenSessionRequest + { + ClientSessionName = "live-mxaccess-write", + ClientCorrelationId = "live-open-write", + CommandTimeout = Duration.FromTimeSpan(CommandTimeout), + }, + new TestServerCallContext()).ConfigureAwait(false); + + sessionId = openReply.SessionId; + Assert.Equal(ProtocolStatusCode.Ok, openReply.ProtocolStatus.Code); + + streamTask = fixture.Service.StreamEvents( + new StreamEventsRequest { SessionId = sessionId }, + eventWriter, + new TestServerCallContext(streamCancellation.Token)); + + MxCommandReply registerReply = await fixture.Service.Invoke( + CreateRegisterRequest(sessionId), + new TestServerCallContext()).ConfigureAwait(false); + LogReply("Register", registerReply); + Assert.Equal(ProtocolStatusCode.Ok, registerReply.ProtocolStatus.Code); + + MxCommandReply addItemReply = await fixture.Service.Invoke( + CreateAddItemRequest(sessionId, registerReply.Register.ServerHandle), + new TestServerCallContext()).ConfigureAwait(false); + LogReply("AddItem", addItemReply); + Assert.Equal(ProtocolStatusCode.Ok, addItemReply.ProtocolStatus.Code); + Assert.True(addItemReply.AddItem.ItemHandle > 0); + + MxCommandReply adviseReply = await fixture.Service.Invoke( + CreateAdviseRequest( + sessionId, + registerReply.Register.ServerHandle, + addItemReply.AddItem.ItemHandle), + new TestServerCallContext()).ConfigureAwait(false); + LogReply("Advise", adviseReply); + Assert.Equal(ProtocolStatusCode.Ok, adviseReply.ProtocolStatus.Code); + + MxCommandReply writeReply = await fixture.Service.Invoke( + CreateWriteRequest( + sessionId, + registerReply.Register.ServerHandle, + addItemReply.AddItem.ItemHandle), + new TestServerCallContext()).ConfigureAwait(false); + LogReply("Write", writeReply); + + // Happy-path Write: the worker COM call succeeded so HResultConverter + // produces ProtocolStatusCode.Ok. An MXAccess rejection (a write to a + // bad item, a secured-item failure) would surface as + // ProtocolStatusCode.MxaccessFailure with a non-zero hresult — never + // as an RpcException / transport fault, because the command still + // completed its round-trip to the worker and back. + Assert.Equal(ProtocolStatusCode.Ok, writeReply.ProtocolStatus.Code); + Assert.Equal(MxCommandKind.Write, writeReply.Kind); + + // Proof of round-trip: MXAccess fires OnWriteComplete (event id 2) + // after the underlying provider acknowledges the write — that is + // the event the cross-language client e2e runner asserts on. We + // scan the recorded stream (so an interleaving OnDataChange does + // not preempt the match) for an OnWriteComplete carrying the same + // server/item handles the Write command targeted. + MxEvent writeComplete = await eventWriter + .WaitForMessageAsync( + candidate => candidate.Family == MxEventFamily.OnWriteComplete + && candidate.ServerHandle == registerReply.Register.ServerHandle + && candidate.ItemHandle == addItemReply.AddItem.ItemHandle, + IntegrationTestEnvironment.LiveMxAccessEventTimeout, + streamCancellation.Token) + .ConfigureAwait(false); + LogEvent(writeComplete); + + Assert.Equal(MxEventFamily.OnWriteComplete, writeComplete.Family); + Assert.Equal(sessionId, writeComplete.SessionId); + Assert.Equal(registerReply.Register.ServerHandle, writeComplete.ServerHandle); + Assert.Equal(addItemReply.AddItem.ItemHandle, writeComplete.ItemHandle); + + // The stream task must not be in a faulted state. ShutDownAsync's + // broad catch would otherwise swallow the fault and silently let + // this Write-parity coverage pass against a broken event pipeline. + Assert.False( + streamTask.IsFaulted, + streamTask.Exception?.ToString() ?? "Event stream task faulted without an exception."); + } + finally + { + // Cancel the stream call before draining so StreamEvents observes + // cancellation rather than blocking on the channel. Any unhandled + // stream-task fault is rethrown from ShutDownAsync into the test. + streamCancellation.Cancel(); + await ShutDownAsync(fixture, processFactory, sessionId, streamTask, propagateStreamFaults: true).ConfigureAwait(false); + } + } + + /// + /// Verifies that an AddItem against an invalid server handle surfaces the MXAccess failure + /// without faulting the gateway transport, exercising the invalid-handle parity path. + /// + [LiveMxAccessFact] + public async Task GatewaySession_WithLiveWorker_InvalidHandleCommand_SurfacesFailureWithoutTransportFault() + { + string workerExecutablePath = IntegrationTestEnvironment.ResolveLiveMxAccessWorkerExecutablePath(); + Assert.True( + File.Exists(workerExecutablePath), + $"Live MXAccess worker executable was not found at {workerExecutablePath}. Build the worker or set {IntegrationTestEnvironment.LiveMxAccessWorkerExecutableVariableName}."); + + TestWorkerProcessFactory processFactory = new(output); + await using GatewayServiceFixture fixture = new(workerExecutablePath, processFactory, output); + + string? sessionId = null; + + try + { + OpenSessionReply openReply = await fixture.Service.OpenSession( + new OpenSessionRequest + { + ClientSessionName = "live-mxaccess-invalid-handle", + ClientCorrelationId = "live-open-invalid", + CommandTimeout = Duration.FromTimeSpan(CommandTimeout), + }, + new TestServerCallContext()).ConfigureAwait(false); + + sessionId = openReply.SessionId; + Assert.Equal(ProtocolStatusCode.Ok, openReply.ProtocolStatus.Code); + + // Deliberately skip Register: server handle 0x7FFFFFFF was never + // issued by MXAccess. The worker must invoke COM and relay the + // invalid-handle failure rather than the gateway short-circuiting. + MxCommandReply addItemReply = await fixture.Service.Invoke( + CreateAddItemRequest(sessionId, serverHandle: int.MaxValue), + new TestServerCallContext()).ConfigureAwait(false); + LogReply("AddItem(invalid-handle)", addItemReply); + + // MXAccess parity: an invalid handle is an MXAccess-level failure. + // The command still completed its worker round-trip, so the gateway + // must reply with ProtocolStatusCode.MxaccessFailure and a non-zero + // hresult carrying the COM failure (per HResultConverter) — never a + // gRPC transport fault. The assertion below just checks the status + // is not Ok; the failure detail lives in hresult / the status proxies. + Assert.NotEqual(ProtocolStatusCode.Ok, addItemReply.ProtocolStatus.Code); + Assert.True( + addItemReply.AddItem is null || addItemReply.AddItem.ItemHandle <= 0, + "Invalid-handle AddItem must not yield a usable item handle."); + } + finally + { + await ShutDownAsync(fixture, processFactory, sessionId, streamTask: null).ConfigureAwait(false); + } + } + + /// + /// Verifies the MXAccess teardown chain: Unadvise then RemoveItem then Unregister + /// each return , and the worker stops emitting + /// OnDataChange events for the un-advised item. Exercises the lifecycle-ordering + /// parity CLAUDE.md singles out as a "do not synthesize" rule. + /// + [LiveMxAccessFact] + public async Task GatewaySession_WithLiveWorker_UnadviseRemoveItemUnregister_TeardownOrderingParity() + { + string workerExecutablePath = IntegrationTestEnvironment.ResolveLiveMxAccessWorkerExecutablePath(); + Assert.True( + File.Exists(workerExecutablePath), + $"Live MXAccess worker executable was not found at {workerExecutablePath}. Build the worker or set {IntegrationTestEnvironment.LiveMxAccessWorkerExecutableVariableName}."); + + TestWorkerProcessFactory processFactory = new(output); + await using GatewayServiceFixture fixture = new(workerExecutablePath, processFactory, output); + using RecordingServerStreamWriter eventWriter = new(); + + string? sessionId = null; + Task? streamTask = null; + using CancellationTokenSource streamCancellation = new(); + + try + { + OpenSessionReply openReply = await fixture.Service.OpenSession( + new OpenSessionRequest + { + ClientSessionName = "live-mxaccess-teardown", + ClientCorrelationId = "live-open-teardown", + CommandTimeout = Duration.FromTimeSpan(CommandTimeout), + }, + new TestServerCallContext()).ConfigureAwait(false); + + sessionId = openReply.SessionId; + Assert.Equal(ProtocolStatusCode.Ok, openReply.ProtocolStatus.Code); + + streamTask = fixture.Service.StreamEvents( + new StreamEventsRequest { SessionId = sessionId }, + eventWriter, + new TestServerCallContext(streamCancellation.Token)); + + MxCommandReply registerReply = await fixture.Service.Invoke( + CreateRegisterRequest(sessionId), + new TestServerCallContext()).ConfigureAwait(false); + LogReply("Register", registerReply); + Assert.Equal(ProtocolStatusCode.Ok, registerReply.ProtocolStatus.Code); + + int serverHandle = registerReply.Register.ServerHandle; + + MxCommandReply addItemReply = await fixture.Service.Invoke( + CreateAddItemRequest(sessionId, serverHandle), + new TestServerCallContext()).ConfigureAwait(false); + LogReply("AddItem", addItemReply); + Assert.Equal(ProtocolStatusCode.Ok, addItemReply.ProtocolStatus.Code); + int itemHandle = addItemReply.AddItem.ItemHandle; + + MxCommandReply adviseReply = await fixture.Service.Invoke( + CreateAdviseRequest(sessionId, serverHandle, itemHandle), + new TestServerCallContext()).ConfigureAwait(false); + LogReply("Advise", adviseReply); + Assert.Equal(ProtocolStatusCode.Ok, adviseReply.ProtocolStatus.Code); + + // Wait for an OnDataChange to prove the subscription is live before tearing it down. + MxEvent firstDataChange = await eventWriter + .WaitForMessageAsync( + candidate => candidate.Family == MxEventFamily.OnDataChange + && candidate.ServerHandle == serverHandle + && candidate.ItemHandle == itemHandle, + IntegrationTestEnvironment.LiveMxAccessEventTimeout, + streamCancellation.Token) + .ConfigureAwait(false); + LogEvent(firstDataChange); + + // 1) UnAdvise — must reply Ok; the worker must stop emitting OnDataChange + // for this (server, item) pair after this returns. + MxCommandReply unadviseReply = await fixture.Service.Invoke( + CreateUnAdviseRequest(sessionId, serverHandle, itemHandle), + new TestServerCallContext()).ConfigureAwait(false); + LogReply("UnAdvise", unadviseReply); + Assert.Equal(ProtocolStatusCode.Ok, unadviseReply.ProtocolStatus.Code); + Assert.Equal(MxCommandKind.UnAdvise, unadviseReply.Kind); + + // 2) RemoveItem — must reply Ok against the same handles. + MxCommandReply removeItemReply = await fixture.Service.Invoke( + CreateRemoveItemRequest(sessionId, serverHandle, itemHandle), + new TestServerCallContext()).ConfigureAwait(false); + LogReply("RemoveItem", removeItemReply); + Assert.Equal(ProtocolStatusCode.Ok, removeItemReply.ProtocolStatus.Code); + Assert.Equal(MxCommandKind.RemoveItem, removeItemReply.Kind); + + // 3) Unregister — closes the client session inside the worker. + MxCommandReply unregisterReply = await fixture.Service.Invoke( + CreateUnregisterRequest(sessionId, serverHandle), + new TestServerCallContext()).ConfigureAwait(false); + LogReply("Unregister", unregisterReply); + Assert.Equal(ProtocolStatusCode.Ok, unregisterReply.ProtocolStatus.Code); + Assert.Equal(MxCommandKind.Unregister, unregisterReply.Kind); + + // Parity rule: after UnAdvise returns Ok the worker must stop emitting + // OnDataChange for this (server, item) pair. Events the provider already + // published before that ack are in-flight and not a regression — the rule + // only constrains events generated AFTER the teardown returned. So the + // "before" baseline is taken *after* a first settle window drains those + // in-flight events, not before UnAdvise was issued (which races against + // the round-trip + STA dispatch + pipe send window — see IntegrationTests-017). + // + // RecordingServerStreamWriter.Messages returns a snapshot copy under its + // own lock, so iterating after each settle window is safe without external + // sync. + await Task.Delay(TimeSpan.FromMilliseconds(500)).ConfigureAwait(false); + int dataChangeCountAfterFirstSettle = CountMatchingEvents( + eventWriter, + e => e.Family == MxEventFamily.OnDataChange + && e.ServerHandle == serverHandle + && e.ItemHandle == itemHandle); + + await Task.Delay(TimeSpan.FromMilliseconds(500)).ConfigureAwait(false); + int dataChangeCountAfterSecondSettle = CountMatchingEvents( + eventWriter, + e => e.Family == MxEventFamily.OnDataChange + && e.ServerHandle == serverHandle + && e.ItemHandle == itemHandle); + output.WriteLine( + $"DataChange count after first settle={dataChangeCountAfterFirstSettle} after second settle={dataChangeCountAfterSecondSettle}"); + Assert.Equal(dataChangeCountAfterFirstSettle, dataChangeCountAfterSecondSettle); + + // A RemoveItem against the just-freed item handle must not silently succeed — + // the worker has to relay MXAccess's invalid-handle response. Closing the + // session is enough for parity, but we sanity-check that re-using the freed + // pair does not accidentally appear Ok. + MxCommandReply secondRemoveItemReply = await fixture.Service.Invoke( + CreateRemoveItemRequest(sessionId, serverHandle, itemHandle), + new TestServerCallContext()).ConfigureAwait(false); + LogReply("RemoveItem(stale)", secondRemoveItemReply); + Assert.NotEqual(ProtocolStatusCode.Ok, secondRemoveItemReply.ProtocolStatus.Code); + } + finally + { + streamCancellation.Cancel(); + await ShutDownAsync(fixture, processFactory, sessionId, streamTask).ConfigureAwait(false); + } + } + + /// + /// Verifies the MXAccess WriteSecured path: AuthenticateUser resolves a + /// user id, then WriteSecured against the advised item completes its round-trip + /// to the worker and back. CLAUDE.md singles out WriteSecured ordering as a + /// parity surface the gateway must not "fix" — the test asserts the reply kind and + /// protocol status, not a fabricated outcome. + /// + [LiveMxAccessFact] + public async Task GatewaySession_WithLiveWorker_WriteSecured_AuthenticatedRoundTripParity() + { + string workerExecutablePath = IntegrationTestEnvironment.ResolveLiveMxAccessWorkerExecutablePath(); + Assert.True( + File.Exists(workerExecutablePath), + $"Live MXAccess worker executable was not found at {workerExecutablePath}. Build the worker or set {IntegrationTestEnvironment.LiveMxAccessWorkerExecutableVariableName}."); + + // IntegrationTests-019: CLAUDE.md's credential-redaction rule covers every log + // surface the test sees, not just the reply's DiagnosticMessage. Wire a buffering + // wrapper around output and route the worker stdout/stderr echo and the gateway + // ILogger sink through it so the post-run assertion covers the accumulated test + // output. A regression that logged the request body, the WorkerCommandRequest + // envelope, or printed the credential from inside the worker is caught here + // even if the bare DiagnosticMessage check still passes. + RecordingTestOutputHelper recordedOutput = new(output); + TestWorkerProcessFactory processFactory = new(recordedOutput); + await using GatewayServiceFixture fixture = new(workerExecutablePath, processFactory, recordedOutput); + // Stream events so a regression that emitted an OperationComplete or + // OnWriteComplete with wrong handles would still be observable via the test + // output (we don't assert a specific event here — the docs note successful + // writes raise only OnWriteComplete, but WriteSecured against an unprotected + // item commonly fails with 0x80004021 in this provider, which raises no event). + using RecordingServerStreamWriter eventWriter = new(); + + string? sessionId = null; + Task? streamTask = null; + using CancellationTokenSource streamCancellation = new(); + (string verifyUser, string verifyPassword) = ResolveLiveMxAccessSecuredCredentials(); + + try + { + OpenSessionReply openReply = await fixture.Service.OpenSession( + new OpenSessionRequest + { + ClientSessionName = "live-mxaccess-write-secured", + ClientCorrelationId = "live-open-write-secured", + CommandTimeout = Duration.FromTimeSpan(CommandTimeout), + }, + new TestServerCallContext()).ConfigureAwait(false); + + sessionId = openReply.SessionId; + Assert.Equal(ProtocolStatusCode.Ok, openReply.ProtocolStatus.Code); + + streamTask = fixture.Service.StreamEvents( + new StreamEventsRequest { SessionId = sessionId }, + eventWriter, + new TestServerCallContext(streamCancellation.Token)); + + MxCommandReply registerReply = await fixture.Service.Invoke( + CreateRegisterRequest(sessionId), + new TestServerCallContext()).ConfigureAwait(false); + LogReplyTo(recordedOutput, "Register", registerReply); + Assert.Equal(ProtocolStatusCode.Ok, registerReply.ProtocolStatus.Code); + int serverHandle = registerReply.Register.ServerHandle; + + MxCommandReply addItemReply = await fixture.Service.Invoke( + CreateAddItemRequest(sessionId, serverHandle), + new TestServerCallContext()).ConfigureAwait(false); + LogReplyTo(recordedOutput, "AddItem", addItemReply); + Assert.Equal(ProtocolStatusCode.Ok, addItemReply.ProtocolStatus.Code); + int itemHandle = addItemReply.AddItem.ItemHandle; + + MxCommandReply adviseReply = await fixture.Service.Invoke( + CreateAdviseRequest(sessionId, serverHandle, itemHandle), + new TestServerCallContext()).ConfigureAwait(false); + LogReplyTo(recordedOutput, "Advise", adviseReply); + Assert.Equal(ProtocolStatusCode.Ok, adviseReply.ProtocolStatus.Code); + + // AuthenticateUser resolves an ArchestrA user id for the WriteSecured call. + // Credentials are env-overridable so the test honors the gateway's "do not + // log secrets" rule and works against either MXAccess's own user store or + // the LmxOpcUa-baseline GLAuth-bridged ArchestrA identity (admin/admin123). + MxCommandReply authReply = await fixture.Service.Invoke( + CreateAuthenticateUserRequest(sessionId, serverHandle, verifyUser, verifyPassword), + new TestServerCallContext()).ConfigureAwait(false); + recordedOutput.WriteLine( + $"AuthenticateUser status={authReply.ProtocolStatus.Code} hresult={authReply.Hresult} user_id={authReply.AuthenticateUser?.UserId}"); + + // AuthenticateUser is allowed to fail (the underlying provider may reject + // the credential pair); we use the returned user id if non-zero and fall + // back to 0 ("operator only" / no verifier) so the parity assertion holds. + int currentUserId = authReply.ProtocolStatus.Code == ProtocolStatusCode.Ok + && authReply.AuthenticateUser is not null + && authReply.AuthenticateUser.UserId != 0 + ? authReply.AuthenticateUser.UserId + : 0; + + MxCommandReply writeSecuredReply = await fixture.Service.Invoke( + CreateWriteSecuredRequest( + sessionId, + serverHandle, + itemHandle, + currentUserId, + verifierUserId: 0), + new TestServerCallContext()).ConfigureAwait(false); + LogReplyTo(recordedOutput, "WriteSecured", writeSecuredReply); + + // Parity: the command itself completed its round-trip — the reply kind is + // WriteSecured and the gateway protocol status is set. The MXAccess outcome + // (Ok for an unprotected provider, MxaccessFailure with hresult 0x80004021 + // when the item is not WriteSecured-eligible) lives in protocol_status + + // hresult, never as a transport fault. The diagnostic message must never + // contain the credential. + Assert.Equal(MxCommandKind.WriteSecured, writeSecuredReply.Kind); + Assert.True( + writeSecuredReply.ProtocolStatus.Code is ProtocolStatusCode.Ok + or ProtocolStatusCode.MxaccessFailure, + $"Unexpected WriteSecured protocol status {writeSecuredReply.ProtocolStatus.Code}."); + Assert.DoesNotContain(verifyPassword, writeSecuredReply.DiagnosticMessage ?? string.Empty, StringComparison.Ordinal); + } + finally + { + streamCancellation.Cancel(); + await ShutDownAsync(fixture, processFactory, sessionId, streamTask).ConfigureAwait(false); + } + + // CLAUDE.md credential contract: passwords and WriteSecured payloads must never + // reach logs. The buffered output covers the gateway ILogger sink, worker + // stdout/stderr, and every direct WriteLine the test body issued. A regression + // that dumped the request envelope, the AuthenticateUserCommand body, or any + // command-level WriteSecured payload would land here and trip this assertion. + Assert.DoesNotContain(verifyPassword, recordedOutput.Captured, StringComparison.Ordinal); + } + + /// + /// Verifies that killing the worker process marks the session + /// with a clean fault classification — the gateway + /// must observe the abnormal exit, transition the session, and surface a non-empty + /// fault description rather than hanging or crashing. + /// + [LiveMxAccessFact] + public async Task GatewaySession_WithLiveWorker_AbnormalWorkerExit_MarksSessionFaulted() + { + string workerExecutablePath = IntegrationTestEnvironment.ResolveLiveMxAccessWorkerExecutablePath(); + Assert.True( + File.Exists(workerExecutablePath), + $"Live MXAccess worker executable was not found at {workerExecutablePath}. Build the worker or set {IntegrationTestEnvironment.LiveMxAccessWorkerExecutableVariableName}."); + + TestWorkerProcessFactory processFactory = new(output); + await using GatewayServiceFixture fixture = new(workerExecutablePath, processFactory, output); + using RecordingServerStreamWriter eventWriter = new(); + + string? sessionId = null; + Task? streamTask = null; + using CancellationTokenSource streamCancellation = new(); + + try + { + OpenSessionReply openReply = await fixture.Service.OpenSession( + new OpenSessionRequest + { + ClientSessionName = "live-mxaccess-abnormal-exit", + ClientCorrelationId = "live-open-abnormal", + CommandTimeout = Duration.FromTimeSpan(CommandTimeout), + }, + new TestServerCallContext()).ConfigureAwait(false); + + sessionId = openReply.SessionId; + Assert.Equal(ProtocolStatusCode.Ok, openReply.ProtocolStatus.Code); + + streamTask = fixture.Service.StreamEvents( + new StreamEventsRequest { SessionId = sessionId }, + eventWriter, + new TestServerCallContext(streamCancellation.Token)); + + // Kill the worker process directly. WorkerClient's read loop hits an + // end-of-stream on the named pipe and routes through SetFaulted; the + // session manager then marks the session Faulted. We avoid CloseSession + // so the transition is driven by the abnormal exit, not a graceful path. + processFactory.KillAllAndDetach(); + + DateTimeOffset waitDeadline = DateTimeOffset.UtcNow + StreamShutdownTimeout; + SessionState observedState = SessionState.Unspecified; + string? observedFault = null; + while (DateTimeOffset.UtcNow < waitDeadline) + { + if (fixture.TryGetSession(sessionId, out GatewaySession? session)) + { + observedState = session.State; + observedFault = session.FinalFault; + if (observedState == SessionState.Faulted) + { + break; + } + } + + await Task.Delay(TimeSpan.FromMilliseconds(50)).ConfigureAwait(false); + } + + output.WriteLine($"AbnormalExit observed_state={observedState} fault={observedFault}"); + Assert.Equal(SessionState.Faulted, observedState); + Assert.False(string.IsNullOrWhiteSpace(observedFault), "Faulted session must carry a non-empty fault description."); + + // The fault classification must come from a known worker-client error code so + // operators get an actionable cause string rather than an opaque exception + // trace. We accept the classifications WorkerClient actually drives on an + // abnormal exit (kill-the-process path): the read loop hits EndOfStream and + // calls SetFaulted with WorkerClientErrorCode.PipeDisconnected and the + // message "Worker pipe disconnected." (see WorkerClient.cs:378-381). The + // earlier broad list (including "worker") matched every WorkerClient fault + // message (they all begin with "Worker"); tighten to the pipe/disconnect/ + // end-of-stream classifications that match THIS path, so a regression that + // routed an unrelated fault here would surface as a test failure rather + // than silently passing (see IntegrationTests-020). "heartbeat" is dropped + // because HeartbeatGraceSeconds (15s) exceeds the StreamShutdownTimeout + // (10s) poll window, so a heartbeat-expired transition can never be + // observed inside this test. + Assert.True( + observedFault!.Contains("pipe disconnected", StringComparison.OrdinalIgnoreCase) + || observedFault.Contains("end of stream", StringComparison.OrdinalIgnoreCase), + $"Fault description '{observedFault}' did not match a known abnormal-exit classification " + + "(expected 'pipe disconnected' or 'end of stream' from WorkerClient's EndOfStream path)."); + + // IntegrationTests-021: also assert the StreamEvents call observed the fault + // — the chain that puts the session into Faulted goes through ReadEventsAsync + // propagating a WorkerClientException into EventStreamService, which calls + // session.MarkFaulted. The gateway then maps the WorkerClientException to an + // RpcException at the public boundary (MxAccessGatewayService.MapException → + // MapWorkerClientException). Polling session.State alone would silently pass + // if a future refactor moved MarkFaulted off the stream-consumption path — + // assert the streamTask itself terminated with a fault so the test couples + // to the actual fault-propagation path. Compare to the inverse assertion in + // the Write parity test (line 217: Assert.False(streamTask.IsFaulted, ...)). + try + { + await streamTask.WaitAsync(StreamShutdownTimeout).ConfigureAwait(false); + } + catch (Exception streamException) + { + output.WriteLine($"StreamEvents task terminated with: {streamException.GetType().Name}: {streamException.Message}"); + } + + Assert.True( + streamTask.IsCompleted, + "StreamEvents task did not complete within the shutdown timeout after the worker was killed."); + Assert.True( + streamTask.IsFaulted, + "StreamEvents task must fault on abnormal worker exit, not complete cleanly — " + + "the fault-propagation path from WorkerClient.SetFaulted through ReadEventsAsync is the contract."); + } + finally + { + streamCancellation.Cancel(); + // sessionId is intentionally null here — the session is already faulted and a + // CloseSession round-trip would just log a cleanup failure. We still wait for + // the worker process exit so the next test starts with a clean state. + await ShutDownAsync(fixture, processFactory, sessionId: null, streamTask).ConfigureAwait(false); + } + } + + /// + /// Closes the session and drains the event stream / worker processes without letting a + /// cleanup timeout mask the original failure from the test body. + /// + /// + /// When , a faulted is rethrown so the + /// test fails on a silent stream-task exception (the Write parity test relies on this so + /// stream-side defects in event delivery are visible). When , all + /// cleanup exceptions are logged and swallowed so a real test-body assertion failure is not + /// masked by a shutdown timeout (the original IntegrationTests-004 fix). + /// + private async Task ShutDownAsync( + GatewayServiceFixture fixture, + TestWorkerProcessFactory processFactory, + string? sessionId, + Task? streamTask, + bool propagateStreamFaults = false) + { + Exception? streamFault = null; + + try + { + if (!string.IsNullOrWhiteSpace(sessionId)) + { + await CloseSessionAsync(fixture, sessionId).ConfigureAwait(false); + } + } + catch (Exception ex) + { + output.WriteLine($"Cleanup error during session close: {ex}"); + } + + if (streamTask is not null) + { + try + { + await streamTask.WaitAsync(StreamShutdownTimeout).ConfigureAwait(false); + } + catch (OperationCanceledException ex) + { + // A linked CancellationToken on the streaming TestServerCallContext is the + // intended way to stop StreamEvents promptly — treat the resulting + // OperationCanceledException as a clean shutdown, not a fault. + output.WriteLine($"Event stream task cancelled during shutdown: {ex.Message}"); + } + catch (RpcException ex) when (ex.StatusCode == StatusCode.Cancelled) + { + // MxAccessGatewayService.MapException intentionally converts the + // server-side OperationCanceledException into RpcException(Cancelled) + // so real gRPC clients see the standard cancellation status. Treat + // that as the same clean-shutdown signal as a raw OCE. + output.WriteLine($"Event stream task cancelled during shutdown: {ex.Status.Detail}"); + } + catch (Exception ex) + { + // Cleanup runs in a finally block. By default a faulted StreamEvents task is + // logged and swallowed so a test-body assertion failure is not masked. When + // the caller opts into propagateStreamFaults (the Write parity test), we + // rethrow the fault after the worker-process wait so a silent stream-side + // defect actually fails the test. + output.WriteLine($"Event stream task faulted during shutdown: {ex}"); + if (propagateStreamFaults) + { + streamFault = ex; + } + } + } + + try + { + await processFactory.WaitForProcessesAsync(StreamShutdownTimeout).ConfigureAwait(false); + } + catch (Exception ex) + { + output.WriteLine($"Cleanup error while waiting for worker processes to exit: {ex}"); + } + + if (streamFault is not null) + { + throw streamFault; + } + } + + private static MxCommandRequest CreateRegisterRequest(string sessionId) + { + return new MxCommandRequest + { + SessionId = sessionId, + ClientCorrelationId = "live-register", + Command = new MxCommand + { + Kind = MxCommandKind.Register, + Register = new RegisterCommand + { + ClientName = IntegrationTestEnvironment.LiveMxAccessClientName, + }, + }, + }; + } + + private static MxCommandRequest CreateAddItemRequest( + string sessionId, + int serverHandle) + { + return new MxCommandRequest + { + SessionId = sessionId, + ClientCorrelationId = "live-add-item", + Command = new MxCommand + { + Kind = MxCommandKind.AddItem, + AddItem = new AddItemCommand + { + ServerHandle = serverHandle, + ItemDefinition = IntegrationTestEnvironment.LiveMxAccessItem, + }, + }, + }; + } + + private static MxCommandRequest CreateAdviseRequest( + string sessionId, + int serverHandle, + int itemHandle) + { + return new MxCommandRequest + { + SessionId = sessionId, + ClientCorrelationId = "live-advise", + Command = new MxCommand + { + Kind = MxCommandKind.Advise, + Advise = new AdviseCommand + { + ServerHandle = serverHandle, + ItemHandle = itemHandle, + }, + }, + }; + } + + private static MxCommandRequest CreateWriteRequest( + string sessionId, + int serverHandle, + int itemHandle) + { + return new MxCommandRequest + { + SessionId = sessionId, + ClientCorrelationId = "live-write", + Command = new MxCommand + { + Kind = MxCommandKind.Write, + Write = new WriteCommand + { + ServerHandle = serverHandle, + ItemHandle = itemHandle, + Value = new MxValue + { + DataType = MxDataType.Integer, + Int32Value = 1, + }, + }, + }, + }; + } + + private static MxCommandRequest CreateUnAdviseRequest( + string sessionId, + int serverHandle, + int itemHandle) + { + return new MxCommandRequest + { + SessionId = sessionId, + ClientCorrelationId = "live-unadvise", + Command = new MxCommand + { + Kind = MxCommandKind.UnAdvise, + UnAdvise = new UnAdviseCommand + { + ServerHandle = serverHandle, + ItemHandle = itemHandle, + }, + }, + }; + } + + private static MxCommandRequest CreateRemoveItemRequest( + string sessionId, + int serverHandle, + int itemHandle) + { + return new MxCommandRequest + { + SessionId = sessionId, + ClientCorrelationId = "live-remove-item", + Command = new MxCommand + { + Kind = MxCommandKind.RemoveItem, + RemoveItem = new RemoveItemCommand + { + ServerHandle = serverHandle, + ItemHandle = itemHandle, + }, + }, + }; + } + + private static MxCommandRequest CreateUnregisterRequest( + string sessionId, + int serverHandle) + { + return new MxCommandRequest + { + SessionId = sessionId, + ClientCorrelationId = "live-unregister", + Command = new MxCommand + { + Kind = MxCommandKind.Unregister, + Unregister = new UnregisterCommand + { + ServerHandle = serverHandle, + }, + }, + }; + } + + private static MxCommandRequest CreateAuthenticateUserRequest( + string sessionId, + int serverHandle, + string verifyUser, + string verifyPassword) + { + return new MxCommandRequest + { + SessionId = sessionId, + ClientCorrelationId = "live-authenticate-user", + Command = new MxCommand + { + Kind = MxCommandKind.AuthenticateUser, + AuthenticateUser = new AuthenticateUserCommand + { + ServerHandle = serverHandle, + VerifyUser = verifyUser, + VerifyUserPassword = verifyPassword, + }, + }, + }; + } + + private static MxCommandRequest CreateWriteSecuredRequest( + string sessionId, + int serverHandle, + int itemHandle, + int currentUserId, + int verifierUserId) + { + return new MxCommandRequest + { + SessionId = sessionId, + ClientCorrelationId = "live-write-secured", + Command = new MxCommand + { + Kind = MxCommandKind.WriteSecured, + WriteSecured = new WriteSecuredCommand + { + ServerHandle = serverHandle, + ItemHandle = itemHandle, + CurrentUserId = currentUserId, + VerifierUserId = verifierUserId, + Value = new MxValue + { + DataType = MxDataType.Integer, + Int32Value = 2, + }, + }, + }, + }; + } + + private static (string VerifyUser, string VerifyPassword) ResolveLiveMxAccessSecuredCredentials() + { + string verifyUser = Environment.GetEnvironmentVariable("MXGATEWAY_LIVE_MXACCESS_WRITE_SECURED_USER") + ?? "admin"; + string verifyPassword = Environment.GetEnvironmentVariable("MXGATEWAY_LIVE_MXACCESS_WRITE_SECURED_PASSWORD") + ?? "admin123"; + return (verifyUser, verifyPassword); + } + + private static int CountMatchingEvents( + RecordingServerStreamWriter writer, + Func predicate) + { + int count = 0; + foreach (MxEvent message in writer.Messages) + { + if (predicate(message)) + { + count++; + } + } + + return count; + } + + private async Task CloseSessionAsync( + GatewayServiceFixture fixture, + string sessionId) + { + CloseSessionReply closeReply = await fixture.Service.CloseSession( + new CloseSessionRequest + { + SessionId = sessionId, + ClientCorrelationId = "live-close", + }, + new TestServerCallContext()).ConfigureAwait(false); + + output.WriteLine($"CloseSession status={closeReply.ProtocolStatus.Code} final_state={closeReply.FinalState}"); + } + + private void LogReply( + string method, + MxCommandReply reply) + { + LogReplyTo(output, method, reply); + } + + private static void LogReplyTo( + ITestOutputHelper sink, + string method, + MxCommandReply reply) + { + sink.WriteLine( + $"{method} status={reply.ProtocolStatus.Code} hresult={reply.Hresult} diagnostic={reply.DiagnosticMessage}"); + + foreach (MxStatusProxy status in reply.Statuses) + { + sink.WriteLine( + $"{method} mxstatus success={status.Success} category={status.Category} detail={status.Detail} text={status.DiagnosticText}"); + } + } + + private void LogEvent(MxEvent dataChange) + { + output.WriteLine( + $"Event family={dataChange.Family} worker_sequence={dataChange.WorkerSequence} server_handle={dataChange.ServerHandle} item_handle={dataChange.ItemHandle} quality={dataChange.Quality}"); + output.WriteLine( + $"Event value_type={dataChange.Value?.DataType} raw_status={dataChange.RawStatus}"); + } + + /// + /// Test fixture that assembles the gateway service with a worker process factory for live MXAccess testing. + /// + private sealed class GatewayServiceFixture : IAsyncDisposable + { + private readonly GatewayMetrics _metrics = new(); + private readonly SessionRegistry _registry = new(); + private readonly ILoggerFactory _loggerFactory; + + /// + /// Initializes the fixture with worker executable path, factory, and test output helper. + /// + /// Path to the worker process executable. + /// Factory for creating worker processes. + /// Test output helper for logging. + public GatewayServiceFixture( + string workerExecutablePath, + IWorkerProcessFactory processFactory, + ITestOutputHelper output) + { + IOptions options = Options.Create(CreateOptions(workerExecutablePath)); + _loggerFactory = LoggerFactory.Create(builder => builder.AddProvider(new TestOutputLoggerProvider(output))); + WorkerProcessLauncher launcher = new( + options, + processFactory, + new WorkerProcessStartedProbe(), + _metrics); + SessionWorkerClientFactory workerClientFactory = new( + launcher, + options, + _metrics, + _loggerFactory); + SessionManager sessionManager = new( + _registry, + workerClientFactory, + options, + _metrics, + logger: _loggerFactory.CreateLogger()); + MxAccessGrpcMapper mapper = new(); + EventStreamService eventStreamService = new( + sessionManager, + options, + mapper, + _metrics, + _loggerFactory.CreateLogger()); + + Service = new MxAccessGatewayService( + sessionManager, + new GatewayRequestIdentityAccessor(), + new AllowAllConstraintEnforcer(), + new MxAccessGrpcRequestValidator(), + mapper, + eventStreamService, + _metrics, + _loggerFactory.CreateLogger(), + new ZB.MOM.WW.MxGateway.Server.Alarms.GatewayAlarmMonitor( + sessionManager, + options, + _loggerFactory.CreateLogger())); + } + + /// + /// The assembled gateway service instance. + /// + public MxAccessGatewayService Service { get; } + + /// + /// Looks up a session by id directly against the in-process registry. The abnormal + /// worker-exit test needs to observe the session's State / FinalFault as the gateway + /// transitions it to Faulted, which the public gRPC API only exposes indirectly via + /// CloseSession's reply (and not before a graceful close completes). + /// + public bool TryGetSession(string sessionId, [MaybeNullWhen(false)] out GatewaySession session) + { + return _registry.TryGet(sessionId, out session); + } + + /// + /// Disposes the fixture resources and closes all sessions. + /// + public async ValueTask DisposeAsync() + { + foreach (GatewaySession session in _registry.Snapshot()) + { + await session.DisposeAsync().ConfigureAwait(false); + } + + _loggerFactory.Dispose(); + _metrics.Dispose(); + } + + private static GatewayOptions CreateOptions(string workerExecutablePath) + { + return new GatewayOptions + { + Worker = new WorkerOptions + { + ExecutablePath = workerExecutablePath, + StartupTimeoutSeconds = 30, + ShutdownTimeoutSeconds = 15, + HeartbeatIntervalSeconds = 5, + HeartbeatGraceSeconds = 15, + MaxMessageBytes = WorkerFrameProtocolOptions.DefaultMaxMessageBytes, + RequiredArchitecture = WorkerArchitecture.X86, + }, + Sessions = new SessionOptions + { + DefaultCommandTimeoutSeconds = 15, + MaxSessions = 1, + }, + Events = new EventOptions + { + QueueCapacity = 32, + }, + }; + } + } + + /// + /// Gathers messages written to a server stream for test inspection. + /// + private sealed class RecordingServerStreamWriter : IServerStreamWriter, IDisposable + { + private readonly object syncRoot = new(); + private readonly List messages = []; + private readonly SemaphoreSlim messageArrived = new(0); + + /// + /// All messages that have been written to the stream. + /// + public IReadOnlyList Messages + { + get + { + lock (syncRoot) + { + return messages.ToArray(); + } + } + } + + /// + /// Inherited write options. + /// + public WriteOptions? WriteOptions { get; set; } + + /// + /// Records the message and signals any pending waiter. + /// + /// The message to write. + public Task WriteAsync(T message) + { + lock (syncRoot) + { + messages.Add(message); + } + + messageArrived.Release(); + return Task.CompletedTask; + } + + /// + /// Waits for the first recorded message that satisfies , + /// up to the specified timeout. Earlier non-matching messages (for example a + /// registration-state bootstrap event) are skipped rather than treated as the result. + /// + /// Filter the awaited message must satisfy. + /// The maximum total time to wait. + /// + /// Token observed alongside the timeout so a per-test cancellation (for example the + /// gRPC call context's token) aborts the wait promptly instead of hanging until the + /// timeout elapses. + /// + /// The first message that satisfies the predicate. + public async Task WaitForMessageAsync( + Func predicate, + TimeSpan timeout, + CancellationToken cancellationToken = default) + { + using CancellationTokenSource timeoutCancellation = new(timeout); + using CancellationTokenSource linkedCancellation = + CancellationTokenSource.CreateLinkedTokenSource(timeoutCancellation.Token, cancellationToken); + int scanned = 0; + + while (true) + { + T[] snapshot; + lock (syncRoot) + { + snapshot = messages.ToArray(); + } + + for (; scanned < snapshot.Length; scanned++) + { + if (predicate(snapshot[scanned])) + { + return snapshot[scanned]; + } + } + + try + { + await messageArrived.WaitAsync(linkedCancellation.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (timeoutCancellation.IsCancellationRequested) + { + throw new TimeoutException( + $"No stream message satisfied the predicate within {timeout}. Recorded {scanned} message(s)."); + } + } + } + + /// + /// Releases the wait handle backing messageArrived. The writer owns an + /// field so it must be disposable itself; the leak + /// is otherwise bounded only by how many opt-in live tests run. + /// + public void Dispose() + { + messageArrived.Dispose(); + } + } + + /// + /// Minimal stub for invoking the gRPC service + /// in-process. It is a hand-written fake with no verification behavior — it + /// only supplies the context values the service reads during a call. + /// + private sealed class TestServerCallContext(CancellationToken cancellationToken = default) : ServerCallContext + { + private readonly Metadata requestHeaders = []; + private readonly Metadata responseTrailers = []; + private readonly Dictionary userState = []; + private Status status; + private WriteOptions? writeOptions; + + /// + protected override string MethodCore => "/mxaccess_gateway.v1.MxAccessGateway/Test"; + + /// + protected override string HostCore => "localhost"; + + /// + protected override string PeerCore => "ipv4:127.0.0.1:5000"; + + /// + protected override DateTime DeadlineCore => DateTime.UtcNow.AddMinutes(1); + + /// + protected override Metadata RequestHeadersCore => requestHeaders; + + /// + protected override CancellationToken CancellationTokenCore => cancellationToken; + + /// + protected override Metadata ResponseTrailersCore => responseTrailers; + + /// + protected override Status StatusCore + { + get => status; + set => status = value; + } + + /// + protected override WriteOptions? WriteOptionsCore + { + get => writeOptions; + set => writeOptions = value; + } + + /// + protected override AuthContext AuthContextCore { get; } = new( + string.Empty, + new Dictionary>(StringComparer.Ordinal)); + + /// + protected override IDictionary UserStateCore => userState; + + /// + protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders) + { + return Task.CompletedTask; + } + + /// + protected override ContextPropagationToken CreatePropagationTokenCore( + ContextPropagationOptions? options) + { + throw new NotSupportedException(); + } + } + + /// + /// Factory that launches worker processes and records their outputs for testing. + /// + private sealed class TestWorkerProcessFactory(ITestOutputHelper output) : IWorkerProcessFactory + { + private readonly ConcurrentBag processes = []; + + /// + public IWorkerProcess Start(ProcessStartInfo startInfo) + { + startInfo.RedirectStandardError = true; + startInfo.RedirectStandardOutput = true; + startInfo.UseShellExecute = false; + + Process process = new() + { + StartInfo = startInfo, + EnableRaisingEvents = true, + }; + + process.OutputDataReceived += (_, args) => WriteWorkerOutput("stdout", args.Data); + process.ErrorDataReceived += (_, args) => WriteWorkerOutput("stderr", args.Data); + + if (!process.Start()) + { + process.Dispose(); + throw new InvalidOperationException("Worker process failed to start."); + } + + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + TestWorkerProcess workerProcess = new(process); + processes.Add(workerProcess); + output.WriteLine($"WorkerProcess started pid={workerProcess.Id} path={startInfo.FileName}"); + + return workerProcess; + } + + /// + public async Task WaitForProcessesAsync(TimeSpan timeout) + { + foreach (TestWorkerProcess process in processes) + { + if (process.HasExited) + { + output.WriteLine($"WorkerProcess exited pid={process.Id} exit_code={process.ExitCode}"); + continue; + } + + using CancellationTokenSource timeoutCancellation = new(timeout); + await process.WaitForExitAsync(timeoutCancellation.Token).ConfigureAwait(false); + output.WriteLine($"WorkerProcess exited pid={process.Id} exit_code={process.ExitCode}"); + } + } + + /// + /// Kills every recorded worker process tree so the abnormal-exit test can simulate a + /// crashed worker without going through the graceful shutdown handshake. Failures to + /// kill an already-dead process are tolerated. + /// + public void KillAllAndDetach() + { + foreach (TestWorkerProcess process in processes) + { + if (process.HasExited) + { + continue; + } + + try + { + process.Kill(entireProcessTree: true); + output.WriteLine($"WorkerProcess killed pid={process.Id} (abnormal-exit simulation)"); + } + catch (InvalidOperationException ex) + { + output.WriteLine($"WorkerProcess kill skipped pid={process.Id}: {ex.Message}"); + } + } + } + + private void WriteWorkerOutput( + string streamName, + string? line) + { + if (!string.IsNullOrWhiteSpace(line)) + { + output.WriteLine($"worker_{streamName}: {line}"); + } + } + } + + /// + /// Adapter wrapping a System.Diagnostics.Process as IWorkerProcess for testing. + /// + private sealed class TestWorkerProcess(Process process) : IWorkerProcess + { + /// + public int Id => process.Id; + + /// + public bool HasExited => process.HasExited; + + /// + public int? ExitCode => process.HasExited ? process.ExitCode : null; + + /// + public async ValueTask WaitForExitAsync(CancellationToken cancellationToken) + { + await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + } + + /// + public void Kill(bool entireProcessTree) + { + process.Kill(entireProcessTree); + } + + /// + public void Dispose() + { + process.Dispose(); + } + } + + /// + /// Logger provider that writes all output to the test output helper. + /// + private sealed class TestOutputLoggerProvider(ITestOutputHelper output) : ILoggerProvider + { + /// + public ILogger CreateLogger(string categoryName) + { + return new TestOutputLogger(output, categoryName); + } + + /// + public void Dispose() + { + } + } + + /// + /// Logger that writes messages to the test output helper. + /// + private sealed class TestOutputLogger( + ITestOutputHelper output, + string categoryName) : ILogger + { + /// + public IDisposable? BeginScope(TState state) + where TState : notnull + { + return null; + } + + /// + public bool IsEnabled(LogLevel logLevel) + { + return logLevel >= LogLevel.Information; + } + + /// + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + if (!IsEnabled(logLevel)) + { + return; + } + + output.WriteLine($"{logLevel} {categoryName}: {formatter(state, exception)}"); + if (exception is not null) + { + output.WriteLine(exception.ToString()); + } + } + } + + /// + /// Buffering wrapper around an that mirrors every line + /// written through it into a the test owns. The WriteSecured + /// parity test (IntegrationTests-019) uses this to make CLAUDE.md's "passwords and + /// WriteSecured payloads must never reach logs" rule a property of the entire + /// test output stream — gateway entries (echoed via + /// ), worker stdout/stderr (echoed via + /// ), and direct + /// output.WriteLine calls all land in the same buffer, so a future maintenance + /// change that prints a credential through any of those channels is caught by the + /// assertion rather than slipping past the existing DiagnosticMessage check. + /// + private sealed class RecordingTestOutputHelper(ITestOutputHelper inner) : ITestOutputHelper + { + private readonly StringBuilder buffer = new(); + private readonly object syncRoot = new(); + + public string Captured + { + get + { + lock (syncRoot) + { + return buffer.ToString(); + } + } + } + + public void WriteLine(string message) + { + lock (syncRoot) + { + buffer.AppendLine(message); + } + + inner.WriteLine(message); + } + + public void WriteLine(string format, params object[] args) + { + string formatted = string.Format(System.Globalization.CultureInfo.InvariantCulture, format, args); + lock (syncRoot) + { + buffer.AppendLine(formatted); + } + + inner.WriteLine(format, args); + } + } + + private sealed class AllowAllConstraintEnforcer : IConstraintEnforcer + { + public Task CheckReadTagAsync( + ApiKeyIdentity? identity, + string tagAddress, + CancellationToken cancellationToken) => Task.FromResult(null); + + public Task CheckReadHandleAsync( + ApiKeyIdentity? identity, + GatewaySession session, + int serverHandle, + int itemHandle, + CancellationToken cancellationToken) => Task.FromResult(null); + + public Task CheckWriteHandleAsync( + ApiKeyIdentity? identity, + GatewaySession session, + int serverHandle, + int itemHandle, + CancellationToken cancellationToken) => Task.FromResult(null); + + public Task RecordDenialAsync( + ApiKeyIdentity? identity, + string commandKind, + string target, + ConstraintFailure failure, + CancellationToken cancellationToken) => Task.CompletedTask; + } +} diff --git a/src/MxGateway.IntegrationTests/MxGateway.IntegrationTests.csproj b/src/ZB.MOM.WW.MxGateway.IntegrationTests/ZB.MOM.WW.MxGateway.IntegrationTests.csproj similarity index 73% rename from src/MxGateway.IntegrationTests/MxGateway.IntegrationTests.csproj rename to src/ZB.MOM.WW.MxGateway.IntegrationTests/ZB.MOM.WW.MxGateway.IntegrationTests.csproj index 27e10c0..c6666e1 100644 --- a/src/MxGateway.IntegrationTests/MxGateway.IntegrationTests.csproj +++ b/src/ZB.MOM.WW.MxGateway.IntegrationTests/ZB.MOM.WW.MxGateway.IntegrationTests.csproj @@ -17,8 +17,8 @@ - - + + diff --git a/src/ZB.MOM.WW.MxGateway.Server/Alarms/AlarmsServiceCollectionExtensions.cs b/src/ZB.MOM.WW.MxGateway.Server/Alarms/AlarmsServiceCollectionExtensions.cs new file mode 100644 index 0000000..26b7e83 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Alarms/AlarmsServiceCollectionExtensions.cs @@ -0,0 +1,22 @@ +namespace ZB.MOM.WW.MxGateway.Server.Alarms; + +/// Service-collection wiring for the gateway's central alarm monitor. +public static class AlarmsServiceCollectionExtensions +{ + /// + /// Registers the always-on as both + /// the singleton and a hosted + /// service, so it starts with the gateway host and is shared by the + /// gRPC alarm surface and the dashboard. + /// + /// Service collection to register services in. + /// The service collection for chaining. + public static IServiceCollection AddGatewayAlarms(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(provider => provider.GetRequiredService()); + services.AddHostedService(provider => provider.GetRequiredService()); + + return services; + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs b/src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs new file mode 100644 index 0000000..ada7dfd --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs @@ -0,0 +1,693 @@ +using System.Threading.Channels; +using Microsoft.Extensions.Options; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Server.Configuration; +using ZB.MOM.WW.MxGateway.Server.Sessions; + +namespace ZB.MOM.WW.MxGateway.Server.Alarms; + +/// +/// The gateway's always-on alarm monitor and broker. It owns one +/// gateway-managed worker session dedicated to alarms, keeps an in-process +/// cache of the active-alarm set fed by that session's transition events +/// (reconciled periodically against the worker's snapshot), and fans the +/// feed out to any number of subscribers. +/// The session is re-opened transparently if the worker faults. +/// +public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmService +{ + private const string MonitorClientName = "gateway-alarm-monitor"; + private const string BackendName = "Galaxy"; + private const int SubscriberQueueCapacity = 2048; + private static readonly TimeSpan RestartBackoff = TimeSpan.FromSeconds(5); + private static readonly TimeSpan StartupGrace = TimeSpan.FromSeconds(2); + + private readonly ISessionManager _sessionManager; + private readonly AlarmsOptions _options; + private readonly ILogger _logger; + + private readonly object _sync = new(); + private readonly Dictionary _alarms = new(StringComparer.Ordinal); + private readonly List _subscribers = []; + + private volatile GatewayAlarmMonitorState _state = GatewayAlarmMonitorState.Disabled; + private volatile string? _lastError; + private GatewaySession? _session; + + /// Initializes the gateway alarm monitor. + /// Gateway session manager. + /// Gateway options carrying the alarm configuration. + /// Diagnostic logger. + public GatewayAlarmMonitor( + ISessionManager sessionManager, + IOptions options, + ILogger logger) + { + _sessionManager = sessionManager ?? throw new ArgumentNullException(nameof(sessionManager)); + _options = (options ?? throw new ArgumentNullException(nameof(options))).Value.Alarms; + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public GatewayAlarmMonitorState State => _state; + + /// + public string? LastError => _lastError; + + /// + public int? WorkerProcessId + { + get { lock (_sync) { return _session?.WorkerProcessId; } } + } + + /// + public IReadOnlyList CurrentAlarms + { + get + { + lock (_sync) + { + return _alarms.Values.Select(alarm => alarm.Clone()).ToArray(); + } + } + } + + /// + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + if (!_options.Enabled) + { + _state = GatewayAlarmMonitorState.Disabled; + _logger.LogInformation("Gateway alarm monitor disabled (MxGateway:Alarms:Enabled is false)."); + return; + } + + string subscription = ResolveSubscription(); + if (string.IsNullOrWhiteSpace(subscription)) + { + _state = GatewayAlarmMonitorState.Faulted; + _lastError = "MxGateway:Alarms is enabled but no SubscriptionExpression / DefaultArea is configured."; + _logger.LogError("{Diagnostic}", _lastError); + return; + } + + // Brief grace so worker-process launching and startup orphan cleanup + // settle before the monitor opens its own session. + try + { + await Task.Delay(StartupGrace, stoppingToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + return; + } + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await RunMonitorAsync(subscription, stoppingToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception exception) + { + _state = GatewayAlarmMonitorState.Faulted; + _lastError = exception.Message; + _logger.LogWarning( + exception, + "Gateway alarm monitor lifecycle faulted; restarting in {Backoff}.", + RestartBackoff); + try + { + await Task.Delay(RestartBackoff, stoppingToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; + } + } + } + + _state = GatewayAlarmMonitorState.Disabled; + } + + // One monitoring lifecycle: open a session, subscribe alarms, reconcile, + // then consume transition events until the session ends or is cancelled. + private async Task RunMonitorAsync(string subscription, CancellationToken stoppingToken) + { + _state = GatewayAlarmMonitorState.Starting; + GatewaySession session = await _sessionManager.OpenSessionAsync( + new SessionOpenRequest(BackendName, MonitorClientName, Guid.NewGuid().ToString("N"), CommandTimeout: null), + MonitorClientName, + stoppingToken) + .ConfigureAwait(false); + lock (_sync) { _session = session; } + + try + { + await SubscribeAlarmsAsync(session.SessionId, subscription, stoppingToken).ConfigureAwait(false); + await ReconcileAsync(session.SessionId, stoppingToken).ConfigureAwait(false); + + _state = GatewayAlarmMonitorState.Monitoring; + _lastError = null; + _logger.LogInformation( + "Gateway alarm monitor active on {Subscription} (session {SessionId}, worker pid {WorkerPid}).", + subscription, + session.SessionId, + session.WorkerProcessId); + + using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken); + Task reconcileLoop = ReconcileLoopAsync(session.SessionId, linked.Token); + try + { + await foreach (WorkerEvent workerEvent in _sessionManager + .ReadEventsAsync(session.SessionId, linked.Token) + .ConfigureAwait(false)) + { + MxEvent? mxEvent = workerEvent.Event; + if (mxEvent is { BodyCase: MxEvent.BodyOneofCase.OnAlarmTransition } + && mxEvent.OnAlarmTransition is not null) + { + ApplyTransition(mxEvent.OnAlarmTransition); + } + } + } + finally + { + await linked.CancelAsync().ConfigureAwait(false); + try + { + await reconcileLoop.ConfigureAwait(false); + } + catch + { + // Reconcile-loop teardown errors are not actionable here. + } + } + + // The event stream ended without cancellation — the worker session + // closed or faulted. Surface it so the supervisor loop restarts. + throw new InvalidOperationException("Alarm monitor worker event stream ended."); + } + finally + { + lock (_sync) { _session = null; } + ClearCache(); + try + { + await _sessionManager.CloseSessionAsync(session.SessionId, CancellationToken.None).ConfigureAwait(false); + } + catch (Exception exception) + { + _logger.LogDebug(exception, "Closing alarm monitor session {SessionId} failed.", session.SessionId); + } + } + } + + private async Task SubscribeAlarmsAsync(string sessionId, string subscription, CancellationToken cancellationToken) + { + WorkerCommandReply reply = await _sessionManager.InvokeAsync( + sessionId, + new WorkerCommand + { + Command = new MxCommand + { + Kind = MxCommandKind.SubscribeAlarms, + SubscribeAlarms = new SubscribeAlarmsCommand { SubscriptionExpression = subscription }, + }, + }, + cancellationToken) + .ConfigureAwait(false); + + ProtocolStatusCode? code = reply.Reply?.ProtocolStatus?.Code; + if (code != ProtocolStatusCode.Ok) + { + string diagnostic = reply.Reply?.DiagnosticMessage + ?? reply.Reply?.ProtocolStatus?.Message + ?? $"status {code}"; + throw new InvalidOperationException($"Worker rejected SubscribeAlarms: {diagnostic}"); + } + } + + private async Task ReconcileLoopAsync(string sessionId, CancellationToken cancellationToken) + { + try + { + int seconds = Math.Max(5, _options.ReconcileIntervalSeconds); + using PeriodicTimer timer = new(TimeSpan.FromSeconds(seconds)); + while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false)) + { + try + { + await ReconcileAsync(sessionId, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) + { + _logger.LogDebug(exception, "Alarm reconcile pass failed; keeping the current cache."); + } + } + } + catch (OperationCanceledException) + { + } + } + + private async Task ReconcileAsync(string sessionId, CancellationToken cancellationToken) + { + WorkerCommandReply reply = await _sessionManager.InvokeAsync( + sessionId, + new WorkerCommand + { + Command = new MxCommand + { + Kind = MxCommandKind.QueryActiveAlarms, + QueryActiveAlarmsCommand = new QueryActiveAlarmsCommand { AlarmFilterPrefix = string.Empty }, + }, + }, + cancellationToken) + .ConfigureAwait(false); + + if (reply.Reply?.ProtocolStatus?.Code != ProtocolStatusCode.Ok) + { + return; + } + + QueryActiveAlarmsReplyPayload? payload = reply.Reply.QueryActiveAlarms; + if (payload is not null) + { + ApplyReconcile(payload.Snapshots); + } + } + + // Applies a live transition to the cache and broadcasts it to subscribers. + private void ApplyTransition(OnAlarmTransitionEvent transition) + { + string reference = transition.AlarmFullReference ?? string.Empty; + if (reference.Length == 0) + { + return; + } + + lock (_sync) + { + if (transition.TransitionKind == AlarmTransitionKind.Clear) + { + _alarms.Remove(reference); + } + else + { + _alarms[reference] = SnapshotFromTransition(transition); + } + + Broadcast(new AlarmFeedMessage { Transition = transition }, reference); + } + } + + // Replaces the cache with the worker's authoritative snapshot, broadcasting + // a synthetic transition for any alarm the live stream missed. + private void ApplyReconcile(IEnumerable snapshots) + { + Dictionary next = new(StringComparer.Ordinal); + foreach (ActiveAlarmSnapshot snapshot in snapshots) + { + if (!string.IsNullOrEmpty(snapshot.AlarmFullReference)) + { + next[snapshot.AlarmFullReference] = snapshot; + } + } + + lock (_sync) + { + foreach (KeyValuePair existing in _alarms) + { + if (!next.ContainsKey(existing.Key)) + { + Broadcast( + new AlarmFeedMessage { Transition = TransitionFromSnapshot(existing.Value, AlarmTransitionKind.Clear) }, + existing.Key); + } + } + + foreach (KeyValuePair incoming in next) + { + if (!_alarms.ContainsKey(incoming.Key)) + { + Broadcast( + new AlarmFeedMessage { Transition = TransitionFromSnapshot(incoming.Value, AlarmTransitionKind.Raise) }, + incoming.Key); + } + } + + _alarms.Clear(); + foreach (KeyValuePair incoming in next) + { + _alarms[incoming.Key] = incoming.Value; + } + } + } + + // Caller holds _sync. Pushes a feed message to every matching subscriber; + // a subscriber that has fallen behind is completed with an error and dropped. + private void Broadcast(AlarmFeedMessage message, string reference) + { + for (int index = _subscribers.Count - 1; index >= 0; index--) + { + Subscriber subscriber = _subscribers[index]; + if (!subscriber.Matches(reference)) + { + continue; + } + + if (!subscriber.Channel.Writer.TryWrite(message)) + { + subscriber.Channel.Writer.TryComplete(new InvalidOperationException( + "Alarm feed subscriber fell behind and was dropped; reconnect to re-snapshot.")); + _subscribers.RemoveAt(index); + } + } + } + + private void ClearCache() + { + lock (_sync) + { + _alarms.Clear(); + } + } + + /// + public async IAsyncEnumerable StreamAsync( + string? alarmFilterPrefix, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + string prefix = alarmFilterPrefix ?? string.Empty; + Channel channel = Channel.CreateBounded( + new BoundedChannelOptions(SubscriberQueueCapacity) + { + FullMode = BoundedChannelFullMode.Wait, + SingleReader = true, + SingleWriter = false, + }); + Subscriber subscriber = new(channel, prefix); + + ActiveAlarmSnapshot[] snapshot; + lock (_sync) + { + // Register before snapshotting under the same lock so no transition + // can slip between the snapshot and the live stream. + _subscribers.Add(subscriber); + snapshot = _alarms.Values + .Where(alarm => prefix.Length == 0 + || alarm.AlarmFullReference.StartsWith(prefix, StringComparison.Ordinal)) + .Select(alarm => alarm.Clone()) + .ToArray(); + } + + try + { + foreach (ActiveAlarmSnapshot alarm in snapshot) + { + yield return new AlarmFeedMessage { ActiveAlarm = alarm }; + } + + yield return new AlarmFeedMessage { SnapshotComplete = true }; + + await foreach (AlarmFeedMessage message in channel.Reader + .ReadAllAsync(cancellationToken) + .ConfigureAwait(false)) + { + yield return message; + } + } + finally + { + lock (_sync) { _subscribers.Remove(subscriber); } + channel.Writer.TryComplete(); + } + } + + /// + public async Task AcknowledgeAsync( + AcknowledgeAlarmRequest request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + string? sessionId; + lock (_sync) { sessionId = _session?.SessionId; } + if (sessionId is null || _state != GatewayAlarmMonitorState.Monitoring) + { + return new AcknowledgeAlarmReply + { + CorrelationId = request.ClientCorrelationId, + ProtocolStatus = new ProtocolStatus + { + Code = ProtocolStatusCode.WorkerUnavailable, + Message = "Gateway alarm monitor is not currently active.", + }, + DiagnosticMessage = _lastError ?? "Alarm monitor is not running.", + }; + } + + MxCommand? command = BuildAcknowledgeCommand(request, out string? parseError); + if (command is null) + { + return new AcknowledgeAlarmReply + { + CorrelationId = request.ClientCorrelationId, + ProtocolStatus = new ProtocolStatus + { + Code = ProtocolStatusCode.InvalidRequest, + Message = parseError ?? "Invalid acknowledge request.", + }, + DiagnosticMessage = parseError ?? "Invalid acknowledge request.", + }; + } + + WorkerCommandReply workerReply = await _sessionManager + .InvokeAsync(sessionId, new WorkerCommand { Command = command }, cancellationToken) + .ConfigureAwait(false); + + MxCommandReply mxReply = workerReply.Reply ?? new MxCommandReply + { + ProtocolStatus = new ProtocolStatus + { + Code = ProtocolStatusCode.ProtocolViolation, + Message = "Worker reply did not include an MxCommandReply.", + }, + }; + + AcknowledgeAlarmReply reply = new() + { + CorrelationId = request.ClientCorrelationId, + ProtocolStatus = mxReply.ProtocolStatus ?? new ProtocolStatus { Code = ProtocolStatusCode.Ok }, + DiagnosticMessage = mxReply.DiagnosticMessage ?? string.Empty, + }; + if (mxReply.HasHresult) + { + reply.Hresult = mxReply.Hresult; + } + + return reply; + } + + private string ResolveSubscription() + { + if (!string.IsNullOrWhiteSpace(_options.SubscriptionExpression)) + { + return _options.SubscriptionExpression; + } + + if (!string.IsNullOrWhiteSpace(_options.DefaultArea)) + { + return $@"\\{Environment.MachineName}\Galaxy!{_options.DefaultArea}"; + } + + return string.Empty; + } + + private static MxCommand? BuildAcknowledgeCommand(AcknowledgeAlarmRequest request, out string? parseError) + { + parseError = null; + if (string.IsNullOrWhiteSpace(request.AlarmFullReference)) + { + parseError = "alarm_full_reference is required."; + return null; + } + + string comment = request.Comment ?? string.Empty; + string operatorUser = request.OperatorUser ?? string.Empty; + + if (Guid.TryParse(request.AlarmFullReference, out Guid guid)) + { + return new MxCommand + { + Kind = MxCommandKind.AcknowledgeAlarm, + AcknowledgeAlarmCommand = new AcknowledgeAlarmCommand + { + AlarmGuid = guid.ToString(), + Comment = comment, + OperatorUser = operatorUser, + OperatorNode = string.Empty, + OperatorDomain = string.Empty, + OperatorFullName = string.Empty, + }, + }; + } + + if (TryParseAlarmReference(request.AlarmFullReference, out string provider, out string group, out string alarm)) + { + return new MxCommand + { + Kind = MxCommandKind.AcknowledgeAlarmByName, + AcknowledgeAlarmByNameCommand = new AcknowledgeAlarmByNameCommand + { + AlarmName = alarm, + ProviderName = provider, + GroupName = group, + Comment = comment, + OperatorUser = operatorUser, + OperatorNode = string.Empty, + OperatorDomain = string.Empty, + OperatorFullName = string.Empty, + }, + }; + } + + parseError = "alarm_full_reference must be a canonical GUID or 'Provider!Group.Tag' format."; + return null; + } + + /// + /// Parses an alarm reference of the form Provider!Group.Tag: the + /// first ! splits provider from Group.Tag; the first + /// . after the ! splits group from tag. + /// + /// The full alarm reference. + /// The parsed provider. + /// The parsed group/area. + /// The parsed tag/alarm name. + /// true on a well-formed reference; otherwise false. + public static bool TryParseAlarmReference( + string? reference, + out string providerName, + out string groupName, + out string alarmName) + { + providerName = string.Empty; + groupName = string.Empty; + alarmName = string.Empty; + if (string.IsNullOrWhiteSpace(reference)) + { + return false; + } + + int bang = reference!.IndexOf('!', StringComparison.Ordinal); + if (bang <= 0 || bang == reference.Length - 1) + { + return false; + } + + string left = reference[..bang]; + string right = reference[(bang + 1)..]; + int dot = right.IndexOf('.', StringComparison.Ordinal); + if (dot <= 0 || dot == right.Length - 1) + { + return false; + } + + providerName = left; + groupName = right[..dot]; + alarmName = right[(dot + 1)..]; + return true; + } + + private static ActiveAlarmSnapshot SnapshotFromTransition(OnAlarmTransitionEvent transition) + { + ActiveAlarmSnapshot snapshot = new() + { + AlarmFullReference = transition.AlarmFullReference, + SourceObjectReference = transition.SourceObjectReference, + AlarmTypeName = transition.AlarmTypeName, + Severity = transition.Severity, + CurrentState = transition.TransitionKind == AlarmTransitionKind.Acknowledge + ? AlarmConditionState.ActiveAcked + : AlarmConditionState.Active, + Category = transition.Category, + Description = transition.Description, + OperatorUser = transition.OperatorUser, + OperatorComment = transition.OperatorComment, + }; + if (transition.OriginalRaiseTimestamp is not null) + { + snapshot.OriginalRaiseTimestamp = transition.OriginalRaiseTimestamp; + } + if (transition.TransitionTimestamp is not null) + { + snapshot.LastTransitionTimestamp = transition.TransitionTimestamp; + } + if (transition.CurrentValue is not null) + { + snapshot.CurrentValue = transition.CurrentValue; + } + if (transition.LimitValue is not null) + { + snapshot.LimitValue = transition.LimitValue; + } + + return snapshot; + } + + private static OnAlarmTransitionEvent TransitionFromSnapshot( + ActiveAlarmSnapshot snapshot, + AlarmTransitionKind kind) + { + OnAlarmTransitionEvent transition = new() + { + AlarmFullReference = snapshot.AlarmFullReference, + SourceObjectReference = snapshot.SourceObjectReference, + AlarmTypeName = snapshot.AlarmTypeName, + TransitionKind = kind, + Severity = snapshot.Severity, + Category = snapshot.Category, + Description = snapshot.Description, + OperatorUser = snapshot.OperatorUser, + OperatorComment = snapshot.OperatorComment, + }; + if (snapshot.OriginalRaiseTimestamp is not null) + { + transition.OriginalRaiseTimestamp = snapshot.OriginalRaiseTimestamp; + } + if (snapshot.LastTransitionTimestamp is not null) + { + transition.TransitionTimestamp = snapshot.LastTransitionTimestamp; + } + if (snapshot.CurrentValue is not null) + { + transition.CurrentValue = snapshot.CurrentValue; + } + if (snapshot.LimitValue is not null) + { + transition.LimitValue = snapshot.LimitValue; + } + + return transition; + } + + private sealed class Subscriber(Channel channel, string prefix) + { + public Channel Channel { get; } = channel; + + public bool Matches(string reference) + { + return prefix.Length == 0 || reference.StartsWith(prefix, StringComparison.Ordinal); + } + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Alarms/IGatewayAlarmService.cs b/src/ZB.MOM.WW.MxGateway.Server/Alarms/IGatewayAlarmService.cs new file mode 100644 index 0000000..268f7a7 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Alarms/IGatewayAlarmService.cs @@ -0,0 +1,63 @@ +using ZB.MOM.WW.MxGateway.Contracts.Proto; + +namespace ZB.MOM.WW.MxGateway.Server.Alarms; + +/// Lifecycle state of the gateway's central alarm monitor. +public enum GatewayAlarmMonitorState +{ + /// Alarm monitoring is switched off (MxGateway:Alarms:Enabled is false). + Disabled, + + /// The monitor is opening or re-opening its worker session. + Starting, + + /// The monitor is connected and tracking the active-alarm set. + Monitoring, + + /// The monitor's last lifecycle attempt failed; a restart is pending. + Faulted, +} + +/// +/// The gateway's always-on alarm broker. A single gateway-owned worker +/// session monitors the AVEVA alarm provider; this service caches the +/// current active-alarm set and fans it out to any number of clients — +/// no client needs to open its own worker session to see alarms. +/// +public interface IGatewayAlarmService +{ + /// Current monitor lifecycle state. + GatewayAlarmMonitorState State { get; } + + /// Diagnostic message from the most recent fault, or null. + string? LastError { get; } + + /// Process id of the worker backing the monitor, when one is attached. + int? WorkerProcessId { get; } + + /// A point-in-time copy of the current active-alarm set. + IReadOnlyList CurrentAlarms { get; } + + /// + /// Attaches to the central alarm feed. The returned stream yields one + /// per currently-active alarm, then a + /// single snapshot_complete sentinel, then a transition + /// for every subsequent change. + /// + /// Optional alarm-reference prefix scoping the feed. + /// Token that ends the subscription. + IAsyncEnumerable StreamAsync( + string? alarmFilterPrefix, + CancellationToken cancellationToken); + + /// + /// Acknowledges an alarm through the monitor's worker session. Never + /// throws — transport and monitor-state failures surface in the + /// reply's . + /// + /// The acknowledge request. + /// Token to cancel the call. + Task AcknowledgeAsync( + AcknowledgeAlarmRequest request, + CancellationToken cancellationToken); +} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Configuration/AlarmsOptions.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/AlarmsOptions.cs new file mode 100644 index 0000000..38563fa --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/AlarmsOptions.cs @@ -0,0 +1,48 @@ +namespace ZB.MOM.WW.MxGateway.Server.Configuration; + +/// +/// Configuration for the gateway's always-on central alarm monitor +/// (). When +/// is true the gateway opens one gateway-owned worker session dedicated to +/// alarms, caches the active-alarm set, and fans it out to every client +/// through the StreamAlarms RPC — no client opens its own session +/// to see alarms. +/// +/// +/// Defaults preserve current behaviour (alarm monitoring disabled). +/// Operators opt in by setting MxGateway:Alarms:Enabled = true and +/// supplying a canonical \\<machine>\Galaxy!<area> +/// subscription expression. The literal "Galaxy" provider is correct +/// regardless of the configured Galaxy database name (the wnwrap consumer +/// does not accept the database name as the provider). +/// +public sealed class AlarmsOptions +{ + /// Gate the gateway's always-on central alarm monitor. Default false. + public bool Enabled { get; init; } + + /// + /// AVEVA alarm-subscription expression the monitor subscribes on + /// startup. When empty and is true, the gateway + /// falls back to \\$(MachineName)\Galaxy!$(DefaultArea) if + /// is set; otherwise the monitor faults with + /// a configuration diagnostic. + /// + public string SubscriptionExpression { get; init; } = string.Empty; + + /// + /// Optional area name used to compose a default subscription when + /// is empty. Combined with + /// Environment.MachineName as + /// \\<MachineName>\Galaxy!<DefaultArea>. + /// + public string DefaultArea { get; init; } = string.Empty; + + /// + /// How often the monitor reconciles its in-process alarm cache against + /// the worker's authoritative active-alarm snapshot, catching any + /// transitions the live poll-and-diff feed missed. Default 30 seconds; + /// the monitor floors it at 5 seconds. + /// + public int ReconcileIntervalSeconds { get; init; } = 30; +} diff --git a/src/MxGateway.Server/Configuration/AuthenticationMode.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/AuthenticationMode.cs similarity index 53% rename from src/MxGateway.Server/Configuration/AuthenticationMode.cs rename to src/ZB.MOM.WW.MxGateway.Server/Configuration/AuthenticationMode.cs index 6559a0d..a7215e2 100644 --- a/src/MxGateway.Server/Configuration/AuthenticationMode.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/AuthenticationMode.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Configuration; +namespace ZB.MOM.WW.MxGateway.Server.Configuration; public enum AuthenticationMode { diff --git a/src/MxGateway.Server/Configuration/AuthenticationOptions.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/AuthenticationOptions.cs similarity index 92% rename from src/MxGateway.Server/Configuration/AuthenticationOptions.cs rename to src/ZB.MOM.WW.MxGateway.Server/Configuration/AuthenticationOptions.cs index 9e3aac7..534cdef 100644 --- a/src/MxGateway.Server/Configuration/AuthenticationOptions.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/AuthenticationOptions.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Configuration; +namespace ZB.MOM.WW.MxGateway.Server.Configuration; public sealed class AuthenticationOptions { diff --git a/src/MxGateway.Server/Configuration/DashboardOptions.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/DashboardOptions.cs similarity index 95% rename from src/MxGateway.Server/Configuration/DashboardOptions.cs rename to src/ZB.MOM.WW.MxGateway.Server/Configuration/DashboardOptions.cs index 717d24a..7694c8f 100644 --- a/src/MxGateway.Server/Configuration/DashboardOptions.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/DashboardOptions.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Configuration; +namespace ZB.MOM.WW.MxGateway.Server.Configuration; public sealed class DashboardOptions { diff --git a/src/MxGateway.Server/Configuration/EffectiveAuthenticationConfiguration.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/EffectiveAuthenticationConfiguration.cs similarity index 75% rename from src/MxGateway.Server/Configuration/EffectiveAuthenticationConfiguration.cs rename to src/ZB.MOM.WW.MxGateway.Server/Configuration/EffectiveAuthenticationConfiguration.cs index 9e553e2..879f76c 100644 --- a/src/MxGateway.Server/Configuration/EffectiveAuthenticationConfiguration.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/EffectiveAuthenticationConfiguration.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Configuration; +namespace ZB.MOM.WW.MxGateway.Server.Configuration; public sealed record EffectiveAuthenticationConfiguration( string Mode, diff --git a/src/MxGateway.Server/Configuration/EffectiveDashboardConfiguration.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/EffectiveDashboardConfiguration.cs similarity index 84% rename from src/MxGateway.Server/Configuration/EffectiveDashboardConfiguration.cs rename to src/ZB.MOM.WW.MxGateway.Server/Configuration/EffectiveDashboardConfiguration.cs index ee22a92..91e852b 100644 --- a/src/MxGateway.Server/Configuration/EffectiveDashboardConfiguration.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/EffectiveDashboardConfiguration.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Configuration; +namespace ZB.MOM.WW.MxGateway.Server.Configuration; public sealed record EffectiveDashboardConfiguration( bool Enabled, diff --git a/src/MxGateway.Server/Configuration/EffectiveEventConfiguration.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/EffectiveEventConfiguration.cs similarity index 67% rename from src/MxGateway.Server/Configuration/EffectiveEventConfiguration.cs rename to src/ZB.MOM.WW.MxGateway.Server/Configuration/EffectiveEventConfiguration.cs index 8fe5938..6e0bee5 100644 --- a/src/MxGateway.Server/Configuration/EffectiveEventConfiguration.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/EffectiveEventConfiguration.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Configuration; +namespace ZB.MOM.WW.MxGateway.Server.Configuration; public sealed record EffectiveEventConfiguration( int QueueCapacity, diff --git a/src/MxGateway.Server/Configuration/EffectiveGatewayConfiguration.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/EffectiveGatewayConfiguration.cs similarity index 87% rename from src/MxGateway.Server/Configuration/EffectiveGatewayConfiguration.cs rename to src/ZB.MOM.WW.MxGateway.Server/Configuration/EffectiveGatewayConfiguration.cs index e06096b..d5d1c56 100644 --- a/src/MxGateway.Server/Configuration/EffectiveGatewayConfiguration.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/EffectiveGatewayConfiguration.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Configuration; +namespace ZB.MOM.WW.MxGateway.Server.Configuration; public sealed record EffectiveGatewayConfiguration( EffectiveAuthenticationConfiguration Authentication, diff --git a/src/ZB.MOM.WW.MxGateway.Server/Configuration/EffectiveLdapConfiguration.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/EffectiveLdapConfiguration.cs new file mode 100644 index 0000000..7ce9d6e --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/EffectiveLdapConfiguration.cs @@ -0,0 +1,15 @@ +namespace ZB.MOM.WW.MxGateway.Server.Configuration; + +public sealed record EffectiveLdapConfiguration( + bool Enabled, + string Server, + int Port, + bool UseTls, + bool AllowInsecureLdap, + string SearchBase, + string ServiceAccountDn, + string ServiceAccountPassword, + string UserNameAttribute, + string DisplayNameAttribute, + string GroupAttribute, + string RequiredGroup); diff --git a/src/MxGateway.Server/Configuration/EffectiveProtocolConfiguration.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/EffectiveProtocolConfiguration.cs similarity index 69% rename from src/MxGateway.Server/Configuration/EffectiveProtocolConfiguration.cs rename to src/ZB.MOM.WW.MxGateway.Server/Configuration/EffectiveProtocolConfiguration.cs index c54e184..ec0d211 100644 --- a/src/MxGateway.Server/Configuration/EffectiveProtocolConfiguration.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/EffectiveProtocolConfiguration.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Configuration; +namespace ZB.MOM.WW.MxGateway.Server.Configuration; public sealed record EffectiveProtocolConfiguration( uint WorkerProtocolVersion, diff --git a/src/MxGateway.Server/Configuration/EffectiveSessionConfiguration.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/EffectiveSessionConfiguration.cs similarity index 83% rename from src/MxGateway.Server/Configuration/EffectiveSessionConfiguration.cs rename to src/ZB.MOM.WW.MxGateway.Server/Configuration/EffectiveSessionConfiguration.cs index 9753c20..22ef89b 100644 --- a/src/MxGateway.Server/Configuration/EffectiveSessionConfiguration.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/EffectiveSessionConfiguration.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Configuration; +namespace ZB.MOM.WW.MxGateway.Server.Configuration; public sealed record EffectiveSessionConfiguration( int DefaultCommandTimeoutSeconds, diff --git a/src/MxGateway.Server/Configuration/EffectiveWorkerConfiguration.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/EffectiveWorkerConfiguration.cs similarity index 85% rename from src/MxGateway.Server/Configuration/EffectiveWorkerConfiguration.cs rename to src/ZB.MOM.WW.MxGateway.Server/Configuration/EffectiveWorkerConfiguration.cs index 68bd6b1..738be77 100644 --- a/src/MxGateway.Server/Configuration/EffectiveWorkerConfiguration.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/EffectiveWorkerConfiguration.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Configuration; +namespace ZB.MOM.WW.MxGateway.Server.Configuration; public sealed record EffectiveWorkerConfiguration( string ExecutablePath, diff --git a/src/MxGateway.Server/Configuration/EventBackpressurePolicy.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/EventBackpressurePolicy.cs similarity index 60% rename from src/MxGateway.Server/Configuration/EventBackpressurePolicy.cs rename to src/ZB.MOM.WW.MxGateway.Server/Configuration/EventBackpressurePolicy.cs index 089aab4..41fac65 100644 --- a/src/MxGateway.Server/Configuration/EventBackpressurePolicy.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/EventBackpressurePolicy.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Configuration; +namespace ZB.MOM.WW.MxGateway.Server.Configuration; public enum EventBackpressurePolicy { diff --git a/src/MxGateway.Server/Configuration/EventOptions.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/EventOptions.cs similarity index 87% rename from src/MxGateway.Server/Configuration/EventOptions.cs rename to src/ZB.MOM.WW.MxGateway.Server/Configuration/EventOptions.cs index a3831cf..3d3fb2e 100644 --- a/src/MxGateway.Server/Configuration/EventOptions.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/EventOptions.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Configuration; +namespace ZB.MOM.WW.MxGateway.Server.Configuration; public sealed class EventOptions { diff --git a/src/MxGateway.Server/Configuration/GatewayConfigurationProvider.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayConfigurationProvider.cs similarity index 98% rename from src/MxGateway.Server/Configuration/GatewayConfigurationProvider.cs rename to src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayConfigurationProvider.cs index 63d4112..09d64e1 100644 --- a/src/MxGateway.Server/Configuration/GatewayConfigurationProvider.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayConfigurationProvider.cs @@ -1,6 +1,6 @@ using Microsoft.Extensions.Options; -namespace MxGateway.Server.Configuration; +namespace ZB.MOM.WW.MxGateway.Server.Configuration; /// Provides the effective gateway configuration with sensitive values redacted. public sealed class GatewayConfigurationProvider(IOptions options) : IGatewayConfigurationProvider diff --git a/src/MxGateway.Server/Configuration/GatewayConfigurationServiceCollectionExtensions.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayConfigurationServiceCollectionExtensions.cs similarity index 93% rename from src/MxGateway.Server/Configuration/GatewayConfigurationServiceCollectionExtensions.cs rename to src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayConfigurationServiceCollectionExtensions.cs index b514bf0..d572311 100644 --- a/src/MxGateway.Server/Configuration/GatewayConfigurationServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayConfigurationServiceCollectionExtensions.cs @@ -1,6 +1,6 @@ using Microsoft.Extensions.Options; -namespace MxGateway.Server.Configuration; +namespace ZB.MOM.WW.MxGateway.Server.Configuration; public static class GatewayConfigurationServiceCollectionExtensions { diff --git a/src/MxGateway.Server/Configuration/GatewayOptions.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptions.cs similarity index 96% rename from src/MxGateway.Server/Configuration/GatewayOptions.cs rename to src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptions.cs index 312dd4a..0ac52fd 100644 --- a/src/MxGateway.Server/Configuration/GatewayOptions.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptions.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Configuration; +namespace ZB.MOM.WW.MxGateway.Server.Configuration; public sealed class GatewayOptions { diff --git a/src/MxGateway.Server/Configuration/GatewayOptionsValidator.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs similarity index 89% rename from src/MxGateway.Server/Configuration/GatewayOptionsValidator.cs rename to src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs index 3c1b459..89af828 100644 --- a/src/MxGateway.Server/Configuration/GatewayOptionsValidator.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs @@ -1,7 +1,7 @@ using Microsoft.Extensions.Options; -using MxGateway.Contracts; +using ZB.MOM.WW.MxGateway.Contracts; -namespace MxGateway.Server.Configuration; +namespace ZB.MOM.WW.MxGateway.Server.Configuration; public sealed class GatewayOptionsValidator : IValidateOptions { @@ -25,6 +25,7 @@ public sealed class GatewayOptionsValidator : IValidateOptions ValidateEvents(options.Events, failures); ValidateDashboard(options.Dashboard, failures); ValidateProtocol(options.Protocol, failures); + ValidateAlarms(options.Alarms, failures); return failures.Count == 0 ? ValidateOptionsResult.Success @@ -228,6 +229,32 @@ public sealed class GatewayOptionsValidator : IValidateOptions failures); } + private static void ValidateAlarms(AlarmsOptions options, List failures) + { + if (!options.Enabled) + { + return; + } + + // When the central alarm monitor is enabled, it needs either a canonical + // SubscriptionExpression or a DefaultArea to compose one from. Validating + // it at startup makes the misconfiguration fail-fast at boot, in line + // with every other section. + if (string.IsNullOrWhiteSpace(options.SubscriptionExpression) + && string.IsNullOrWhiteSpace(options.DefaultArea)) + { + failures.Add( + "MxGateway:Alarms requires either a non-blank SubscriptionExpression or a non-blank DefaultArea when Enabled is true."); + } + + if (!string.IsNullOrWhiteSpace(options.SubscriptionExpression) + && !options.SubscriptionExpression.StartsWith(@"\\", StringComparison.Ordinal)) + { + failures.Add( + @"MxGateway:Alarms:SubscriptionExpression must start with '\\' (canonical \\\Galaxy! shape)."); + } + } + private static void ValidateProtocol(ProtocolOptions options, List failures) { if (options.WorkerProtocolVersion != GatewayContractInfo.WorkerProtocolVersion) diff --git a/src/MxGateway.Server/Configuration/IGatewayConfigurationProvider.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/IGatewayConfigurationProvider.cs similarity index 86% rename from src/MxGateway.Server/Configuration/IGatewayConfigurationProvider.cs rename to src/ZB.MOM.WW.MxGateway.Server/Configuration/IGatewayConfigurationProvider.cs index be240e9..d4a1275 100644 --- a/src/MxGateway.Server/Configuration/IGatewayConfigurationProvider.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/IGatewayConfigurationProvider.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Configuration; +namespace ZB.MOM.WW.MxGateway.Server.Configuration; /// /// Provides the effective gateway configuration, applying defaults and validations. diff --git a/src/ZB.MOM.WW.MxGateway.Server/Configuration/LdapOptions.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/LdapOptions.cs new file mode 100644 index 0000000..1d5c16e --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/LdapOptions.cs @@ -0,0 +1,28 @@ +namespace ZB.MOM.WW.MxGateway.Server.Configuration; + +public sealed class LdapOptions +{ + public bool Enabled { get; init; } = true; + + public string Server { get; init; } = "localhost"; + + public int Port { get; init; } = 3893; + + public bool UseTls { get; init; } + + public bool AllowInsecureLdap { get; init; } = true; + + public string SearchBase { get; init; } = "dc=lmxopcua,dc=local"; + + public string ServiceAccountDn { get; init; } = "cn=serviceaccount,dc=lmxopcua,dc=local"; + + public string ServiceAccountPassword { get; init; } = "serviceaccount123"; + + public string UserNameAttribute { get; init; } = "cn"; + + public string DisplayNameAttribute { get; init; } = "cn"; + + public string GroupAttribute { get; init; } = "memberOf"; + + public string RequiredGroup { get; init; } = "GwAdmin"; +} diff --git a/src/MxGateway.Server/Configuration/ProtocolOptions.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/ProtocolOptions.cs similarity index 81% rename from src/MxGateway.Server/Configuration/ProtocolOptions.cs rename to src/ZB.MOM.WW.MxGateway.Server/Configuration/ProtocolOptions.cs index 9cf6816..d526dce 100644 --- a/src/MxGateway.Server/Configuration/ProtocolOptions.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/ProtocolOptions.cs @@ -1,6 +1,6 @@ -using MxGateway.Contracts; +using ZB.MOM.WW.MxGateway.Contracts; -namespace MxGateway.Server.Configuration; +namespace ZB.MOM.WW.MxGateway.Server.Configuration; /// /// Configuration options for the worker protocol version. diff --git a/src/MxGateway.Server/Configuration/SessionOptions.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/SessionOptions.cs similarity index 94% rename from src/MxGateway.Server/Configuration/SessionOptions.cs rename to src/ZB.MOM.WW.MxGateway.Server/Configuration/SessionOptions.cs index f74539b..02136b1 100644 --- a/src/MxGateway.Server/Configuration/SessionOptions.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/SessionOptions.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Configuration; +namespace ZB.MOM.WW.MxGateway.Server.Configuration; public sealed class SessionOptions { diff --git a/src/MxGateway.Server/Configuration/WorkerArchitecture.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/WorkerArchitecture.cs similarity index 50% rename from src/MxGateway.Server/Configuration/WorkerArchitecture.cs rename to src/ZB.MOM.WW.MxGateway.Server/Configuration/WorkerArchitecture.cs index 6de6f45..e476f73 100644 --- a/src/MxGateway.Server/Configuration/WorkerArchitecture.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/WorkerArchitecture.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Configuration; +namespace ZB.MOM.WW.MxGateway.Server.Configuration; public enum WorkerArchitecture { diff --git a/src/MxGateway.Server/Configuration/WorkerOptions.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/WorkerOptions.cs similarity index 92% rename from src/MxGateway.Server/Configuration/WorkerOptions.cs rename to src/ZB.MOM.WW.MxGateway.Server/Configuration/WorkerOptions.cs index 3081b07..28d3386 100644 --- a/src/MxGateway.Server/Configuration/WorkerOptions.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/WorkerOptions.cs @@ -1,10 +1,10 @@ -namespace MxGateway.Server.Configuration; +namespace ZB.MOM.WW.MxGateway.Server.Configuration; public sealed class WorkerOptions { /// The path to the worker executable. public string ExecutablePath { get; init; } = - @"src\MxGateway.Worker\bin\x86\Release\MxGateway.Worker.exe"; + @"src\ZB.MOM.WW.MxGateway.Worker\bin\x86\Release\ZB.MOM.WW.MxGateway.Worker.exe"; /// The working directory for the worker process, or null to inherit. public string? WorkingDirectory { get; init; } diff --git a/src/MxGateway.Server/Dashboard/Components/App.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/App.razor similarity index 94% rename from src/MxGateway.Server/Dashboard/Components/App.razor rename to src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/App.razor index 4468c4c..09ec8c1 100644 --- a/src/MxGateway.Server/Dashboard/Components/App.razor +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/App.razor @@ -7,6 +7,7 @@ + diff --git a/src/MxGateway.Server/Dashboard/Components/DashboardDisplay.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/DashboardDisplay.cs similarity index 97% rename from src/MxGateway.Server/Dashboard/Components/DashboardDisplay.cs rename to src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/DashboardDisplay.cs index c844ef9..ccdd6f9 100644 --- a/src/MxGateway.Server/Dashboard/Components/DashboardDisplay.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/DashboardDisplay.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Dashboard.Components; +namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Components; public static class DashboardDisplay { diff --git a/src/MxGateway.Server/Dashboard/Components/DashboardPageBase.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/DashboardPageBase.cs similarity index 96% rename from src/MxGateway.Server/Dashboard/Components/DashboardPageBase.cs rename to src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/DashboardPageBase.cs index 2d4447d..400fa64 100644 --- a/src/MxGateway.Server/Dashboard/Components/DashboardPageBase.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/DashboardPageBase.cs @@ -1,6 +1,6 @@ using Microsoft.AspNetCore.Components; -namespace MxGateway.Server.Dashboard.Components; +namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Components; /// /// Base class for Blazor dashboard pages that watch gateway metrics snapshots. diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Layout/DashboardLayout.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Layout/DashboardLayout.razor new file mode 100644 index 0000000..29ddee2 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Layout/DashboardLayout.razor @@ -0,0 +1,50 @@ +@inherits LayoutComponentBase +@inject IOptions GatewayOptions + +
+
+ MXAccess Gateway + + + + +
+ @authState.User.Identity?.Name +
+ + + +
+
+ + Sign in + +
+
+
+ @Body +
+
+ +@code { + private string DashboardPath(string relativePath) + { + string pathBase = GatewayOptions.Value.Dashboard.PathBase.TrimEnd('/'); + if (string.IsNullOrWhiteSpace(pathBase)) + { + pathBase = "/dashboard"; + } + + return $"{pathBase}{relativePath}"; + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/AlarmsPage.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/AlarmsPage.razor new file mode 100644 index 0000000..2fc06f6 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/AlarmsPage.razor @@ -0,0 +1,286 @@ +@page "/alarms" +@page "/dashboard/alarms" +@implements IAsyncDisposable +@inject IDashboardLiveDataService LiveData +@inject IOptions GatewayOptions + +Dashboard Alarms + +
+
+

Alarms

+
@HeaderLine()
+
+
+ +@if (!GatewayOptions.Value.Alarms.Enabled) +{ +
+ Alarm auto-subscribe is disabled (MxGateway:Alarms:Enabled is false). The + dashboard session is not subscribed to any alarm provider, so this list will stay empty. + Enable alarms in configuration and restart the gateway. +
+} + +@if (!string.IsNullOrWhiteSpace(_queryError)) +{ +
Alarm query failed: @_queryError
+} + +
+ + + + +
+ +
+
+

Filters

+
+
+ + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+

Active Alarms

+
+ @{ + IReadOnlyList rows = FilteredAlarms(); + } + @if (rows.Count == 0) + { +
+ @if (_alarms.Count == 0) + { + No alarms are currently Active or ActiveAcked. + } + else + { + No alarms match the current filters. + } +
+ } + else + { +
+ + + + + + + + + + + + + + + @foreach (DashboardActiveAlarm alarm in rows) + { + + + + + + + + + + + } + +
StateSeverityAlarm ReferenceSourceTypeAreaLast TransitionOperator
@StateText(alarm.State)@alarm.Severity + @alarm.Reference + @if (!string.IsNullOrWhiteSpace(alarm.Description)) + { +
@alarm.Description
+ } +
@DashboardDisplay.Text(alarm.Source)@DashboardDisplay.Text(alarm.AlarmType)@DashboardDisplay.Text(alarm.Area)@(alarm.LastTransition is { } ts ? DashboardDisplay.DateTime(ts) : "-") + @DashboardDisplay.Text(alarm.OperatorUser) + @if (!string.IsNullOrWhiteSpace(alarm.OperatorComment)) + { +
@alarm.OperatorComment
+ } +
+
+ } +
+ Cleared alarms are not retained — this list reflects only alarms currently Active or + ActiveAcked, refreshed every 3 seconds. +
+
+ +@code { + private readonly List _alarms = []; + private string? _queryError; + private int? _workerPid; + private DateTimeOffset? _lastRefresh; + private int _unackedCount; + private int _ackedCount; + + private bool _showActive = true; + private bool _showAcked; + private string _areaFilter = string.Empty; + private int _minSeverity; + private int _maxSeverity = 1000; + private string _search = string.Empty; + + private readonly CancellationTokenSource _cts = new(); + private Task? _pollTask; + + /// + protected override void OnInitialized() + { + _pollTask = PollLoopAsync(); + } + + private string HeaderLine() + { + string refreshed = _lastRefresh is { } at + ? $"refreshed {DashboardDisplay.DateTime(at)}" + : "awaiting first refresh"; + return _workerPid is int pid ? $"{refreshed} · worker pid {pid}" : refreshed; + } + + private IReadOnlyList Areas() + { + return _alarms + .Select(alarm => alarm.Area) + .Where(area => !string.IsNullOrWhiteSpace(area)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(area => area, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + private IReadOnlyList FilteredAlarms() + { + string query = _search.Trim(); + return _alarms + .Where(MatchesState) + .Where(alarm => _areaFilter.Length == 0 + || string.Equals(alarm.Area, _areaFilter, StringComparison.OrdinalIgnoreCase)) + .Where(alarm => alarm.Severity >= _minSeverity && alarm.Severity <= _maxSeverity) + .Where(alarm => query.Length == 0 + || alarm.Reference.Contains(query, StringComparison.OrdinalIgnoreCase) + || alarm.Source.Contains(query, StringComparison.OrdinalIgnoreCase) + || alarm.Description.Contains(query, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(alarm => alarm.Severity) + .ThenByDescending(alarm => alarm.LastTransition ?? DateTimeOffset.MinValue) + .ToArray(); + } + + private bool MatchesState(DashboardActiveAlarm alarm) + { + return alarm.State switch + { + AlarmConditionState.Active => _showActive, + AlarmConditionState.ActiveAcked => _showAcked, + _ => true, + }; + } + + private static string StateText(AlarmConditionState state) + { + return state switch + { + AlarmConditionState.Active => "Active", + AlarmConditionState.ActiveAcked => "Acked", + AlarmConditionState.Inactive => "Inactive", + _ => "Unknown", + }; + } + + private static string StateClass(AlarmConditionState state) + { + return state switch + { + AlarmConditionState.Active => "alarm-state-active", + AlarmConditionState.ActiveAcked => "alarm-state-acked", + _ => "alarm-state-other", + }; + } + + private async Task PollLoopAsync() + { + try + { + await InvokeAsync(RefreshAlarmsAsync).ConfigureAwait(false); + using PeriodicTimer timer = new(TimeSpan.FromSeconds(3)); + while (await timer.WaitForNextTickAsync(_cts.Token).ConfigureAwait(false)) + { + await InvokeAsync(RefreshAlarmsAsync).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + } + } + + private async Task RefreshAlarmsAsync() + { + DashboardAlarmQueryResult result = await LiveData.QueryAlarmsAsync(_cts.Token); + _queryError = result.Error; + _workerPid = result.WorkerProcessId; + _lastRefresh = DateTimeOffset.UtcNow; + _alarms.Clear(); + _alarms.AddRange(result.Alarms); + _unackedCount = _alarms.Count(alarm => alarm.State == AlarmConditionState.Active); + _ackedCount = _alarms.Count(alarm => alarm.State == AlarmConditionState.ActiveAcked); + StateHasChanged(); + } + + /// + public async ValueTask DisposeAsync() + { + await _cts.CancelAsync(); + if (_pollTask is not null) + { + try + { + await _pollTask; + } + catch (OperationCanceledException) + { + } + } + + _cts.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/ApiKeysPage.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/ApiKeysPage.razor new file mode 100644 index 0000000..345bcd1 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/ApiKeysPage.razor @@ -0,0 +1,466 @@ +@page "/apikeys" +@page "/dashboard/apikeys" +@inherits DashboardPageBase +@inject AuthenticationStateProvider AuthenticationStateProvider +@inject IDashboardApiKeyManagementService ApiKeyManagementService + +Dashboard API Keys + +@if (Snapshot is null) +{ +
Loading API keys.
+} +else +{ +
+
+

API Keys

+
@Snapshot.ApiKeys.Count key rows
+
+ @if (CanManageApiKeys) + { + + } +
+ + @if (CanManageApiKeys) + { + @if (!string.IsNullOrWhiteSpace(ResultMessage)) + { + + } + + @if (IsCreateDialogOpen) + { + + + } + } + +
+ @if (Snapshot.ApiKeys.Count == 0) + { +
No API keys are available for display.
+ } + else + { +
+ + + + + + + + + + + @if (CanManageApiKeys) + { + + } + + + + @foreach (DashboardApiKeySummary key in Snapshot.ApiKeys) + { + + + + + + + + + @if (CanManageApiKeys) + { + + } + + } + +
KeyStatusDisplay NameScopesConstraintsCreatedLast UsedActions
@key.KeyId@DashboardDisplay.Text(key.DisplayName)@DashboardDisplay.Text(string.Join(", ", key.Scopes.Order(StringComparer.Ordinal)))@DashboardDisplay.Text(ConstraintText(key.Constraints))@DashboardDisplay.DateTime(key.CreatedUtc)@DashboardDisplay.DateTime(key.LastUsedUtc) +
+ @if (key.RevokedUtc is null) + { + @* Rotate clears revoked_utc, which would silently reactivate a + deliberately revoked key. Only offer it for active keys so a + revoked key is not un-revoked as a side effect of rotation. *@ + + + } + else + { + No actions + } +
+
+
+ } +
+} + +@code { + private static readonly string[] AvailableScopes = + [ + GatewayScopes.SessionOpen, + GatewayScopes.SessionClose, + GatewayScopes.InvokeRead, + GatewayScopes.InvokeWrite, + GatewayScopes.InvokeSecure, + GatewayScopes.EventsRead, + GatewayScopes.MetadataRead, + GatewayScopes.Admin + ]; + + private ApiKeyCreateModel CreateModel { get; } = new(); + + private bool CanManageApiKeys { get; set; } + + private bool IsBusy { get; set; } + + private bool IsCreateDialogOpen { get; set; } + + private string? ResultMessage { get; set; } + + private bool LastOperationSucceeded { get; set; } + + private string? LastGeneratedApiKey { get; set; } + + protected override async Task OnInitializedAsync() + { + AuthenticationState authenticationState = await AuthenticationStateProvider.GetAuthenticationStateAsync() + .ConfigureAwait(false); + CanManageApiKeys = ApiKeyManagementService.CanManage(authenticationState.User); + } + + private async Task CreateApiKeyAsync() + { + if (IsBusy) + { + return; + } + + if (!TryBuildCreateRequest(out DashboardApiKeyManagementRequest? request, out string? validationMessage)) + { + SetResult(DashboardApiKeyManagementResult.Fail(validationMessage ?? "API key request is invalid.")); + return; + } + + await RunManagementActionAsync(user => ApiKeyManagementService.CreateAsync( + user, + request, + CancellationToken.None)) + .ConfigureAwait(false); + } + + private async Task RevokeApiKeyAsync(string keyId) + { + await RunManagementActionAsync(user => ApiKeyManagementService.RevokeAsync( + user, + keyId, + CancellationToken.None)) + .ConfigureAwait(false); + } + + private async Task RotateApiKeyAsync(string keyId) + { + await RunManagementActionAsync(user => ApiKeyManagementService.RotateAsync( + user, + keyId, + CancellationToken.None)) + .ConfigureAwait(false); + } + + private async Task RunManagementActionAsync( + Func> action) + { + if (IsBusy) + { + return; + } + + IsBusy = true; + try + { + AuthenticationState authenticationState = await AuthenticationStateProvider.GetAuthenticationStateAsync() + .ConfigureAwait(false); + CanManageApiKeys = ApiKeyManagementService.CanManage(authenticationState.User); + DashboardApiKeyManagementResult result = await action(authenticationState.User).ConfigureAwait(false); + SetResult(result); + if (result.Succeeded && result.ApiKey is not null) + { + CreateModel.Reset(); + IsCreateDialogOpen = false; + } + } + finally + { + IsBusy = false; + } + } + + private void SetResult(DashboardApiKeyManagementResult result) + { + LastOperationSucceeded = result.Succeeded; + ResultMessage = result.Message; + LastGeneratedApiKey = result.ApiKey; + } + + private void OpenCreateDialog() + { + IsCreateDialogOpen = true; + } + + private void CloseCreateDialog() + { + if (!IsBusy) + { + IsCreateDialogOpen = false; + } + } + + private bool TryBuildCreateRequest( + [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out DashboardApiKeyManagementRequest? request, + out string? validationMessage) + { + request = null; + validationMessage = null; + + if (!string.IsNullOrWhiteSpace(CreateModel.MaxWriteClassification) + && !int.TryParse( + CreateModel.MaxWriteClassification, + System.Globalization.NumberStyles.Integer, + System.Globalization.CultureInfo.InvariantCulture, + out int _)) + { + validationMessage = "Max write classification must be an integer."; + return false; + } + + int? maxWriteClassification = string.IsNullOrWhiteSpace(CreateModel.MaxWriteClassification) + ? null + : int.Parse( + CreateModel.MaxWriteClassification, + System.Globalization.NumberStyles.Integer, + System.Globalization.CultureInfo.InvariantCulture); + + request = new DashboardApiKeyManagementRequest( + KeyId: CreateModel.KeyId, + DisplayName: CreateModel.DisplayName, + Scopes: CreateModel.SelectedScopes, + Constraints: new ZB.MOM.WW.MxGateway.Server.Security.Authentication.ApiKeyConstraints( + ReadSubtrees: ParseList(CreateModel.ReadSubtrees), + WriteSubtrees: ParseList(CreateModel.WriteSubtrees), + ReadTagGlobs: ParseList(CreateModel.ReadTagGlobs), + WriteTagGlobs: ParseList(CreateModel.WriteTagGlobs), + MaxWriteClassification: maxWriteClassification, + BrowseSubtrees: ParseList(CreateModel.BrowseSubtrees), + ReadAlarmOnly: CreateModel.ReadAlarmOnly, + ReadHistorizedOnly: CreateModel.ReadHistorizedOnly)); + + return true; + } + + private bool IsScopeSelected(string scope) + { + return CreateModel.SelectedScopes.Contains(scope); + } + + private void SetScope(string scope, ChangeEventArgs eventArgs) + { + bool selected = eventArgs.Value is bool value && value; + if (selected) + { + CreateModel.SelectedScopes.Add(scope); + } + else + { + CreateModel.SelectedScopes.Remove(scope); + } + } + + private static string ConstraintText(ZB.MOM.WW.MxGateway.Server.Security.Authentication.ApiKeyConstraints constraints) + { + if (constraints.IsEmpty) + { + return "unconstrained"; + } + + List parts = []; + AddList(parts, "read_subtrees", constraints.ReadSubtrees); + AddList(parts, "write_subtrees", constraints.WriteSubtrees); + AddList(parts, "read_tag_globs", constraints.ReadTagGlobs); + AddList(parts, "write_tag_globs", constraints.WriteTagGlobs); + AddList(parts, "browse_subtrees", constraints.BrowseSubtrees); + if (constraints.MaxWriteClassification is { } max) + { + parts.Add($"max_write_classification={max}"); + } + + if (constraints.ReadAlarmOnly) + { + parts.Add("read_alarm_only"); + } + + if (constraints.ReadHistorizedOnly) + { + parts.Add("read_historized_only"); + } + + return string.Join("; ", parts); + } + + private static void AddList(List parts, string name, IReadOnlyList values) + { + if (values.Count > 0) + { + parts.Add($"{name}=[{string.Join(", ", values)}]"); + } + } + + private static IReadOnlyList ParseList(string? value) + { + return (value ?? string.Empty) + .Split([',', ';', '\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(item => !string.IsNullOrWhiteSpace(item)) + .ToArray(); + } + + private sealed class ApiKeyCreateModel + { + public string KeyId { get; set; } = string.Empty; + + public string DisplayName { get; set; } = string.Empty; + + public HashSet SelectedScopes { get; } = new(StringComparer.Ordinal); + + public string ReadSubtrees { get; set; } = string.Empty; + + public string WriteSubtrees { get; set; } = string.Empty; + + public string ReadTagGlobs { get; set; } = string.Empty; + + public string WriteTagGlobs { get; set; } = string.Empty; + + public string BrowseSubtrees { get; set; } = string.Empty; + + public string MaxWriteClassification { get; set; } = string.Empty; + + public bool ReadAlarmOnly { get; set; } + + public bool ReadHistorizedOnly { get; set; } + + public void Reset() + { + KeyId = string.Empty; + DisplayName = string.Empty; + SelectedScopes.Clear(); + ReadSubtrees = string.Empty; + WriteSubtrees = string.Empty; + ReadTagGlobs = string.Empty; + WriteTagGlobs = string.Empty; + BrowseSubtrees = string.Empty; + MaxWriteClassification = string.Empty; + ReadAlarmOnly = false; + ReadHistorizedOnly = false; + } + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/BrowsePage.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/BrowsePage.razor new file mode 100644 index 0000000..dc7ddc0 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/BrowsePage.razor @@ -0,0 +1,423 @@ +@page "/browse" +@page "/dashboard/browse" +@implements IAsyncDisposable +@inject IGalaxyHierarchyCache GalaxyCache +@inject IDashboardLiveDataService LiveData +@using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy +@using ZB.MOM.WW.MxGateway.Server.Galaxy + +Dashboard Browse + +
+
+

Browse

+
@HeaderLine()
+
+ +
+ +
+
+
+

Galaxy Hierarchy

+
+ + + @if (_roots.Count == 0) + { +
+ No Galaxy hierarchy is cached yet. The hierarchy refreshes from the + Galaxy Repository in the background — check the Galaxy tab for status. +
+ } + else if (!string.IsNullOrWhiteSpace(Search)) + { + @if (_searchMatches.Count == 0) + { +
No attributes match “@Search”.
+ } + else + { +
+ @foreach (GalaxyAttribute hit in _searchMatches) + { + GalaxyAttribute row = hit; +
+ · + @row.FullTagReference + @FormatType(row) + @if (row.IsAlarm) + { + alarm + } + @if (row.IsHistorized) + { + hist + } +
+ } +
+ @if (_searchMatches.Count >= SearchResultLimit) + { +
Showing the first @SearchResultLimit matches — refine the filter.
+ } + } + } + else + { +
+ @foreach (DashboardBrowseNode root in _roots) + { + + } +
+
Double-click a tag, or right-click for the menu.
+ } +
+ +
+
+

Subscription Panel

+
+
+ @if (_subscribed.Count > 0) + { + @_subscribed.Count subscribed + · + refresh 2s + @if (_workerPid is int pid) + { + · + worker pid @pid + } + + } +
+ + @if (!string.IsNullOrWhiteSpace(_readError)) + { +
Live read failed: @_readError
+ } + + @if (_subscribed.Count == 0) + { +
+ No tags subscribed. Right-click a tag in the hierarchy and choose + Add to subscription panel (or double-click it) to watch its + live value, quality and source timestamp here. +
+ } + else + { +
+ + + + + + + + + + + + + @foreach (string tag in _subscribed) + { + string key = tag; + DashboardTagValue? value = _values.GetValueOrDefault(key); + + + + + + + + + } + +
TagValueTypeQualityUpdated
@key@(value?.ValueText ?? "…")@(value?.DataType ?? "-") + @if (value is null) + { + + } + else + { + + @value.Quality + + @if (!string.IsNullOrWhiteSpace(value.Error)) + { + ! + } + } + @TimestampText(key, value) + +
+
+ } +
+
+ +@if (_menuVisible) +{ +
+
+
@(_menuAttribute?.AttributeName)
+ +
+} + +@code { + private const int SearchResultLimit = 300; + + private IReadOnlyList _roots = []; + private string _search = string.Empty; + private IReadOnlyList _searchMatches = []; + private readonly List _subscribed = []; + private readonly Dictionary _values = new(StringComparer.Ordinal); + // Per-tag bookkeeping for the Updated column: the signature of the value + // last seen, and when that value/quality was first observed. Lets the + // column move only on a real change, not on every 2s poll. + private readonly Dictionary _valueSignature = new(StringComparer.Ordinal); + private readonly Dictionary _observedChangeAt = new(StringComparer.Ordinal); + private string? _readError; + private int? _workerPid; + + private bool _menuVisible; + private int _menuX; + private int _menuY; + private GalaxyAttribute? _menuAttribute; + + private readonly CancellationTokenSource _cts = new(); + private Task? _pollTask; + + /// + protected override void OnInitialized() + { + _roots = DashboardBrowseTreeBuilder.Build(GalaxyCache.Current.Objects); + _pollTask = PollLoopAsync(); + } + + private string HeaderLine() + { + GalaxyHierarchyCacheEntry entry = GalaxyCache.Current; + return $"{entry.ObjectCount:N0} objects · {entry.AttributeCount:N0} attributes · " + + $"{entry.AlarmAttributeCount:N0} alarm attributes"; + } + + private string Search + { + get => _search; + set + { + _search = value ?? string.Empty; + _searchMatches = ComputeSearch(_search); + } + } + + private IReadOnlyList ComputeSearch(string rawQuery) + { + string query = rawQuery.Trim(); + if (query.Length == 0) + { + return []; + } + + List matches = []; + foreach (GalaxyObject galaxyObject in GalaxyCache.Current.Objects) + { + foreach (GalaxyAttribute attr in galaxyObject.Attributes) + { + if (attr.FullTagReference.Contains(query, StringComparison.OrdinalIgnoreCase) + || attr.AttributeName.Contains(query, StringComparison.OrdinalIgnoreCase)) + { + matches.Add(attr); + if (matches.Count >= SearchResultLimit) + { + return matches; + } + } + } + } + + return matches; + } + + private static string FormatType(GalaxyAttribute attr) + { + string baseType = string.IsNullOrWhiteSpace(attr.DataTypeName) ? "type?" : attr.DataTypeName; + if (!attr.IsArray) + { + return baseType; + } + + return attr.ArrayDimensionPresent ? $"{baseType}[{attr.ArrayDimension}]" : $"{baseType}[]"; + } + + private Task OnTagContextMenu((MouseEventArgs Event, GalaxyAttribute Attribute) args) + { + ShowMenu(args.Event, args.Attribute); + return Task.CompletedTask; + } + + private void ShowMenu(MouseEventArgs args, GalaxyAttribute attr) + { + _menuAttribute = attr; + _menuX = (int)args.ClientX; + _menuY = (int)args.ClientY; + _menuVisible = true; + } + + private void HideMenu() + { + _menuVisible = false; + _menuAttribute = null; + } + + private async Task AddMenuTagAsync() + { + GalaxyAttribute? attr = _menuAttribute; + HideMenu(); + if (attr is not null) + { + await AddTagAsync(attr.FullTagReference); + } + } + + private async Task AddTagAsync(string fullReference) + { + if (string.IsNullOrWhiteSpace(fullReference) + || _subscribed.Contains(fullReference, StringComparer.Ordinal)) + { + return; + } + + _subscribed.Add(fullReference); + await RefreshValuesAsync(); + } + + private void RemoveTag(string tag) + { + _subscribed.Remove(tag); + _values.Remove(tag); + _valueSignature.Remove(tag); + _observedChangeAt.Remove(tag); + } + + private void ClearAll() + { + _subscribed.Clear(); + _values.Clear(); + _valueSignature.Clear(); + _observedChangeAt.Clear(); + _readError = null; + } + + // The MXAccess source timestamp when the worker supplies one, otherwise the + // time the dashboard first observed the current value/quality. + private string TimestampText(string tag, DashboardTagValue? value) + { + if (value is null) + { + return "…"; + } + + if (value.SourceTimestamp is { } source) + { + return DashboardDisplay.DateTime(source); + } + + return _observedChangeAt.TryGetValue(tag, out DateTimeOffset observed) + ? DashboardDisplay.DateTime(observed) + : "-"; + } + + private static string TimestampTooltip(DashboardTagValue? value) + { + return value?.SourceTimestamp is not null + ? "MXAccess source timestamp." + : "When the dashboard first observed this value — MXAccess did not supply a source timestamp for this tag."; + } + + private async Task PollLoopAsync() + { + try + { + using PeriodicTimer timer = new(TimeSpan.FromSeconds(2)); + while (await timer.WaitForNextTickAsync(_cts.Token).ConfigureAwait(false)) + { + await InvokeAsync(RefreshValuesAsync).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + } + } + + private async Task RefreshValuesAsync() + { + if (_subscribed.Count == 0) + { + return; + } + + string[] tags = [.. _subscribed]; + DashboardLiveReadResult result = await LiveData.ReadAsync(tags, _cts.Token); + _readError = result.Error; + _workerPid = result.WorkerProcessId; + DateTimeOffset now = DateTimeOffset.UtcNow; + foreach (DashboardTagValue value in result.Values) + { + // Stamp the observed-change time only when the value/quality + // signature actually changes, so the Updated column does not + // tick on every poll for a static tag. + string signature = $"{value.ValueText}{value.Quality}{value.Ok}"; + if (!_valueSignature.TryGetValue(value.TagAddress, out string? previous) + || previous != signature) + { + _valueSignature[value.TagAddress] = signature; + _observedChangeAt[value.TagAddress] = now; + } + + _values[value.TagAddress] = value; + } + + StateHasChanged(); + } + + /// + public async ValueTask DisposeAsync() + { + await _cts.CancelAsync(); + if (_pollTask is not null) + { + try + { + await _pollTask; + } + catch (OperationCanceledException) + { + } + } + + _cts.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/src/MxGateway.Server/Dashboard/Components/Pages/DashboardHome.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/DashboardHome.razor similarity index 100% rename from src/MxGateway.Server/Dashboard/Components/Pages/DashboardHome.razor rename to src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/DashboardHome.razor diff --git a/src/MxGateway.Server/Dashboard/Components/Pages/EventsPage.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/EventsPage.razor similarity index 100% rename from src/MxGateway.Server/Dashboard/Components/Pages/EventsPage.razor rename to src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/EventsPage.razor diff --git a/src/MxGateway.Server/Dashboard/Components/Pages/GalaxyPage.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/GalaxyPage.razor similarity index 97% rename from src/MxGateway.Server/Dashboard/Components/Pages/GalaxyPage.razor rename to src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/GalaxyPage.razor index b9f64fd..ba3cc2a 100644 --- a/src/MxGateway.Server/Dashboard/Components/Pages/GalaxyPage.razor +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/GalaxyPage.razor @@ -19,12 +19,12 @@ else
- + + -
@if (Snapshot.Galaxy.Status == DashboardGalaxyStatus.Unknown) @@ -141,7 +141,7 @@ else @code { [Inject] - private IOptions GalaxyOptions { get; set; } = null!; + private IOptions GalaxyOptions { get; set; } = null!; private string RefreshHeading() { diff --git a/src/MxGateway.Server/Dashboard/Components/Pages/SessionDetailsPage.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SessionDetailsPage.razor similarity index 100% rename from src/MxGateway.Server/Dashboard/Components/Pages/SessionDetailsPage.razor rename to src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SessionDetailsPage.razor diff --git a/src/MxGateway.Server/Dashboard/Components/Pages/SessionsPage.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SessionsPage.razor similarity index 100% rename from src/MxGateway.Server/Dashboard/Components/Pages/SessionsPage.razor rename to src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SessionsPage.razor diff --git a/src/MxGateway.Server/Dashboard/Components/Pages/SettingsPage.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SettingsPage.razor similarity index 100% rename from src/MxGateway.Server/Dashboard/Components/Pages/SettingsPage.razor rename to src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SettingsPage.razor diff --git a/src/MxGateway.Server/Dashboard/Components/Pages/WorkersPage.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/WorkersPage.razor similarity index 100% rename from src/MxGateway.Server/Dashboard/Components/Pages/WorkersPage.razor rename to src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/WorkersPage.razor diff --git a/src/MxGateway.Server/Dashboard/Components/Routes.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Routes.razor similarity index 100% rename from src/MxGateway.Server/Dashboard/Components/Routes.razor rename to src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Routes.razor diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Shared/BrowseTreeNodeView.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Shared/BrowseTreeNodeView.razor new file mode 100644 index 0000000..57b4b0b --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Shared/BrowseTreeNodeView.razor @@ -0,0 +1,102 @@ +@using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy + +@* + Recursive Browse hierarchy node. Renders one Galaxy object, its child + objects (recursively), and its attributes as right-clickable tag rows. + Expansion state is local; children render only while expanded. +*@ + +
+
+ @if (Node.HasChildren) + { + + } + else + { + + } + + @(Node.IsArea ? "▣" : "◇") + @Node.DisplayName + @if (!string.IsNullOrWhiteSpace(Node.Object.TagName) + && !string.Equals(Node.Object.TagName, Node.DisplayName, StringComparison.Ordinal)) + { + @Node.Object.TagName + } + +
+ @if (_expanded) + { +
+ @foreach (DashboardBrowseNode child in Node.Children) + { + + } + @foreach (GalaxyAttribute attr in Node.Attributes) + { + GalaxyAttribute row = attr; +
+ + · + @row.AttributeName + @DisplayType(row) + @if (row.IsAlarm) + { + alarm + } + @if (row.IsHistorized) + { + hist + } +
+ } +
+ } +
+ +@code { + /// The hierarchy node this view renders. + [Parameter] + [EditorRequired] + public DashboardBrowseNode Node { get; set; } = null!; + + /// Raised with a tag's full reference when the operator double-clicks it. + [Parameter] + public EventCallback OnAddTag { get; set; } + + /// Raised when an attribute row is right-clicked, for the context menu. + [Parameter] + public EventCallback<(MouseEventArgs Event, GalaxyAttribute Attribute)> OnTagContextMenu { get; set; } + + private bool _expanded; + + private void Toggle() + { + if (Node.HasChildren) + { + _expanded = !_expanded; + } + } + + private static string DisplayType(GalaxyAttribute attribute) + { + string baseType = string.IsNullOrWhiteSpace(attribute.DataTypeName) + ? "type?" + : attribute.DataTypeName; + if (attribute.IsArray) + { + return attribute.ArrayDimensionPresent + ? $"{baseType}[{attribute.ArrayDimension}]" + : $"{baseType}[]"; + } + + return baseType; + } +} diff --git a/src/MxGateway.Server/Dashboard/Components/Shared/FaultList.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Shared/FaultList.razor similarity index 100% rename from src/MxGateway.Server/Dashboard/Components/Shared/FaultList.razor rename to src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Shared/FaultList.razor diff --git a/src/MxGateway.Server/Dashboard/Components/Shared/MetricCard.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Shared/MetricCard.razor similarity index 66% rename from src/MxGateway.Server/Dashboard/Components/Shared/MetricCard.razor rename to src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Shared/MetricCard.razor index 78264b3..f3069b5 100644 --- a/src/MxGateway.Server/Dashboard/Components/Shared/MetricCard.razor +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Shared/MetricCard.razor @@ -1,4 +1,4 @@ -
+
@Label
@Value
@@ -18,4 +18,8 @@ [Parameter] public string? Detail { get; set; } + + /// Spans the card across two grid columns for long values such as timestamps. + [Parameter] + public bool Wide { get; set; } } diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Shared/StatusBadge.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Shared/StatusBadge.razor new file mode 100644 index 0000000..910851e --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Shared/StatusBadge.razor @@ -0,0 +1,16 @@ +@Text + +@code { + [Parameter] + public string? Text { get; set; } + + private string CssClass => Text switch + { + "Ready" or "Healthy" or "Active" => "chip-ok", + "Creating" or "StartingWorker" or "WaitingForPipe" or "InitializingWorker" or "Closing" => "chip-warn", + "Stale" or "Degraded" => "chip-warn", + "Faulted" or "Unavailable" => "chip-bad", + "Closed" or "Revoked" or "Unknown" => "chip-idle", + _ => "chip-idle" + }; +} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/_Imports.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/_Imports.razor new file mode 100644 index 0000000..5a1db93 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/_Imports.razor @@ -0,0 +1,13 @@ +@using Microsoft.AspNetCore.Components.Authorization +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.Extensions.Options +@using ZB.MOM.WW.MxGateway.Contracts.Proto +@using ZB.MOM.WW.MxGateway.Server.Configuration +@using ZB.MOM.WW.MxGateway.Server.Dashboard +@using ZB.MOM.WW.MxGateway.Server.Dashboard.Components.Layout +@using ZB.MOM.WW.MxGateway.Server.Dashboard.Components.Shared +@using ZB.MOM.WW.MxGateway.Server.Security.Authorization +@using ZB.MOM.WW.MxGateway.Server.Workers +@using static Microsoft.AspNetCore.Components.Web.RenderMode diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardActiveAlarm.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardActiveAlarm.cs new file mode 100644 index 0000000..90708e2 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardActiveAlarm.cs @@ -0,0 +1,63 @@ +using ZB.MOM.WW.MxGateway.Contracts.Proto; + +namespace ZB.MOM.WW.MxGateway.Server.Dashboard; + +/// +/// One active-alarm row as shown on the dashboard Alarms tab. Projected +/// from an so the Razor component never +/// touches protobuf types directly. +/// +public sealed record DashboardActiveAlarm( + string Reference, + string Provider, + string Area, + string Source, + string AlarmType, + int Severity, + AlarmConditionState State, + DateTimeOffset? LastTransition, + string OperatorUser, + string OperatorComment, + string Description) +{ + /// Projects a worker active-alarm snapshot into a dashboard alarm row. + /// The snapshot returned by QueryActiveAlarms. + /// The projected dashboard alarm. + public static DashboardActiveAlarm FromSnapshot(ActiveAlarmSnapshot snapshot) + { + ArgumentNullException.ThrowIfNull(snapshot); + + string provider = string.Empty; + string reference = snapshot.AlarmFullReference ?? string.Empty; + int bang = reference.IndexOf('!', StringComparison.Ordinal); + if (bang > 0) + { + provider = reference[..bang]; + } + + return new DashboardActiveAlarm( + Reference: reference, + Provider: provider, + Area: snapshot.Category ?? string.Empty, + Source: snapshot.SourceObjectReference ?? string.Empty, + AlarmType: snapshot.AlarmTypeName ?? string.Empty, + Severity: snapshot.Severity, + State: snapshot.CurrentState, + LastTransition: snapshot.LastTransitionTimestamp?.ToDateTimeOffset(), + OperatorUser: snapshot.OperatorUser ?? string.Empty, + OperatorComment: snapshot.OperatorComment ?? string.Empty, + Description: snapshot.Description ?? string.Empty); + } + + /// True when this alarm is active and not yet acknowledged. + public bool IsUnacknowledged => State == AlarmConditionState.Active; +} + +/// Result of a dashboard active-alarm query. +/// The active alarms, or an empty list on error. +/// A diagnostic message when the query failed; otherwise null. +/// The worker process id backing the dashboard session, when available. +public sealed record DashboardAlarmQueryResult( + IReadOnlyList Alarms, + string? Error, + int? WorkerProcessId); diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardApiKeyAuthorization.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardApiKeyAuthorization.cs new file mode 100644 index 0000000..79b7671 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardApiKeyAuthorization.cs @@ -0,0 +1,22 @@ +using System.Security.Claims; +using Microsoft.Extensions.Options; +using ZB.MOM.WW.MxGateway.Server.Configuration; + +namespace ZB.MOM.WW.MxGateway.Server.Dashboard; + +public sealed class DashboardApiKeyAuthorization(IOptions options) +{ + public bool CanManage(ClaimsPrincipal user) + { + if (user.Identity?.IsAuthenticated != true) + { + return false; + } + + string requiredGroup = options.Value.Ldap.RequiredGroup; + IEnumerable groups = user.FindAll(DashboardAuthenticationDefaults.LdapGroupClaimType) + .Select(claim => claim.Value); + + return DashboardAuthenticator.IsMemberOfRequiredGroup(groups, requiredGroup); + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardApiKeyManagementRequest.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardApiKeyManagementRequest.cs new file mode 100644 index 0000000..96116b9 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardApiKeyManagementRequest.cs @@ -0,0 +1,9 @@ +using ZB.MOM.WW.MxGateway.Server.Security.Authentication; + +namespace ZB.MOM.WW.MxGateway.Server.Dashboard; + +public sealed record DashboardApiKeyManagementRequest( + string KeyId, + string DisplayName, + IReadOnlySet Scopes, + ApiKeyConstraints Constraints); diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardApiKeyManagementResult.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardApiKeyManagementResult.cs new file mode 100644 index 0000000..a4d6623 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardApiKeyManagementResult.cs @@ -0,0 +1,17 @@ +namespace ZB.MOM.WW.MxGateway.Server.Dashboard; + +public sealed record DashboardApiKeyManagementResult( + bool Succeeded, + string Message, + string? ApiKey) +{ + public static DashboardApiKeyManagementResult Success(string message, string? apiKey = null) + { + return new DashboardApiKeyManagementResult(true, message, apiKey); + } + + public static DashboardApiKeyManagementResult Fail(string message) + { + return new DashboardApiKeyManagementResult(false, message, null); + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardApiKeyManagementService.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardApiKeyManagementService.cs new file mode 100644 index 0000000..5fc8236 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardApiKeyManagementService.cs @@ -0,0 +1,205 @@ +using System.Security.Claims; +using Microsoft.Data.Sqlite; +using ZB.MOM.WW.MxGateway.Server.Security.Authentication; +using ZB.MOM.WW.MxGateway.Server.Security.Authorization; + +namespace ZB.MOM.WW.MxGateway.Server.Dashboard; + +public sealed class DashboardApiKeyManagementService( + DashboardApiKeyAuthorization authorization, + IApiKeyAdminStore adminStore, + IApiKeyAuditStore auditStore, + IApiKeySecretHasher hasher, + IHttpContextAccessor httpContextAccessor) : IDashboardApiKeyManagementService +{ + private const string UnauthorizedMessage = "Sign in with an authorized LDAP account to manage API keys."; + + public bool CanManage(ClaimsPrincipal user) + { + return authorization.CanManage(user); + } + + public async Task CreateAsync( + ClaimsPrincipal user, + DashboardApiKeyManagementRequest request, + CancellationToken cancellationToken) + { + if (!CanManage(user)) + { + return DashboardApiKeyManagementResult.Fail(UnauthorizedMessage); + } + + string? validation = ValidateCreateRequest(request); + if (validation is not null) + { + return DashboardApiKeyManagementResult.Fail(validation); + } + + string keyId = request.KeyId.Trim(); + string secret = ApiKeySecretGenerator.Generate(); + string apiKey = FormatApiKey(keyId, secret); + + try + { + await adminStore.CreateAsync( + new ApiKeyCreateRequest( + KeyId: keyId, + KeyPrefix: $"mxgw_{keyId}", + SecretHash: hasher.HashSecret(secret), + DisplayName: request.DisplayName.Trim(), + Scopes: request.Scopes, + Constraints: request.Constraints, + CreatedUtc: DateTimeOffset.UtcNow), + cancellationToken) + .ConfigureAwait(false); + + await AppendAuditAsync(keyId, "dashboard-create-key", null, cancellationToken).ConfigureAwait(false); + + return DashboardApiKeyManagementResult.Success("API key created. Copy the key now; it will not be shown again.", apiKey); + } + catch (ApiKeyPepperUnavailableException) + { + return DashboardApiKeyManagementResult.Fail("API key pepper is not configured."); + } + catch (SqliteException exception) when (exception.SqliteErrorCode == 19) + { + return DashboardApiKeyManagementResult.Fail("An API key with that id already exists."); + } + } + + public async Task RevokeAsync( + ClaimsPrincipal user, + string keyId, + CancellationToken cancellationToken) + { + if (!CanManage(user)) + { + return DashboardApiKeyManagementResult.Fail(UnauthorizedMessage); + } + + string? validation = ValidateKeyId(keyId); + if (validation is not null) + { + return DashboardApiKeyManagementResult.Fail(validation); + } + + string normalizedKeyId = keyId.Trim(); + bool revoked = await adminStore + .RevokeAsync(normalizedKeyId, DateTimeOffset.UtcNow, cancellationToken) + .ConfigureAwait(false); + + await AppendAuditAsync( + normalizedKeyId, + "dashboard-revoke-key", + revoked ? "revoked" : "not-found-or-already-revoked", + cancellationToken) + .ConfigureAwait(false); + + return revoked + ? DashboardApiKeyManagementResult.Success("API key revoked.") + : DashboardApiKeyManagementResult.Fail("API key was not found or is already revoked."); + } + + public async Task RotateAsync( + ClaimsPrincipal user, + string keyId, + CancellationToken cancellationToken) + { + if (!CanManage(user)) + { + return DashboardApiKeyManagementResult.Fail(UnauthorizedMessage); + } + + string? validation = ValidateKeyId(keyId); + if (validation is not null) + { + return DashboardApiKeyManagementResult.Fail(validation); + } + + string normalizedKeyId = keyId.Trim(); + string secret = ApiKeySecretGenerator.Generate(); + string apiKey = FormatApiKey(normalizedKeyId, secret); + + try + { + bool rotated = await adminStore + .RotateAsync(normalizedKeyId, hasher.HashSecret(secret), DateTimeOffset.UtcNow, cancellationToken) + .ConfigureAwait(false); + + await AppendAuditAsync( + normalizedKeyId, + "dashboard-rotate-key", + rotated ? "rotated" : "not-found", + cancellationToken) + .ConfigureAwait(false); + + return rotated + ? DashboardApiKeyManagementResult.Success("API key rotated. Copy the key now; it will not be shown again.", apiKey) + : DashboardApiKeyManagementResult.Fail("API key was not found."); + } + catch (ApiKeyPepperUnavailableException) + { + return DashboardApiKeyManagementResult.Fail("API key pepper is not configured."); + } + } + + private async Task AppendAuditAsync( + string? keyId, + string eventType, + string? details, + CancellationToken cancellationToken) + { + await auditStore.AppendAsync( + new ApiKeyAuditEntry( + KeyId: keyId, + EventType: eventType, + RemoteAddress: httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString(), + Details: details), + cancellationToken) + .ConfigureAwait(false); + } + + private static string? ValidateCreateRequest(DashboardApiKeyManagementRequest request) + { + string? keyIdValidation = ValidateKeyId(request.KeyId); + if (keyIdValidation is not null) + { + return keyIdValidation; + } + + if (string.IsNullOrWhiteSpace(request.DisplayName)) + { + return "Display name is required."; + } + + string[] unknownScopes = request.Scopes + .Where(scope => !GatewayScopes.IsKnown(scope)) + .ToArray(); + if (unknownScopes.Length > 0) + { + return $"Unknown scope(s): {string.Join(", ", unknownScopes)}. " + + $"Valid scopes are: {string.Join(", ", GatewayScopes.All)}."; + } + + return null; + } + + private static string? ValidateKeyId(string keyId) + { + if (string.IsNullOrWhiteSpace(keyId)) + { + return "API key id is required."; + } + + return keyId.Trim().All(character => + char.IsAsciiLetterOrDigit(character) + || character is '.' or '-') + ? null + : "API key id may contain only letters, numbers, periods, and hyphens."; + } + + private static string FormatApiKey(string keyId, string secret) + { + return $"mxgw_{keyId}_{secret}"; + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardApiKeySummary.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardApiKeySummary.cs new file mode 100644 index 0000000..820342b --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardApiKeySummary.cs @@ -0,0 +1,12 @@ +using ZB.MOM.WW.MxGateway.Server.Security.Authentication; + +namespace ZB.MOM.WW.MxGateway.Server.Dashboard; + +public sealed record DashboardApiKeySummary( + string KeyId, + string DisplayName, + IReadOnlySet Scopes, + ApiKeyConstraints Constraints, + DateTimeOffset CreatedUtc, + DateTimeOffset? LastUsedUtc, + DateTimeOffset? RevokedUtc); diff --git a/src/MxGateway.Server/Dashboard/DashboardAuthenticationDefaults.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthenticationDefaults.cs similarity index 90% rename from src/MxGateway.Server/Dashboard/DashboardAuthenticationDefaults.cs rename to src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthenticationDefaults.cs index aac0b96..55b2613 100644 --- a/src/MxGateway.Server/Dashboard/DashboardAuthenticationDefaults.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthenticationDefaults.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Dashboard; +namespace ZB.MOM.WW.MxGateway.Server.Dashboard; public static class DashboardAuthenticationDefaults { diff --git a/src/MxGateway.Server/Dashboard/DashboardAuthenticationResult.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthenticationResult.cs similarity index 96% rename from src/MxGateway.Server/Dashboard/DashboardAuthenticationResult.cs rename to src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthenticationResult.cs index 22cceac..b656c79 100644 --- a/src/MxGateway.Server/Dashboard/DashboardAuthenticationResult.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthenticationResult.cs @@ -1,6 +1,6 @@ using System.Security.Claims; -namespace MxGateway.Server.Dashboard; +namespace ZB.MOM.WW.MxGateway.Server.Dashboard; /// /// Result of a dashboard authentication attempt. diff --git a/src/MxGateway.Server/Dashboard/DashboardAuthenticator.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthenticator.cs similarity index 92% rename from src/MxGateway.Server/Dashboard/DashboardAuthenticator.cs rename to src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthenticator.cs index f985ba9..a5fb097 100644 --- a/src/MxGateway.Server/Dashboard/DashboardAuthenticator.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthenticator.cs @@ -1,10 +1,11 @@ using System.Security.Claims; using System.Text; using Microsoft.Extensions.Options; -using MxGateway.Server.Configuration; +using ZB.MOM.WW.MxGateway.Server.Configuration; +using ZB.MOM.WW.MxGateway.Server.Security.Authorization; using Novell.Directory.Ldap; -namespace MxGateway.Server.Dashboard; +namespace ZB.MOM.WW.MxGateway.Server.Dashboard; public sealed class DashboardAuthenticator( IOptions options, @@ -238,10 +239,16 @@ public sealed class DashboardAuthenticator( string displayName, IEnumerable groups) { + // CreatePrincipal is reached only after IsMemberOfRequiredGroup passed, + // so the authenticated user is authorized for the dashboard. Emit the + // admin scope claim that DashboardAuthorizationHandler checks when + // Dashboard:RequireAdminScope is enabled — without it, every LDAP login + // would be denied once route-level authorization is enforced. List claims = [ new Claim(ClaimTypes.NameIdentifier, username), - new Claim(ClaimTypes.Name, displayName) + new Claim(ClaimTypes.Name, displayName), + new Claim(DashboardAuthenticationDefaults.ScopeClaimType, GatewayScopes.Admin) ]; claims.AddRange(groups.Select(group => new Claim( diff --git a/src/MxGateway.Server/Dashboard/DashboardAuthorizationHandler.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthorizationHandler.cs similarity index 91% rename from src/MxGateway.Server/Dashboard/DashboardAuthorizationHandler.cs rename to src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthorizationHandler.cs index d523e54..ec10f7a 100644 --- a/src/MxGateway.Server/Dashboard/DashboardAuthorizationHandler.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthorizationHandler.cs @@ -1,10 +1,10 @@ using System.Net; using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.Options; -using MxGateway.Server.Configuration; -using MxGateway.Server.Security.Authorization; +using ZB.MOM.WW.MxGateway.Server.Configuration; +using ZB.MOM.WW.MxGateway.Server.Security.Authorization; -namespace MxGateway.Server.Dashboard; +namespace ZB.MOM.WW.MxGateway.Server.Dashboard; public sealed class DashboardAuthorizationHandler( IHttpContextAccessor httpContextAccessor, diff --git a/src/MxGateway.Server/Dashboard/DashboardAuthorizationRequirement.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthorizationRequirement.cs similarity index 72% rename from src/MxGateway.Server/Dashboard/DashboardAuthorizationRequirement.cs rename to src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthorizationRequirement.cs index 45862e2..37685b8 100644 --- a/src/MxGateway.Server/Dashboard/DashboardAuthorizationRequirement.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthorizationRequirement.cs @@ -1,5 +1,5 @@ using Microsoft.AspNetCore.Authorization; -namespace MxGateway.Server.Dashboard; +namespace ZB.MOM.WW.MxGateway.Server.Dashboard; public sealed class DashboardAuthorizationRequirement : IAuthorizationRequirement; diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardBrowseModel.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardBrowseModel.cs new file mode 100644 index 0000000..8cf7d5d --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardBrowseModel.cs @@ -0,0 +1,95 @@ +using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; + +namespace ZB.MOM.WW.MxGateway.Server.Dashboard; + +/// +/// One node in the dashboard Browse hierarchy tree. Wraps a Galaxy object +/// and its child objects; the object's attributes are the leaf "tags" the +/// operator can right-click and add to the subscription panel. +/// +public sealed class DashboardBrowseNode +{ + /// The underlying Galaxy object for this node. + public required GalaxyObject Object { get; init; } + + /// Child objects contained by this object, sorted areas-first then by name. + public List Children { get; } = []; + + /// The label shown for this node in the tree. + public string DisplayName => + !string.IsNullOrWhiteSpace(Object.BrowseName) ? Object.BrowseName + : !string.IsNullOrWhiteSpace(Object.ContainedName) ? Object.ContainedName + : Object.TagName; + + /// True when this node is a Galaxy area rather than an instance object. + public bool IsArea => Object.IsArea; + + /// The object's attributes — the browsable tags. + public IReadOnlyList Attributes => Object.Attributes; + + /// True when the node has child objects or attributes to expand. + public bool HasChildren => Children.Count > 0 || Object.Attributes.Count > 0; +} + +/// +/// Builds the dashboard Browse tree from the flat Galaxy object list held +/// by IGalaxyHierarchyCache. Pure and side-effect free so the +/// parent/child linkage and ordering rules are unit-testable. +/// +public static class DashboardBrowseTreeBuilder +{ + /// Builds the root nodes of the Browse tree. + /// The flat Galaxy object list. + /// The root nodes, sorted areas-first then alphabetically. + public static IReadOnlyList Build(IReadOnlyList objects) + { + ArgumentNullException.ThrowIfNull(objects); + + Dictionary nodes = new(objects.Count); + foreach (GalaxyObject galaxyObject in objects) + { + // Last write wins on a duplicate gobject id — Galaxy ids are unique + // in practice, but guard so the dictionary build never throws. + nodes[galaxyObject.GobjectId] = new DashboardBrowseNode { Object = galaxyObject }; + } + + List roots = []; + foreach (DashboardBrowseNode node in nodes.Values) + { + int parentId = node.Object.ParentGobjectId; + if (parentId != 0 + && parentId != node.Object.GobjectId + && nodes.TryGetValue(parentId, out DashboardBrowseNode? parent)) + { + parent.Children.Add(node); + } + else + { + roots.Add(node); + } + } + + SortRecursive(roots); + return roots; + } + + private static void SortRecursive(List nodes) + { + nodes.Sort(CompareNodes); + foreach (DashboardBrowseNode node in nodes) + { + SortRecursive(node.Children); + } + } + + // Areas sort before instance objects; within a group, by display name. + private static int CompareNodes(DashboardBrowseNode left, DashboardBrowseNode right) + { + if (left.IsArea != right.IsArea) + { + return left.IsArea ? -1 : 1; + } + + return string.Compare(left.DisplayName, right.DisplayName, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardConnectionStringDisplay.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardConnectionStringDisplay.cs new file mode 100644 index 0000000..93b884a --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardConnectionStringDisplay.cs @@ -0,0 +1,28 @@ +using Microsoft.Data.SqlClient; + +namespace ZB.MOM.WW.MxGateway.Server.Dashboard; + +public static class DashboardConnectionStringDisplay +{ + public static string GalaxyRepositoryConnectionString(string connectionString) + { + try + { + SqlConnectionStringBuilder builder = new(connectionString); + SqlConnectionStringBuilder display = new() + { + DataSource = builder.DataSource, + InitialCatalog = builder.InitialCatalog, + IntegratedSecurity = builder.IntegratedSecurity, + Encrypt = builder.Encrypt, + TrustServerCertificate = builder.TrustServerCertificate, + }; + + return display.ConnectionString; + } + catch (ArgumentException) + { + return "[invalid connection string]"; + } + } +} diff --git a/src/MxGateway.Server/Dashboard/DashboardEndpointRouteBuilderExtensions.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardEndpointRouteBuilderExtensions.cs similarity index 89% rename from src/MxGateway.Server/Dashboard/DashboardEndpointRouteBuilderExtensions.cs rename to src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardEndpointRouteBuilderExtensions.cs index 71fcce4..a11fc04 100644 --- a/src/MxGateway.Server/Dashboard/DashboardEndpointRouteBuilderExtensions.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardEndpointRouteBuilderExtensions.cs @@ -2,10 +2,10 @@ using System.Text.Encodings.Web; using Microsoft.AspNetCore.Antiforgery; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http.HttpResults; -using MxGateway.Server.Configuration; -using MxGateway.Server.Dashboard.Components; +using ZB.MOM.WW.MxGateway.Server.Configuration; +using ZB.MOM.WW.MxGateway.Server.Dashboard.Components; -namespace MxGateway.Server.Dashboard; +namespace ZB.MOM.WW.MxGateway.Server.Dashboard; /// Endpoint extensions for registering the gateway dashboard routes. public static class DashboardEndpointRouteBuilderExtensions @@ -52,8 +52,13 @@ public static class DashboardEndpointRouteBuilderExtensions .AllowAnonymous() .WithName("DashboardAccessDenied"); + // Every dashboard Razor component requires an authorized session. The + // login/logout/denied endpoints above opt out via AllowAnonymous(); an + // unauthenticated request to a component route is challenged by the + // cookie scheme and redirected to the login page. dashboard.MapRazorComponents() - .AddInteractiveServerRenderMode(); + .AddInteractiveServerRenderMode() + .RequireAuthorization(DashboardAuthenticationDefaults.AuthorizationPolicy); return endpoints; } @@ -169,11 +174,17 @@ public static class DashboardEndpointRouteBuilderExtensions {HtmlEncoder.Default.Encode(title)} + -
-

{HtmlEncoder.Default.Encode(title)}

+
+ MXAccess Gateway +
+
+
+

{HtmlEncoder.Default.Encode(title)}

+
{body}
diff --git a/src/MxGateway.Server/Dashboard/DashboardFaultSummary.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardFaultSummary.cs similarity index 79% rename from src/MxGateway.Server/Dashboard/DashboardFaultSummary.cs rename to src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardFaultSummary.cs index 67285f4..33e17a8 100644 --- a/src/MxGateway.Server/Dashboard/DashboardFaultSummary.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardFaultSummary.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Dashboard; +namespace ZB.MOM.WW.MxGateway.Server.Dashboard; public sealed record DashboardFaultSummary( string Source, diff --git a/src/MxGateway.Server/Dashboard/DashboardGalaxyProjector.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardGalaxyProjector.cs similarity index 74% rename from src/MxGateway.Server/Dashboard/DashboardGalaxyProjector.cs rename to src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardGalaxyProjector.cs index 575f73e..f09aaac 100644 --- a/src/MxGateway.Server/Dashboard/DashboardGalaxyProjector.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardGalaxyProjector.cs @@ -1,6 +1,6 @@ -using MxGateway.Server.Galaxy; +using ZB.MOM.WW.MxGateway.Server.Galaxy; -namespace MxGateway.Server.Dashboard; +namespace ZB.MOM.WW.MxGateway.Server.Dashboard; /// Projects the precomputed Galaxy cache dashboard summary. internal static class DashboardGalaxyProjector diff --git a/src/MxGateway.Server/Dashboard/DashboardGalaxySummary.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardGalaxySummary.cs similarity index 97% rename from src/MxGateway.Server/Dashboard/DashboardGalaxySummary.cs rename to src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardGalaxySummary.cs index 5742405..247d028 100644 --- a/src/MxGateway.Server/Dashboard/DashboardGalaxySummary.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardGalaxySummary.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Dashboard; +namespace ZB.MOM.WW.MxGateway.Server.Dashboard; /// /// Snapshot of the Galaxy Repository (ZB) browse state surfaced on the dashboard. diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardLiveDataService.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardLiveDataService.cs new file mode 100644 index 0000000..9e7b4b2 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardLiveDataService.cs @@ -0,0 +1,213 @@ +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Server.Alarms; +using ZB.MOM.WW.MxGateway.Server.Sessions; + +namespace ZB.MOM.WW.MxGateway.Server.Dashboard; + +/// +/// Default . Owns one shared gateway +/// session for the whole dashboard: it is opened lazily on first use and +/// re-opened transparently whenever it faults, is closed, or its lease +/// expires. All access is serialised through so the +/// single backing worker only ever sees one in-flight command. +/// +public sealed class DashboardLiveDataService : IDashboardLiveDataService, IAsyncDisposable +{ + private const string BackendName = "Galaxy"; + private const string ClientName = "mxgateway-dashboard"; + private static readonly TimeSpan ReadTimeout = TimeSpan.FromSeconds(5); + + private readonly ISessionManager _sessionManager; + private readonly IGatewayAlarmService _alarmService; + private readonly ILogger _logger; + private readonly SemaphoreSlim _gate = new(1, 1); + private readonly HashSet _subscribed = new(StringComparer.OrdinalIgnoreCase); + + private GatewaySession? _session; + private int _serverHandle; + private bool _disposed; + + /// Initializes the live-data service. + /// Gateway session manager. + /// Gateway central alarm service. + /// Diagnostic logger. + public DashboardLiveDataService( + ISessionManager sessionManager, + IGatewayAlarmService alarmService, + ILogger logger) + { + _sessionManager = sessionManager ?? throw new ArgumentNullException(nameof(sessionManager)); + _alarmService = alarmService ?? throw new ArgumentNullException(nameof(alarmService)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public async Task ReadAsync( + IReadOnlyCollection tagAddresses, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(tagAddresses); + if (tagAddresses.Count == 0) + { + return DashboardLiveReadResult.Empty; + } + + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + (GatewaySession session, int serverHandle) = await EnsureReadyAsync(cancellationToken) + .ConfigureAwait(false); + + string[] toSubscribe = tagAddresses.Where(tag => !_subscribed.Contains(tag)).ToArray(); + if (toSubscribe.Length > 0) + { + await session.SubscribeBulkAsync(serverHandle, toSubscribe, cancellationToken) + .ConfigureAwait(false); + foreach (string tag in toSubscribe) + { + _subscribed.Add(tag); + } + } + + IReadOnlyList results = await session + .ReadBulkAsync(serverHandle, tagAddresses.ToArray(), ReadTimeout, cancellationToken) + .ConfigureAwait(false); + + DashboardTagValue[] values = results + .Select(DashboardTagValue.FromBulkReadResult) + .ToArray(); + return new DashboardLiveReadResult(values, null, session.SessionId, session.WorkerProcessId); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + InvalidateSession(); + _logger.LogWarning(exception, "Dashboard live read failed; the dashboard session will be re-opened."); + return new DashboardLiveReadResult([], exception.Message, null, null); + } + finally + { + _gate.Release(); + } + } + + /// + public Task QueryAlarmsAsync(CancellationToken cancellationToken) + { + // Alarms come from the gateway's always-on central monitor; the + // dashboard reads its in-process cache directly — no session needed. + DashboardActiveAlarm[] alarms = _alarmService.CurrentAlarms + .Select(DashboardActiveAlarm.FromSnapshot) + .ToArray(); + + string? error = _alarmService.State is GatewayAlarmMonitorState.Monitoring + or GatewayAlarmMonitorState.Disabled + ? null + : _alarmService.LastError ?? $"Alarm monitor is {_alarmService.State}."; + + return Task.FromResult(new DashboardAlarmQueryResult(alarms, error, _alarmService.WorkerProcessId)); + } + + // Returns a Ready session + its Register server handle, opening a fresh + // session when none exists or the current one is no longer usable. Callers + // must hold _gate. + private async Task<(GatewaySession Session, int ServerHandle)> EnsureReadyAsync( + CancellationToken cancellationToken) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + GatewaySession? existing = _session; + if (existing is not null + && existing.State == SessionState.Ready + && _sessionManager.TryGetSession(existing.SessionId, out _)) + { + return (existing, _serverHandle); + } + + if (existing is not null) + { + _logger.LogInformation( + "Dashboard session {SessionId} is no longer usable (state {State}); re-opening.", + existing.SessionId, + existing.State); + await CloseQuietlyAsync(existing.SessionId).ConfigureAwait(false); + } + + _subscribed.Clear(); + _session = null; + + GatewaySession session = await _sessionManager.OpenSessionAsync( + new SessionOpenRequest(BackendName, ClientName, Guid.NewGuid().ToString("N"), CommandTimeout: null), + ClientName, + cancellationToken) + .ConfigureAwait(false); + + WorkerCommandReply reply = await session.InvokeAsync( + new WorkerCommand + { + Command = new MxCommand + { + Kind = MxCommandKind.Register, + Register = new RegisterCommand { ClientName = ClientName }, + }, + }, + cancellationToken) + .ConfigureAwait(false); + + int? serverHandle = reply.Reply?.Register?.ServerHandle; + if (serverHandle is null) + { + string diagnostic = reply.Reply?.ProtocolStatus?.Message + ?? reply.Reply?.DiagnosticMessage + ?? "Worker did not return a server handle for Register."; + await CloseQuietlyAsync(session.SessionId).ConfigureAwait(false); + throw new InvalidOperationException($"Dashboard session registration failed: {diagnostic}"); + } + + _session = session; + _serverHandle = serverHandle.Value; + _logger.LogInformation( + "Dashboard session {SessionId} opened (worker pid {WorkerPid}).", + session.SessionId, + session.WorkerProcessId); + return (session, _serverHandle); + } + + // Drops the cached session so the next call re-opens. Callers must hold _gate. + private void InvalidateSession() + { + _session = null; + _serverHandle = 0; + _subscribed.Clear(); + } + + private async Task CloseQuietlyAsync(string sessionId) + { + try + { + await _sessionManager.CloseSessionAsync(sessionId, CancellationToken.None).ConfigureAwait(false); + } + catch (Exception exception) + { + _logger.LogDebug(exception, "Closing stale dashboard session {SessionId} failed.", sessionId); + } + } + + /// + public async ValueTask DisposeAsync() + { + if (_disposed) + { + return; + } + + _disposed = true; + GatewaySession? session = _session; + _session = null; + if (session is not null) + { + await CloseQuietlyAsync(session.SessionId).ConfigureAwait(false); + } + + _gate.Dispose(); + } +} diff --git a/src/MxGateway.Server/Dashboard/DashboardMetricSummary.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardMetricSummary.cs similarity index 69% rename from src/MxGateway.Server/Dashboard/DashboardMetricSummary.cs rename to src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardMetricSummary.cs index 8b256a1..769d728 100644 --- a/src/MxGateway.Server/Dashboard/DashboardMetricSummary.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardMetricSummary.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Dashboard; +namespace ZB.MOM.WW.MxGateway.Server.Dashboard; public sealed record DashboardMetricSummary( string Name, diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardMxValueFormatter.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardMxValueFormatter.cs new file mode 100644 index 0000000..c6384fc --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardMxValueFormatter.cs @@ -0,0 +1,110 @@ +using System.Globalization; +using ZB.MOM.WW.MxGateway.Contracts.Proto; + +namespace ZB.MOM.WW.MxGateway.Server.Dashboard; + +/// +/// Formats an into the short, human-readable text the +/// dashboard's Browse subscription panel shows. Kept separate from the +/// view layer so the formatting rules are unit-testable without a worker. +/// +public static class DashboardMxValueFormatter +{ + /// Maximum array elements rendered inline before the value is truncated. + private const int MaxArrayElements = 24; + + /// Formats the value payload of an . + /// The value to format; may be null. + /// A display string — never null. + public static string FormatValue(MxValue? value) + { + if (value is null) + { + return "-"; + } + + if (value.IsNull) + { + return "(null)"; + } + + return value.KindCase switch + { + MxValue.KindOneofCase.BoolValue => value.BoolValue ? "true" : "false", + MxValue.KindOneofCase.Int32Value => value.Int32Value.ToString(CultureInfo.InvariantCulture), + MxValue.KindOneofCase.Int64Value => value.Int64Value.ToString(CultureInfo.InvariantCulture), + MxValue.KindOneofCase.FloatValue => value.FloatValue.ToString("G7", CultureInfo.InvariantCulture), + MxValue.KindOneofCase.DoubleValue => value.DoubleValue.ToString("G15", CultureInfo.InvariantCulture), + MxValue.KindOneofCase.StringValue => value.StringValue, + MxValue.KindOneofCase.TimestampValue => value.TimestampValue + .ToDateTimeOffset() + .UtcDateTime + .ToString("yyyy-MM-dd HH:mm:ss.fff 'UTC'", CultureInfo.InvariantCulture), + MxValue.KindOneofCase.ArrayValue => FormatArray(value.ArrayValue), + MxValue.KindOneofCase.RawValue => $"({value.RawValue.Length} bytes)", + _ => "-", + }; + } + + /// Formats the MXAccess data type of an . + /// The value whose data type to describe; may be null. + /// The data-type name — never null. Arrays render as Element[dims]. + public static string FormatDataType(MxValue? value) + { + if (value is null) + { + return "-"; + } + + // A scalar carries its type in MxValue.DataType, but an array leaves + // that Unspecified and carries the element type on the MxArray itself. + return value.KindCase == MxValue.KindOneofCase.ArrayValue + ? FormatArrayDataType(value.ArrayValue) + : value.DataType.ToString(); + } + + private static string FormatArrayDataType(MxArray array) + { + string dimensions = array.Dimensions.Count > 0 + ? string.Join(",", array.Dimensions) + : string.Empty; + return $"{array.ElementDataType}[{dimensions}]"; + } + + private static string FormatArray(MxArray array) + { + IReadOnlyList elements = array.ValuesCase switch + { + MxArray.ValuesOneofCase.BoolValues => + array.BoolValues.Values.Select(item => item ? "true" : "false").ToArray(), + MxArray.ValuesOneofCase.Int32Values => + array.Int32Values.Values.Select(item => item.ToString(CultureInfo.InvariantCulture)).ToArray(), + MxArray.ValuesOneofCase.Int64Values => + array.Int64Values.Values.Select(item => item.ToString(CultureInfo.InvariantCulture)).ToArray(), + MxArray.ValuesOneofCase.FloatValues => + array.FloatValues.Values.Select(item => item.ToString("G7", CultureInfo.InvariantCulture)).ToArray(), + MxArray.ValuesOneofCase.DoubleValues => + array.DoubleValues.Values.Select(item => item.ToString("G15", CultureInfo.InvariantCulture)).ToArray(), + MxArray.ValuesOneofCase.StringValues => + array.StringValues.Values.Select(item => $"\"{item}\"").ToArray(), + MxArray.ValuesOneofCase.TimestampValues => + array.TimestampValues.Values + .Select(item => item.ToDateTimeOffset().UtcDateTime + .ToString("yyyy-MM-dd HH:mm:ss 'UTC'", CultureInfo.InvariantCulture)) + .ToArray(), + MxArray.ValuesOneofCase.RawValues => + array.RawValues.Values.Select(item => $"({item.Length} bytes)").ToArray(), + _ => [], + }; + + if (elements.Count == 0) + { + return "[]"; + } + + string body = string.Join(", ", elements.Take(MaxArrayElements)); + return elements.Count > MaxArrayElements + ? $"[{body}, … {elements.Count} total]" + : $"[{body}]"; + } +} diff --git a/src/MxGateway.Server/Dashboard/DashboardRedactor.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardRedactor.cs similarity index 91% rename from src/MxGateway.Server/Dashboard/DashboardRedactor.cs rename to src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardRedactor.cs index dffc4ca..86455ba 100644 --- a/src/MxGateway.Server/Dashboard/DashboardRedactor.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardRedactor.cs @@ -1,6 +1,6 @@ -using MxGateway.Server.Diagnostics; +using ZB.MOM.WW.MxGateway.Server.Diagnostics; -namespace MxGateway.Server.Dashboard; +namespace ZB.MOM.WW.MxGateway.Server.Dashboard; internal static class DashboardRedactor { diff --git a/src/MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs similarity index 93% rename from src/MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs rename to src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs index 1eea466..8362526 100644 --- a/src/MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs @@ -1,9 +1,9 @@ using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.Options; -using MxGateway.Server.Configuration; +using ZB.MOM.WW.MxGateway.Server.Configuration; -namespace MxGateway.Server.Dashboard; +namespace ZB.MOM.WW.MxGateway.Server.Dashboard; /// /// Extension methods for configuring the gateway dashboard services. @@ -17,6 +17,7 @@ public static class DashboardServiceCollectionExtensions public static IServiceCollection AddGatewayDashboard(this IServiceCollection services) { services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/MxGateway.Server/Dashboard/DashboardSessionSummary.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSessionSummary.cs similarity index 77% rename from src/MxGateway.Server/Dashboard/DashboardSessionSummary.cs rename to src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSessionSummary.cs index 356d9cb..c208a52 100644 --- a/src/MxGateway.Server/Dashboard/DashboardSessionSummary.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSessionSummary.cs @@ -1,7 +1,7 @@ -using MxGateway.Contracts.Proto; -using MxGateway.Server.Workers; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Server.Workers; -namespace MxGateway.Server.Dashboard; +namespace ZB.MOM.WW.MxGateway.Server.Dashboard; public sealed record DashboardSessionSummary( string SessionId, diff --git a/src/MxGateway.Server/Dashboard/DashboardSnapshot.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSnapshot.cs similarity index 84% rename from src/MxGateway.Server/Dashboard/DashboardSnapshot.cs rename to src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSnapshot.cs index 3a54ab7..4ed16d0 100644 --- a/src/MxGateway.Server/Dashboard/DashboardSnapshot.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSnapshot.cs @@ -1,6 +1,6 @@ -using MxGateway.Server.Configuration; +using ZB.MOM.WW.MxGateway.Server.Configuration; -namespace MxGateway.Server.Dashboard; +namespace ZB.MOM.WW.MxGateway.Server.Dashboard; public sealed record DashboardSnapshot( DateTimeOffset GeneratedAt, diff --git a/src/MxGateway.Server/Dashboard/DashboardSnapshotService.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSnapshotService.cs similarity index 96% rename from src/MxGateway.Server/Dashboard/DashboardSnapshotService.cs rename to src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSnapshotService.cs index e65d597..783c343 100644 --- a/src/MxGateway.Server/Dashboard/DashboardSnapshotService.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSnapshotService.cs @@ -2,14 +2,14 @@ using System.Runtime.CompilerServices; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; -using MxGateway.Server.Configuration; -using MxGateway.Server.Galaxy; -using MxGateway.Server.Metrics; -using MxGateway.Server.Security.Authentication; -using MxGateway.Server.Sessions; -using MxGateway.Server.Workers; +using ZB.MOM.WW.MxGateway.Server.Configuration; +using ZB.MOM.WW.MxGateway.Server.Galaxy; +using ZB.MOM.WW.MxGateway.Server.Metrics; +using ZB.MOM.WW.MxGateway.Server.Security.Authentication; +using ZB.MOM.WW.MxGateway.Server.Sessions; +using ZB.MOM.WW.MxGateway.Server.Workers; -namespace MxGateway.Server.Dashboard; +namespace ZB.MOM.WW.MxGateway.Server.Dashboard; public sealed class DashboardSnapshotService : IDashboardSnapshotService { @@ -270,7 +270,7 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService private static bool HasFault(GatewaySession session) { - return session.State == MxGateway.Contracts.Proto.SessionState.Faulted + return session.State == ZB.MOM.WW.MxGateway.Contracts.Proto.SessionState.Faulted || session.WorkerClient?.State == WorkerClientState.Faulted || !string.IsNullOrWhiteSpace(session.FinalFault); } diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardTagValue.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardTagValue.cs new file mode 100644 index 0000000..343fe05 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardTagValue.cs @@ -0,0 +1,69 @@ +using ZB.MOM.WW.MxGateway.Contracts.Proto; + +namespace ZB.MOM.WW.MxGateway.Server.Dashboard; + +/// +/// One live tag value as shown in the Browse subscription panel. Projected +/// from a worker so the Razor component never +/// touches protobuf types directly. +/// +public sealed record DashboardTagValue( + string TagAddress, + bool Ok, + string ValueText, + string DataType, + int Quality, + bool QualityGood, + DateTimeOffset? SourceTimestamp, + string? Error) +{ + /// + /// Classic OPC-DA "Good" quality. MXAccess surfaces 192 for a healthy + /// advised value; anything lower is uncertain or bad. + /// + private const int GoodQualityThreshold = 192; + + /// Projects a worker bulk-read result into a dashboard tag value. + /// The per-tag result from a ReadBulk reply. + /// The projected dashboard value. + public static DashboardTagValue FromBulkReadResult(BulkReadResult result) + { + ArgumentNullException.ThrowIfNull(result); + + string? error = null; + if (!result.WasSuccessful) + { + error = !string.IsNullOrWhiteSpace(result.ErrorMessage) + ? result.ErrorMessage + : FirstStatusDiagnostic(result); + } + + return new DashboardTagValue( + TagAddress: result.TagAddress, + Ok: result.WasSuccessful, + ValueText: DashboardMxValueFormatter.FormatValue(result.Value), + DataType: DashboardMxValueFormatter.FormatDataType(result.Value), + Quality: result.Quality, + QualityGood: result.WasSuccessful && result.Quality >= GoodQualityThreshold, + SourceTimestamp: result.SourceTimestamp?.ToDateTimeOffset(), + Error: error); + } + + private static string? FirstStatusDiagnostic(BulkReadResult result) + { + foreach (MxStatusProxy status in result.Statuses) + { + if (!string.IsNullOrWhiteSpace(status.DiagnosticText)) + { + return status.DiagnosticText; + } + + if (status.Category != MxStatusCategory.Ok) + { + return status.Category.ToString(); + } + } + + return null; + } +} diff --git a/src/MxGateway.Server/Dashboard/DashboardWorkerSummary.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardWorkerSummary.cs similarity index 66% rename from src/MxGateway.Server/Dashboard/DashboardWorkerSummary.cs rename to src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardWorkerSummary.cs index 563e010..e08b58b 100644 --- a/src/MxGateway.Server/Dashboard/DashboardWorkerSummary.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardWorkerSummary.cs @@ -1,6 +1,6 @@ -using MxGateway.Server.Workers; +using ZB.MOM.WW.MxGateway.Server.Workers; -namespace MxGateway.Server.Dashboard; +namespace ZB.MOM.WW.MxGateway.Server.Dashboard; public sealed record DashboardWorkerSummary( string SessionId, diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/IDashboardApiKeyManagementService.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/IDashboardApiKeyManagementService.cs new file mode 100644 index 0000000..5c83c13 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/IDashboardApiKeyManagementService.cs @@ -0,0 +1,23 @@ +using System.Security.Claims; + +namespace ZB.MOM.WW.MxGateway.Server.Dashboard; + +public interface IDashboardApiKeyManagementService +{ + bool CanManage(ClaimsPrincipal user); + + Task CreateAsync( + ClaimsPrincipal user, + DashboardApiKeyManagementRequest request, + CancellationToken cancellationToken); + + Task RevokeAsync( + ClaimsPrincipal user, + string keyId, + CancellationToken cancellationToken); + + Task RotateAsync( + ClaimsPrincipal user, + string keyId, + CancellationToken cancellationToken); +} diff --git a/src/MxGateway.Server/Dashboard/IDashboardAuthenticator.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/IDashboardAuthenticator.cs similarity index 91% rename from src/MxGateway.Server/Dashboard/IDashboardAuthenticator.cs rename to src/ZB.MOM.WW.MxGateway.Server/Dashboard/IDashboardAuthenticator.cs index a7b0372..41ff2b2 100644 --- a/src/MxGateway.Server/Dashboard/IDashboardAuthenticator.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/IDashboardAuthenticator.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Dashboard; +namespace ZB.MOM.WW.MxGateway.Server.Dashboard; /// /// Authenticates dashboard access with API keys. diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/IDashboardLiveDataService.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/IDashboardLiveDataService.cs new file mode 100644 index 0000000..698cc5b --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/IDashboardLiveDataService.cs @@ -0,0 +1,47 @@ +namespace ZB.MOM.WW.MxGateway.Server.Dashboard; + +/// +/// Supplies the Browse and Alarms dashboard tabs with live MXAccess data. +/// Owns one shared, lazily-opened gateway session (and therefore one +/// worker process) used by every dashboard circuit; the session is +/// transparently re-opened if it faults or its lease expires. +/// +public interface IDashboardLiveDataService +{ + /// + /// Subscribes (once) and reads the current value, quality and timestamp + /// of the supplied tags. Never throws — transport and session failures + /// are surfaced in . + /// + /// Fully-qualified tag references to read. + /// Token to cancel the read. + /// The read result, or an error-bearing result on failure. + Task ReadAsync( + IReadOnlyCollection tagAddresses, + CancellationToken cancellationToken); + + /// + /// Queries the currently-active alarm set for the dashboard session. + /// Never throws — failures are surfaced in + /// . + /// + /// Token to cancel the query. + /// The active alarms, or an error-bearing result on failure. + Task QueryAlarmsAsync(CancellationToken cancellationToken); +} + +/// Result of a dashboard live tag read. +/// The per-tag values, or an empty list on error. +/// A diagnostic message when the read failed; otherwise null. +/// The dashboard session id used, when available. +/// The worker process id backing the session, when available. +public sealed record DashboardLiveReadResult( + IReadOnlyList Values, + string? Error, + string? SessionId, + int? WorkerProcessId) +{ + /// An empty, successful result — used when no tags are subscribed. + public static DashboardLiveReadResult Empty { get; } = + new([], null, null, null); +} diff --git a/src/MxGateway.Server/Dashboard/IDashboardSnapshotService.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/IDashboardSnapshotService.cs similarity index 92% rename from src/MxGateway.Server/Dashboard/IDashboardSnapshotService.cs rename to src/ZB.MOM.WW.MxGateway.Server/Dashboard/IDashboardSnapshotService.cs index 37f2298..ca33819 100644 --- a/src/MxGateway.Server/Dashboard/IDashboardSnapshotService.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/IDashboardSnapshotService.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Dashboard; +namespace ZB.MOM.WW.MxGateway.Server.Dashboard; /// /// Provides snapshots of the dashboard state for UI updates. diff --git a/src/MxGateway.Server/Diagnostics/GatewayLogRedactor.cs b/src/ZB.MOM.WW.MxGateway.Server/Diagnostics/GatewayLogRedactor.cs similarity index 98% rename from src/MxGateway.Server/Diagnostics/GatewayLogRedactor.cs rename to src/ZB.MOM.WW.MxGateway.Server/Diagnostics/GatewayLogRedactor.cs index 1f98bc3..d04d2aa 100644 --- a/src/MxGateway.Server/Diagnostics/GatewayLogRedactor.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Diagnostics/GatewayLogRedactor.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Diagnostics; +namespace ZB.MOM.WW.MxGateway.Server.Diagnostics; /// /// Redacts sensitive information from log entries. diff --git a/src/MxGateway.Server/Diagnostics/GatewayLogScope.cs b/src/ZB.MOM.WW.MxGateway.Server/Diagnostics/GatewayLogScope.cs similarity index 95% rename from src/MxGateway.Server/Diagnostics/GatewayLogScope.cs rename to src/ZB.MOM.WW.MxGateway.Server/Diagnostics/GatewayLogScope.cs index 4157f02..c80a2d4 100644 --- a/src/MxGateway.Server/Diagnostics/GatewayLogScope.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Diagnostics/GatewayLogScope.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Diagnostics; +namespace ZB.MOM.WW.MxGateway.Server.Diagnostics; public sealed record GatewayLogScope( string? SessionId = null, diff --git a/src/MxGateway.Server/Diagnostics/GatewayLoggerExtensions.cs b/src/ZB.MOM.WW.MxGateway.Server/Diagnostics/GatewayLoggerExtensions.cs similarity index 93% rename from src/MxGateway.Server/Diagnostics/GatewayLoggerExtensions.cs rename to src/ZB.MOM.WW.MxGateway.Server/Diagnostics/GatewayLoggerExtensions.cs index 22229bd..1772e61 100644 --- a/src/MxGateway.Server/Diagnostics/GatewayLoggerExtensions.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Diagnostics/GatewayLoggerExtensions.cs @@ -1,6 +1,6 @@ using Microsoft.Extensions.Logging; -namespace MxGateway.Server.Diagnostics; +namespace ZB.MOM.WW.MxGateway.Server.Diagnostics; public static class GatewayLoggerExtensions { diff --git a/src/MxGateway.Server/Diagnostics/GatewayRequestLoggingMiddlewareExtensions.cs b/src/ZB.MOM.WW.MxGateway.Server/Diagnostics/GatewayRequestLoggingMiddlewareExtensions.cs similarity index 98% rename from src/MxGateway.Server/Diagnostics/GatewayRequestLoggingMiddlewareExtensions.cs rename to src/ZB.MOM.WW.MxGateway.Server/Diagnostics/GatewayRequestLoggingMiddlewareExtensions.cs index 96f1499..84a0af0 100644 --- a/src/MxGateway.Server/Diagnostics/GatewayRequestLoggingMiddlewareExtensions.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Diagnostics/GatewayRequestLoggingMiddlewareExtensions.cs @@ -1,6 +1,6 @@ using Microsoft.Extensions.Primitives; -namespace MxGateway.Server.Diagnostics; +namespace ZB.MOM.WW.MxGateway.Server.Diagnostics; /// Middleware extensions for structured gateway request logging with correlation context. public static class GatewayRequestLoggingMiddlewareExtensions diff --git a/src/MxGateway.Server/Galaxy/GalaxyCacheStatus.cs b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyCacheStatus.cs similarity index 91% rename from src/MxGateway.Server/Galaxy/GalaxyCacheStatus.cs rename to src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyCacheStatus.cs index 53572b6..a90641f 100644 --- a/src/MxGateway.Server/Galaxy/GalaxyCacheStatus.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyCacheStatus.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Galaxy; +namespace ZB.MOM.WW.MxGateway.Server.Galaxy; public enum GalaxyCacheStatus { diff --git a/src/MxGateway.Server/Galaxy/GalaxyDeployEventInfo.cs b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyDeployEventInfo.cs similarity index 92% rename from src/MxGateway.Server/Galaxy/GalaxyDeployEventInfo.cs rename to src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyDeployEventInfo.cs index 0afd24f..889385f 100644 --- a/src/MxGateway.Server/Galaxy/GalaxyDeployEventInfo.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyDeployEventInfo.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Galaxy; +namespace ZB.MOM.WW.MxGateway.Server.Galaxy; /// /// A single Galaxy deploy notification. Published by diff --git a/src/MxGateway.Server/Galaxy/GalaxyDeployNotifier.cs b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyDeployNotifier.cs similarity index 98% rename from src/MxGateway.Server/Galaxy/GalaxyDeployNotifier.cs rename to src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyDeployNotifier.cs index 55d4d57..f7ae0a0 100644 --- a/src/MxGateway.Server/Galaxy/GalaxyDeployNotifier.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyDeployNotifier.cs @@ -2,7 +2,7 @@ using System.Collections.Concurrent; using System.Runtime.CompilerServices; using System.Threading.Channels; -namespace MxGateway.Server.Galaxy; +namespace ZB.MOM.WW.MxGateway.Server.Galaxy; /// /// Channel-based fan-out of Galaxy deploy events to streaming gRPC subscribers. Each diff --git a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyGlobMatcher.cs b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyGlobMatcher.cs new file mode 100644 index 0000000..86e5f45 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyGlobMatcher.cs @@ -0,0 +1,123 @@ +using System.Collections.Concurrent; +using System.Text; +using System.Text.RegularExpressions; + +namespace ZB.MOM.WW.MxGateway.Server.Galaxy; + +public static class GalaxyGlobMatcher +{ + /// + /// Maximum number of compiled-regex entries retained in . + /// The cache is keyed by glob pattern and patterns flow in from two sources: + /// admin-controlled API-key constraints (naturally bounded) and the + /// client-supplied DiscoverHierarchyRequest.TagNameGlob (unbounded — a + /// client can iterate through generated names and create millions of distinct + /// globs over the process lifetime). Capping the cache bounds memory while + /// keeping the hot working set hit-cached. + /// + internal const int RegexCacheCapacity = 256; + + /// + /// Bounded compiled-regex cache keyed by glob pattern. IsMatch is called + /// once per object per DiscoverHierarchy/WatchDeployEvents + /// evaluation, so the same handful of glob patterns are translated + /// repeatedly; caching avoids rebuilding and recompiling the regex on every + /// call. Beyond entries the oldest insertion + /// is evicted so a client cannot grow the cache without bound by submitting + /// unique patterns. Eviction is approximate (FIFO over insertion order, not + /// true LRU) because we only need the bound, not exact recency tracking. + /// + private static readonly ConcurrentDictionary RegexCache = new(StringComparer.Ordinal); + + /// + /// Insertion-order queue used to evict the oldest cache entry when the cache + /// exceeds . A separate queue keeps the + /// reads lock-free; the lock below only guards the + /// eviction path. + /// + private static readonly ConcurrentQueue InsertionOrder = new(); + private static readonly object EvictionLock = new(); + + /// + /// Current cache size, exposed for tests asserting the cap is honoured. + /// + internal static int CurrentCacheSize => RegexCache.Count; + + public static bool IsMatch(string value, string glob) + { + if (string.IsNullOrWhiteSpace(glob)) + { + return true; + } + + return GetOrCreateRegex(glob).IsMatch(value ?? string.Empty); + } + + private static Regex GetOrCreateRegex(string glob) + { + if (RegexCache.TryGetValue(glob, out Regex? existing)) + { + return existing; + } + + Regex compiled = new( + BuildRegex(glob), + RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.Compiled, + TimeSpan.FromMilliseconds(100)); + + // GetOrAdd atomically returns whichever instance is in the cache after the + // call — either the locally-compiled regex (we won the race) or the regex + // another thread inserted (we lost). It also avoids the TryAdd-then-indexer + // pattern where the key could be evicted between the failed TryAdd and the + // indexer read, producing a KeyNotFoundException under contention near the + // cap (Server-024). + Regex result = RegexCache.GetOrAdd(glob, compiled); + if (ReferenceEquals(result, compiled)) + { + // We were the inserter — track for FIFO eviction and bound the cache. + InsertionOrder.Enqueue(glob); + EvictIfOverCapacity(); + } + return result; + } + + private static void EvictIfOverCapacity() + { + if (RegexCache.Count <= RegexCacheCapacity) + { + return; + } + + // Serialize eviction so two threads do not race past the cap together. + lock (EvictionLock) + { + while (RegexCache.Count > RegexCacheCapacity && InsertionOrder.TryDequeue(out string? oldest)) + { + RegexCache.TryRemove(oldest, out _); + } + } + } + + private static string BuildRegex(string glob) + { + StringBuilder builder = new("^", glob.Length + 2); + foreach (char character in glob) + { + switch (character) + { + case '*': + builder.Append(".*"); + break; + case '?': + builder.Append('.'); + break; + default: + builder.Append(Regex.Escape(character.ToString())); + break; + } + } + + builder.Append('$'); + return builder.ToString(); + } +} diff --git a/src/MxGateway.Server/Galaxy/GalaxyHierarchyCache.cs b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyCache.cs similarity index 59% rename from src/MxGateway.Server/Galaxy/GalaxyHierarchyCache.cs rename to src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyCache.cs index afc1a06..8bafe52 100644 --- a/src/MxGateway.Server/Galaxy/GalaxyHierarchyCache.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyCache.cs @@ -1,11 +1,10 @@ using Google.Protobuf.WellKnownTypes; -using Microsoft.Data.SqlClient; using Microsoft.Extensions.Logging; -using MxGateway.Contracts.Proto.Galaxy; -using MxGateway.Server.Dashboard; -using MxGateway.Server.Grpc; +using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; +using ZB.MOM.WW.MxGateway.Server.Dashboard; +using ZB.MOM.WW.MxGateway.Server.Grpc; -namespace MxGateway.Server.Galaxy; +namespace ZB.MOM.WW.MxGateway.Server.Galaxy; /// /// Server-side cache of Galaxy Repository browse data. All gRPC clients share the same @@ -13,34 +12,46 @@ namespace MxGateway.Server.Galaxy; /// refresh and reused across requests. Refreshes are deploy-time gated: every tick /// queries galaxy.time_of_last_deploy (cheap), and the heavy hierarchy + /// attributes rowsets are pulled only when that timestamp has advanced. +/// Each successful heavy refresh is persisted to disk through +/// ; the first refresh restores that +/// snapshot (as ) so clients can browse +/// last-known data when the Galaxy database is unreachable on a cold start. /// public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache { private static readonly TimeSpan StaleThreshold = TimeSpan.FromMinutes(5); - private readonly GalaxyRepository _repository; + private readonly IGalaxyRepository _repository; private readonly IGalaxyDeployNotifier _notifier; + private readonly IGalaxyHierarchySnapshotStore? _snapshotStore; private readonly TimeProvider _timeProvider; private readonly ILogger? _logger; private readonly TaskCompletionSource _firstLoad = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly SemaphoreSlim _refreshGate = new(1, 1); private GalaxyHierarchyCacheEntry _current = GalaxyHierarchyCacheEntry.Empty; + private bool _restoreAttempted; /// Initializes a new instance of the class. /// Galaxy Repository client for SQL queries. /// Galaxy deploy event notifier. /// Provider for current time; defaults to system time. /// Optional logger for diagnostic output. + /// + /// Optional on-disk snapshot store. When supplied, the cache persists each + /// successful refresh and restores the last snapshot on first load. + /// public GalaxyHierarchyCache( - GalaxyRepository repository, + IGalaxyRepository repository, IGalaxyDeployNotifier notifier, TimeProvider? timeProvider = null, - ILogger? logger = null) + ILogger? logger = null, + IGalaxyHierarchySnapshotStore? snapshotStore = null) { _repository = repository; _notifier = notifier; _timeProvider = timeProvider ?? TimeProvider.System; _logger = logger; + _snapshotStore = snapshotStore; } /// Gets the current Galaxy hierarchy cache entry with projected status. @@ -89,6 +100,15 @@ public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache private async Task RefreshCoreAsync(CancellationToken cancellationToken) { + // First refresh only: seed the cache from the on-disk snapshot before + // querying SQL, so a cold start with an unreachable Galaxy database can + // still serve last-known browse data. Runs under the refresh gate. + if (!_restoreAttempted) + { + _restoreAttempted = true; + await TryRestoreFromDiskAsync(cancellationToken).ConfigureAwait(false); + } + GalaxyHierarchyCacheEntry previous = Volatile.Read(ref _current); DateTimeOffset queriedAt = _timeProvider.GetUtcNow(); @@ -131,41 +151,17 @@ public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache List hierarchy = hierarchyTask.Result; List attributes = attributesTask.Result; - IReadOnlyList objects = BuildObjects(hierarchy, attributes); - GalaxyHierarchyIndex index = GalaxyHierarchyIndex.Build(objects); - int areaCount = hierarchy.Count(row => row.IsArea); - int historized = attributes.Count(row => row.IsHistorized); - int alarms = attributes.Count(row => row.IsAlarm); - DashboardGalaxySummary dashboardSummary = BuildDashboardSummary( + long nextSequence = previous.Sequence + 1; + GalaxyHierarchyCacheEntry next = BuildEntry( status: GalaxyCacheStatus.Healthy, + sequence: nextSequence, lastQueriedAt: queriedAt, lastSuccessAt: queriedAt, lastDeployTime: deployTime, lastError: null, hierarchy: hierarchy, - objectCount: hierarchy.Count, - areaCount: areaCount, - attributeCount: attributes.Count, - historizedAttributeCount: historized, - alarmAttributeCount: alarms); - - long nextSequence = previous.Sequence + 1; - GalaxyHierarchyCacheEntry next = new( - Status: GalaxyCacheStatus.Healthy, - Sequence: nextSequence, - LastQueriedAt: queriedAt, - LastSuccessAt: queriedAt, - LastDeployTime: deployTime, - LastError: null, - Objects: objects, - Index: index, - DashboardSummary: dashboardSummary, - ObjectCount: hierarchy.Count, - AreaCount: areaCount, - AttributeCount: attributes.Count, - HistorizedAttributeCount: historized, - AlarmAttributeCount: alarms); + attributes: attributes); Volatile.Write(ref _current, next); _firstLoad.TrySetResult(); @@ -176,13 +172,20 @@ public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache TimeOfLastDeploy: deployTime, ObjectCount: hierarchy.Count, AttributeCount: attributes.Count)); + + await PersistSnapshotAsync(deployTime, queriedAt, hierarchy, attributes, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } - catch (Exception exception) when (exception is SqlException or InvalidOperationException) + catch (Exception exception) { + // Catch every non-cancellation failure — not just SqlException / + // InvalidOperationException. A TimeoutException or Win32Exception + // from connection establishment, or another DbException subtype, + // must still degrade gracefully to Stale/Unavailable and complete + // _firstLoad rather than escape and fault the refresh BackgroundService. _logger?.LogWarning(exception, "Galaxy hierarchy cache refresh failed."); GalaxyHierarchyCacheEntry failed = previous with { @@ -201,6 +204,161 @@ public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache } } + /// + /// Materializes a complete from raw + /// hierarchy and attribute rowsets. Shared by the live refresh path and the + /// on-disk restore path so both produce an identical object list, index, and + /// dashboard summary. + /// + private static GalaxyHierarchyCacheEntry BuildEntry( + GalaxyCacheStatus status, + long sequence, + DateTimeOffset? lastQueriedAt, + DateTimeOffset? lastSuccessAt, + DateTimeOffset? lastDeployTime, + string? lastError, + IReadOnlyList hierarchy, + IReadOnlyList attributes) + { + IReadOnlyList objects = BuildObjects(hierarchy, attributes); + GalaxyHierarchyIndex index = GalaxyHierarchyIndex.Build(objects); + + int areaCount = hierarchy.Count(row => row.IsArea); + int historized = attributes.Count(row => row.IsHistorized); + int alarms = attributes.Count(row => row.IsAlarm); + DashboardGalaxySummary dashboardSummary = BuildDashboardSummary( + status: status, + lastQueriedAt: lastQueriedAt, + lastSuccessAt: lastSuccessAt, + lastDeployTime: lastDeployTime, + lastError: lastError, + hierarchy: hierarchy, + objectCount: hierarchy.Count, + areaCount: areaCount, + attributeCount: attributes.Count, + historizedAttributeCount: historized, + alarmAttributeCount: alarms); + + return new GalaxyHierarchyCacheEntry( + Status: status, + Sequence: sequence, + LastQueriedAt: lastQueriedAt, + LastSuccessAt: lastSuccessAt, + LastDeployTime: lastDeployTime, + LastError: lastError, + Objects: objects, + Index: index, + DashboardSummary: dashboardSummary, + ObjectCount: hierarchy.Count, + AreaCount: areaCount, + AttributeCount: attributes.Count, + HistorizedAttributeCount: historized, + AlarmAttributeCount: alarms); + } + + /// + /// Seeds the cache from the on-disk snapshot when no live data has loaded yet. + /// The restored entry is marked — it is + /// last-known data, not live. A later refresh that observes the same deploy + /// time promotes it to healthy; one that observes a newer deploy replaces it. + /// + private async Task TryRestoreFromDiskAsync(CancellationToken cancellationToken) + { + if (_snapshotStore is null) + { + return; + } + + if (Volatile.Read(ref _current).HasData) + { + return; + } + + GalaxyHierarchySnapshot? snapshot; + try + { + snapshot = await _snapshotStore.TryLoadAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + _logger?.LogWarning(exception, "Failed to restore the Galaxy hierarchy from the on-disk snapshot."); + return; + } + + if (snapshot is null) + { + return; + } + + long sequence = Volatile.Read(ref _current).Sequence + 1; + GalaxyHierarchyCacheEntry restored = BuildEntry( + status: GalaxyCacheStatus.Stale, + sequence: sequence, + lastQueriedAt: snapshot.SavedAt, + lastSuccessAt: snapshot.SavedAt, + lastDeployTime: snapshot.LastDeployTime, + lastError: null, + hierarchy: snapshot.Hierarchy, + attributes: snapshot.Attributes); + Volatile.Write(ref _current, restored); + + // Restored data is a valid completed first load: unblock callers waiting on + // the bootstrap gate immediately, rather than making them wait out the full + // wait budget for a live query that — when the database is unreachable, the + // scenario this restore exists for — may not return for seconds. + _firstLoad.TrySetResult(); + + _notifier.Publish(new GalaxyDeployEventInfo( + Sequence: sequence, + ObservedAt: _timeProvider.GetUtcNow(), + TimeOfLastDeploy: snapshot.LastDeployTime, + ObjectCount: snapshot.Hierarchy.Count, + AttributeCount: snapshot.Attributes.Count)); + + _logger?.LogInformation( + "Restored Galaxy hierarchy from on-disk snapshot saved {SavedAt:o}: {ObjectCount} objects, {AttributeCount} attributes (status Stale until the Galaxy database confirms).", + snapshot.SavedAt, + snapshot.Hierarchy.Count, + snapshot.Attributes.Count); + } + + /// + /// Persists a successful refresh to disk. Persistence failures are logged and + /// swallowed — a cache that cannot write its backup is still fully usable. + /// + private async Task PersistSnapshotAsync( + DateTimeOffset? deployTime, + DateTimeOffset savedAt, + IReadOnlyList hierarchy, + IReadOnlyList attributes, + CancellationToken cancellationToken) + { + if (_snapshotStore is null) + { + return; + } + + try + { + await _snapshotStore.SaveAsync( + new GalaxyHierarchySnapshot(deployTime, savedAt, hierarchy, attributes), + cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // The refresh was cancelled (gateway shutdown) before the write finished. + // That is not a persistence failure — do not log it as a warning. + } + catch (Exception exception) + { + _logger?.LogWarning(exception, "Failed to persist the Galaxy hierarchy snapshot to disk."); + } + } + private static IReadOnlyList BuildObjects( IReadOnlyList hierarchy, IReadOnlyList attributes) diff --git a/src/MxGateway.Server/Galaxy/GalaxyHierarchyCacheEntry.cs b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyCacheEntry.cs similarity index 91% rename from src/MxGateway.Server/Galaxy/GalaxyHierarchyCacheEntry.cs rename to src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyCacheEntry.cs index 4d46c08..2db9129 100644 --- a/src/MxGateway.Server/Galaxy/GalaxyHierarchyCacheEntry.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyCacheEntry.cs @@ -1,7 +1,7 @@ -using MxGateway.Contracts.Proto.Galaxy; -using MxGateway.Server.Dashboard; +using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; +using ZB.MOM.WW.MxGateway.Server.Dashboard; -namespace MxGateway.Server.Galaxy; +namespace ZB.MOM.WW.MxGateway.Server.Galaxy; /// /// Immutable snapshot of the Galaxy Repository browse data held by diff --git a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyIndex.cs b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyIndex.cs new file mode 100644 index 0000000..b210a6a --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyIndex.cs @@ -0,0 +1,106 @@ +using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; + +namespace ZB.MOM.WW.MxGateway.Server.Galaxy; + +public sealed class GalaxyHierarchyIndex +{ + private GalaxyHierarchyIndex( + IReadOnlyList objectViews, + IReadOnlyDictionary objectViewsById, + IReadOnlyDictionary tagsByAddress) + { + ObjectViews = objectViews; + ObjectViewsById = objectViewsById; + TagsByAddress = tagsByAddress; + } + + public static GalaxyHierarchyIndex Empty { get; } = new( + Array.Empty(), + new Dictionary(), + new Dictionary(StringComparer.OrdinalIgnoreCase)); + + public IReadOnlyList ObjectViews { get; } + + public IReadOnlyDictionary ObjectViewsById { get; } + + public IReadOnlyDictionary TagsByAddress { get; } + + public static GalaxyHierarchyIndex Build(IReadOnlyList objects) + { + if (objects.Count == 0) + { + return Empty; + } + + Dictionary objectsById = new(); + foreach (GalaxyObject obj in objects) + { + objectsById.TryAdd(obj.GobjectId, obj); + } + + List views = new(objects.Count); + Dictionary viewsById = new(); + Dictionary tagsByAddress = new(StringComparer.OrdinalIgnoreCase); + + foreach (GalaxyObject obj in objects) + { + string path = BuildContainedPath(obj, objectsById); + int depth = string.IsNullOrWhiteSpace(path) ? 0 : path.Count(character => character == '/'); + GalaxyObjectView view = new(obj, path, depth); + views.Add(view); + viewsById.TryAdd(obj.GobjectId, view); + + if (!string.IsNullOrWhiteSpace(obj.TagName)) + { + tagsByAddress.TryAdd(obj.TagName, new GalaxyTagLookup(obj, Attribute: null, path)); + } + + foreach (GalaxyAttribute attribute in obj.Attributes) + { + if (!string.IsNullOrWhiteSpace(attribute.FullTagReference)) + { + tagsByAddress.TryAdd(attribute.FullTagReference, new GalaxyTagLookup(obj, attribute, path)); + } + } + } + + return new GalaxyHierarchyIndex( + views, + viewsById, + tagsByAddress); + } + + private static string BuildContainedPath( + GalaxyObject obj, + IReadOnlyDictionary objectsById) + { + Stack names = new(); + HashSet seen = []; + GalaxyObject? current = obj; + while (current is not null && seen.Add(current.GobjectId)) + { + names.Push(ResolvePathSegment(current)); + current = current.ParentGobjectId != 0 + && objectsById.TryGetValue(current.ParentGobjectId, out GalaxyObject? parent) + ? parent + : null; + } + + return string.Join('/', names.Where(name => !string.IsNullOrWhiteSpace(name))); + } + + private static string ResolvePathSegment(GalaxyObject obj) + { + if (!string.IsNullOrWhiteSpace(obj.ContainedName)) + { + return obj.ContainedName; + } + + if (!string.IsNullOrWhiteSpace(obj.BrowseName)) + { + return obj.BrowseName; + } + + return obj.TagName; + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyProjector.cs b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyProjector.cs new file mode 100644 index 0000000..3aff398 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyProjector.cs @@ -0,0 +1,289 @@ +using System.Collections.Concurrent; +using System.Runtime.CompilerServices; +using System.Security.Cryptography; +using System.Text; +using Grpc.Core; +using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; + +namespace ZB.MOM.WW.MxGateway.Server.Galaxy; + +public static class GalaxyHierarchyProjector +{ + /// + /// Per-cache-entry memo of filtered, ordered lists + /// keyed by filter signature. Without it, paging through a large hierarchy + /// re-applies every filter and re-scans the full + /// collection on every page — O(total) per page, O(total²/pageSize) end-to-end. + /// With it, the first page builds the filtered list and each subsequent page is an + /// O(pageSize) slice. The table is keyed on the immutable cache-entry instance, so + /// when the cache publishes a new entry the stale memo becomes unreachable and is + /// reclaimed with it — no explicit invalidation needed. + /// + private static readonly ConditionalWeakTable>> FilteredViewCache = new(); + + public static GalaxyHierarchyQueryResult Project( + GalaxyHierarchyCacheEntry entry, + DiscoverHierarchyRequest request, + IReadOnlyList? browseSubtreeGlobs = null) + { + return Project( + entry, + request, + browseSubtreeGlobs, + offset: 0, + pageSize: int.MaxValue); + } + + public static GalaxyHierarchyQueryResult Project( + GalaxyHierarchyCacheEntry entry, + DiscoverHierarchyRequest request, + IReadOnlyList? browseSubtreeGlobs, + int offset, + int pageSize) + { + ArgumentNullException.ThrowIfNull(entry); + ArgumentNullException.ThrowIfNull(request); + if (offset < 0) + { + throw new ArgumentOutOfRangeException(nameof(offset), offset, "Offset must be greater than or equal to zero."); + } + + if (pageSize <= 0) + { + throw new ArgumentOutOfRangeException(nameof(pageSize), pageSize, "Page size must be greater than zero."); + } + + int? maxDepth = request.MaxDepth; + if (maxDepth < 0) + { + throw new RpcException(new Status( + StatusCode.InvalidArgument, + "DiscoverHierarchy max_depth must be greater than or equal to zero when provided.")); + } + + string filterSignature = ComputeFilterSignature(request, browseSubtreeGlobs); + IReadOnlyList matchedViews = GetFilteredViews( + entry, + request, + browseSubtreeGlobs, + maxDepth, + filterSignature); + + bool includeAttributes = IncludeAttributes(request); + List page = new(Math.Min(pageSize, Math.Max(0, matchedViews.Count - offset))); + int end = (int)Math.Min((long)offset + pageSize, matchedViews.Count); + for (int index = offset; index < end; index++) + { + page.Add(CloneObject(matchedViews[index].Object, includeAttributes)); + } + + return new GalaxyHierarchyQueryResult( + page, + matchedViews.Count, + filterSignature); + } + + private static IReadOnlyList GetFilteredViews( + GalaxyHierarchyCacheEntry entry, + DiscoverHierarchyRequest request, + IReadOnlyList? browseSubtreeGlobs, + int? maxDepth, + string filterSignature) + { + // ResolveRoot can throw RpcException(NotFound); run it before consulting the + // memo so a bad root surfaces consistently regardless of cache state. + IReadOnlyList views = entry.Index.ObjectViews; + GalaxyObjectView? root = ResolveRoot(request, views); + + ConcurrentDictionary> memo = + FilteredViewCache.GetValue(entry, static _ => new ConcurrentDictionary>(StringComparer.Ordinal)); + + return memo.GetOrAdd( + filterSignature, + static (_, state) => + { + List matched = []; + foreach (GalaxyObjectView view in state.Views) + { + if (MatchesRoot(view, state.Root, state.MaxDepth) + && MatchesBrowseSubtrees(view, state.BrowseSubtreeGlobs) + && MatchesFilters(view.Object, state.Request)) + { + matched.Add(view); + } + } + + return matched; + }, + (Views: views, Root: root, MaxDepth: maxDepth, BrowseSubtreeGlobs: browseSubtreeGlobs, Request: request)); + } + + public static GalaxyObject? FindObjectForTag( + GalaxyHierarchyCacheEntry entry, + string tagAddress) + { + if (string.IsNullOrWhiteSpace(tagAddress)) + { + return null; + } + + return entry.Index.TagsByAddress.TryGetValue(tagAddress, out GalaxyTagLookup? lookup) + ? lookup.Object + : null; + } + + public static GalaxyAttribute? FindAttributeForTag( + GalaxyHierarchyCacheEntry entry, + string tagAddress) + { + if (string.IsNullOrWhiteSpace(tagAddress)) + { + return null; + } + + return entry.Index.TagsByAddress.TryGetValue(tagAddress, out GalaxyTagLookup? lookup) + ? lookup.Attribute + : null; + } + + public static string GetContainedPath( + GalaxyHierarchyCacheEntry entry, + int gobjectId) + { + return entry.Index.ObjectViewsById.TryGetValue(gobjectId, out GalaxyObjectView? view) + ? view.ContainedPath + : string.Empty; + } + + private static GalaxyObjectView? ResolveRoot( + DiscoverHierarchyRequest request, + IReadOnlyList views) + { + GalaxyObjectView? root = request.RootCase switch + { + DiscoverHierarchyRequest.RootOneofCase.None => null, + DiscoverHierarchyRequest.RootOneofCase.RootGobjectId => views.FirstOrDefault( + view => view.Object.GobjectId == request.RootGobjectId), + DiscoverHierarchyRequest.RootOneofCase.RootTagName => views.FirstOrDefault( + view => string.Equals(view.Object.TagName, request.RootTagName, StringComparison.OrdinalIgnoreCase)), + DiscoverHierarchyRequest.RootOneofCase.RootContainedPath => views.FirstOrDefault( + view => string.Equals(view.ContainedPath, request.RootContainedPath, StringComparison.OrdinalIgnoreCase)), + _ => null, + }; + + if (request.RootCase != DiscoverHierarchyRequest.RootOneofCase.None && root is null) + { + throw new RpcException(new Status(StatusCode.NotFound, "DiscoverHierarchy root was not found.")); + } + + return root; + } + + private static bool MatchesRoot( + GalaxyObjectView view, + GalaxyObjectView? root, + int? maxDepth) + { + if (root is null) + { + return true; + } + + bool isRoot = view.Object.GobjectId == root.Object.GobjectId; + bool isDescendant = view.ContainedPath.StartsWith(root.ContainedPath + "/", StringComparison.OrdinalIgnoreCase); + if (!isRoot && !isDescendant) + { + return false; + } + + return maxDepth is null || view.Depth - root.Depth <= maxDepth.Value; + } + + private static bool MatchesBrowseSubtrees( + GalaxyObjectView view, + IReadOnlyList? browseSubtreeGlobs) + { + return browseSubtreeGlobs is null + || browseSubtreeGlobs.Count == 0 + || browseSubtreeGlobs.Any(glob => GalaxyGlobMatcher.IsMatch(view.ContainedPath, glob)); + } + + private static bool MatchesFilters( + GalaxyObject obj, + DiscoverHierarchyRequest request) + { + if (request.CategoryIds.Count > 0 && !request.CategoryIds.Contains(obj.CategoryId)) + { + return false; + } + + foreach (string templateFilter in request.TemplateChainContains) + { + if (!obj.TemplateChain.Any(template => template.Contains(templateFilter, StringComparison.OrdinalIgnoreCase))) + { + return false; + } + } + + if (!string.IsNullOrWhiteSpace(request.TagNameGlob) + && !GalaxyGlobMatcher.IsMatch(obj.TagName, request.TagNameGlob)) + { + return false; + } + + if (request.AlarmBearingOnly && !obj.Attributes.Any(attribute => attribute.IsAlarm)) + { + return false; + } + + if (request.HistorizedOnly && !obj.Attributes.Any(attribute => attribute.IsHistorized)) + { + return false; + } + + return true; + } + + private static bool IncludeAttributes(DiscoverHierarchyRequest request) + { + return !request.HasIncludeAttributes || request.IncludeAttributes; + } + + private static GalaxyObject CloneObject(GalaxyObject source, bool includeAttributes) + { + GalaxyObject clone = source.Clone(); + if (!includeAttributes) + { + clone.Attributes.Clear(); + } + + return clone; + } + + public static string ComputeFilterSignature( + DiscoverHierarchyRequest request, + IReadOnlyList? browseSubtreeGlobs) + { + StringBuilder builder = new(); + builder.Append("root=").Append(request.RootCase).Append('|'); + builder.Append(request.RootCase switch + { + DiscoverHierarchyRequest.RootOneofCase.RootGobjectId => request.RootGobjectId.ToString( + System.Globalization.CultureInfo.InvariantCulture), + DiscoverHierarchyRequest.RootOneofCase.RootTagName => request.RootTagName, + DiscoverHierarchyRequest.RootOneofCase.RootContainedPath => request.RootContainedPath, + _ => string.Empty, + }); + builder.Append("|max=").Append(request.MaxDepth?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? ""); + builder.Append("|cat=").AppendJoin(',', request.CategoryIds.Order()); + builder.Append("|tpl=").AppendJoin(',', request.TemplateChainContains.Order(StringComparer.OrdinalIgnoreCase)); + builder.Append("|glob=").Append(request.TagNameGlob); + builder.Append("|attrs=").Append(request.HasIncludeAttributes ? request.IncludeAttributes.ToString() : "unset"); + builder.Append("|alarm=").Append(request.AlarmBearingOnly); + builder.Append("|hist=").Append(request.HistorizedOnly); + builder.Append("|browse=").AppendJoin(',', (browseSubtreeGlobs ?? Array.Empty()).Order(StringComparer.OrdinalIgnoreCase)); + + byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString())); + return Convert.ToHexString(hash, 0, 12); + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyQueryResult.cs b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyQueryResult.cs new file mode 100644 index 0000000..66ce19c --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyQueryResult.cs @@ -0,0 +1,8 @@ +using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; + +namespace ZB.MOM.WW.MxGateway.Server.Galaxy; + +public sealed record GalaxyHierarchyQueryResult( + IReadOnlyList Objects, + int TotalObjectCount, + string FilterSignature); diff --git a/src/MxGateway.Server/Galaxy/GalaxyHierarchyRefreshService.cs b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyRefreshService.cs similarity index 75% rename from src/MxGateway.Server/Galaxy/GalaxyHierarchyRefreshService.cs rename to src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyRefreshService.cs index 629a601..9a82506 100644 --- a/src/MxGateway.Server/Galaxy/GalaxyHierarchyRefreshService.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyRefreshService.cs @@ -2,7 +2,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -namespace MxGateway.Server.Galaxy; +namespace ZB.MOM.WW.MxGateway.Server.Galaxy; /// Background service that periodically refreshes the Galaxy Repository hierarchy cache off the request path. public sealed class GalaxyHierarchyRefreshService( @@ -26,6 +26,15 @@ public sealed class GalaxyHierarchyRefreshService( { return; } + catch (Exception exception) + { + // A transient first-load failure (e.g. a TimeoutException or + // Win32Exception from connection establishment, or a DbException + // subtype the cache does not catch) must not fault this + // BackgroundService and stop the whole gateway. The cache records + // its own Unavailable/Stale status; the periodic tick below retries. + logger.LogWarning(exception, "Initial Galaxy hierarchy cache load failed; will retry on the refresh interval."); + } using PeriodicTimer timer = new(interval, _timeProvider); try diff --git a/src/MxGateway.Server/Galaxy/GalaxyHierarchyRow.cs b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyRow.cs similarity index 98% rename from src/MxGateway.Server/Galaxy/GalaxyHierarchyRow.cs rename to src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyRow.cs index 6df9449..08d345b 100644 --- a/src/MxGateway.Server/Galaxy/GalaxyHierarchyRow.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyRow.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Galaxy; +namespace ZB.MOM.WW.MxGateway.Server.Galaxy; /// /// One row from : a deployed Galaxy diff --git a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchySnapshot.cs b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchySnapshot.cs new file mode 100644 index 0000000..d1879a7 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchySnapshot.cs @@ -0,0 +1,24 @@ +namespace ZB.MOM.WW.MxGateway.Server.Galaxy; + +/// +/// A serializable point-in-time copy of the Galaxy Repository browse data. +/// Holds the raw hierarchy and attribute rowsets — not the materialized +/// protobuf objects — so the restore path runs the exact same +/// materialization as a live refresh. Persisted by +/// after a successful refresh +/// and reloaded at startup when the Galaxy database is unreachable. +/// +/// +/// The galaxy.time_of_last_deploy the rowsets were pulled at, or +/// when the Galaxy table reported no deploy. A later +/// live refresh that observes this same timestamp can promote the restored +/// entry to healthy without re-running the heavy queries. +/// +/// UTC wall-clock when the snapshot was written to disk. +/// The persisted object-hierarchy rowset. +/// The persisted attribute rowset. +public sealed record GalaxyHierarchySnapshot( + DateTimeOffset? LastDeployTime, + DateTimeOffset SavedAt, + IReadOnlyList Hierarchy, + IReadOnlyList Attributes); diff --git a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchySnapshotStore.cs b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchySnapshotStore.cs new file mode 100644 index 0000000..7054af1 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchySnapshotStore.cs @@ -0,0 +1,141 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace ZB.MOM.WW.MxGateway.Server.Galaxy; + +/// +/// JSON-file implementation of . +/// Writes the on-disk snapshot atomically (temp file + rename) so a crash +/// mid-write can never leave a torn file, and ignores files whose schema +/// version it does not recognize. When +/// is +/// both operations are no-ops. +/// +public sealed class GalaxyHierarchySnapshotStore : IGalaxyHierarchySnapshotStore +{ + /// + /// On-disk format version. Bump this whenever the persisted shape changes + /// in a way an older or newer gateway cannot read; a mismatched file is + /// ignored rather than misparsed. + /// + private const int CurrentSchemaVersion = 1; + + private static readonly JsonSerializerOptions SerializerOptions = new() + { + WriteIndented = false, + }; + + private readonly string? _path; + private readonly TimeSpan _writeTimeout; + private readonly ILogger? _logger; + private readonly SemaphoreSlim _ioGate = new(1, 1); + + /// Initializes a new instance of the class. + /// Galaxy repository options carrying the snapshot path and enable flag. + /// Optional logger for diagnostic output. + public GalaxyHierarchySnapshotStore( + IOptions options, + ILogger? logger = null) + { + GalaxyRepositoryOptions value = options.Value; + _path = value.PersistSnapshot && !string.IsNullOrWhiteSpace(value.SnapshotCachePath) + ? value.SnapshotCachePath + : null; + _writeTimeout = TimeSpan.FromSeconds(Math.Max(1, value.CommandTimeoutSeconds)); + _logger = logger; + } + + /// + public async Task SaveAsync(GalaxyHierarchySnapshot snapshot, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(snapshot); + if (_path is null) + { + return; + } + + PersistedFile file = new(CurrentSchemaVersion, snapshot); + + await _ioGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + // Bound the write so a stuck disk — e.g. a SnapshotCachePath on an + // unresponsive network share — cannot stall the caller. On the cache + // refresh path that would otherwise pin the whole refresh loop. + using CancellationTokenSource writeCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + writeCts.CancelAfter(_writeTimeout); + + string? directory = Path.GetDirectoryName(_path); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + string tempPath = _path + ".tmp"; + await using (FileStream stream = new(tempPath, FileMode.Create, FileAccess.Write, FileShare.None)) + { + await JsonSerializer.SerializeAsync(stream, file, SerializerOptions, writeCts.Token).ConfigureAwait(false); + } + + File.Move(tempPath, _path, overwrite: true); + _logger?.LogDebug( + "Persisted Galaxy hierarchy snapshot to {Path} ({ObjectCount} objects, {AttributeCount} attributes).", + _path, + snapshot.Hierarchy.Count, + snapshot.Attributes.Count); + } + finally + { + _ioGate.Release(); + } + } + + /// + public async Task TryLoadAsync(CancellationToken cancellationToken) + { + if (_path is null || !File.Exists(_path)) + { + return null; + } + + await _ioGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + PersistedFile? file; + await using (FileStream stream = new(_path, FileMode.Open, FileAccess.Read, FileShare.Read)) + { + file = await JsonSerializer.DeserializeAsync( + stream, SerializerOptions, cancellationToken).ConfigureAwait(false); + } + + if (file is null || file.SchemaVersion != CurrentSchemaVersion || file.Snapshot is null) + { + _logger?.LogWarning( + "Ignoring Galaxy hierarchy snapshot at {Path}: unrecognized or empty schema version.", + _path); + return null; + } + + return file.Snapshot; + } + catch (Exception exception) when (exception is JsonException or IOException or UnauthorizedAccessException) + { + // A corrupt, truncated, locked, or access-denied snapshot file is an + // expected failure mode for a disk cache — honor the Try contract and + // return null rather than throwing. + _logger?.LogWarning( + exception, + "Ignoring Galaxy hierarchy snapshot at {Path}: the file is unreadable or not valid JSON.", + _path); + return null; + } + finally + { + _ioGate.Release(); + } + } + + /// On-disk envelope: a schema version plus the snapshot payload. + private sealed record PersistedFile(int SchemaVersion, GalaxyHierarchySnapshot? Snapshot); +} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyObjectView.cs b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyObjectView.cs new file mode 100644 index 0000000..626ebaf --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyObjectView.cs @@ -0,0 +1,8 @@ +using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; + +namespace ZB.MOM.WW.MxGateway.Server.Galaxy; + +public sealed record GalaxyObjectView( + GalaxyObject Object, + string ContainedPath, + int Depth); diff --git a/src/MxGateway.Server/Galaxy/GalaxyRepository.cs b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyRepository.cs similarity index 63% rename from src/MxGateway.Server/Galaxy/GalaxyRepository.cs rename to src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyRepository.cs index 2e27499..db3c171 100644 --- a/src/MxGateway.Server/Galaxy/GalaxyRepository.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyRepository.cs @@ -1,14 +1,19 @@ using Microsoft.Data.SqlClient; -namespace MxGateway.Server.Galaxy; +namespace ZB.MOM.WW.MxGateway.Server.Galaxy; /// -/// SQL access to the AVEVA System Platform Galaxy Repository (ZB) database. Ported from -/// the OtOpcUa project so the row sets stay byte-for-byte identical between the two -/// consumers — the same SQL drives the OPC UA server's address space and this gateway's -/// gRPC browse surface. +/// SQL access to the AVEVA System Platform Galaxy Repository (ZB) database. +/// +/// is still the query originally ported from the OtOpcUa +/// project. has diverged: it additionally enumerates the +/// built-in attributes contributed by each object's primitives (from +/// attribute_definition via primitive_instance), so engine/platform objects +/// and extension sub-attributes (e.g. TestAlarm001.Acked) are surfaced. The +/// OtOpcUa query is not kept in sync — see docs/GalaxyRepository.md. +/// /// -public sealed class GalaxyRepository(GalaxyRepositoryOptions options) +public sealed class GalaxyRepository(GalaxyRepositoryOptions options) : IGalaxyRepository { /// Tests the connection to the Galaxy Repository database. /// Token to cancel the asynchronous operation. @@ -158,6 +163,16 @@ WHERE td.category_id IN (1, 3, 4, 10, 11, 13, 17, 24, 26) AND g.deployed_package_id <> 0 ORDER BY parent_gobject_id, g.tag_name"; + // Unlike HierarchySql, this query has diverged from the OtOpcUa original. It returns two + // kinds of attribute: user-configured dynamic attributes (the original `dynamic_attribute` + // body, src_pri 0) and the built-in attributes every object inherits from its primitives + // (`attribute_definition` joined through `primitive_instance`, src_pri 1). Built-in + // attributes are why engine/platform objects and extension sub-attributes such as + // `TestAlarm001.Acked` show up at all. Built-in rows carry no category filter (the + // `attribute_definition` category numbering differs from `dynamic_attribute`'s — only the + // `_`-prefix and `.Description` name exclusions apply) and are never flagged + // `is_historized`/`is_alarm`: those flags describe a user attribute that anchors an + // extension, not the extension's machinery leaves. See docs/GalaxyRepository.md. private const string AttributesSql = @" ;WITH deployed_package_chain AS ( SELECT g.gobject_id, p.package_id, p.derived_from_package_id, 0 AS depth @@ -169,58 +184,69 @@ ORDER BY parent_gobject_id, g.tag_name"; FROM deployed_package_chain dpc INNER JOIN package p ON p.package_id = dpc.derived_from_package_id WHERE dpc.derived_from_package_id <> 0 AND dpc.depth < 10 -) -SELECT gobject_id, tag_name, attribute_name, full_tag_reference, - mx_data_type, data_type_name, is_array, array_dimension, - mx_attribute_category, security_classification, is_historized, is_alarm -FROM ( +), +candidate AS ( SELECT - dpc.gobject_id, - g.tag_name, - da.attribute_name, - g.tag_name + '.' + da.attribute_name - + CASE WHEN da.is_array = 1 THEN '[]' ELSE '' END - AS full_tag_reference, - da.mx_data_type, - dt.description AS data_type_name, - da.is_array, + dpc.gobject_id, g.tag_name, da.attribute_name, da.mx_data_type, da.is_array, CASE WHEN da.is_array = 1 THEN CONVERT(int, CONVERT(varbinary(2), SUBSTRING(da.mx_value, 15, 2) + SUBSTRING(da.mx_value, 13, 2), 2)) - ELSE NULL - END AS array_dimension, - da.mx_attribute_category, - da.security_classification, - CASE WHEN EXISTS ( - SELECT 1 FROM deployed_package_chain dpc2 - INNER JOIN primitive_instance pi ON pi.package_id = dpc2.package_id AND pi.primitive_name = da.attribute_name - INNER JOIN primitive_definition pd ON pd.primitive_definition_id = pi.primitive_definition_id AND pd.primitive_name = 'HistoryExtension' - WHERE dpc2.gobject_id = dpc.gobject_id - ) THEN 1 ELSE 0 END AS is_historized, - CASE WHEN EXISTS ( - SELECT 1 FROM deployed_package_chain dpc2 - INNER JOIN primitive_instance pi ON pi.package_id = dpc2.package_id AND pi.primitive_name = da.attribute_name - INNER JOIN primitive_definition pd ON pd.primitive_definition_id = pi.primitive_definition_id AND pd.primitive_name = 'AlarmExtension' - WHERE dpc2.gobject_id = dpc.gobject_id - ) THEN 1 ELSE 0 END AS is_alarm, - ROW_NUMBER() OVER ( - PARTITION BY dpc.gobject_id, da.attribute_name - ORDER BY dpc.depth - ) AS rn + ELSE NULL END AS array_dimension, + da.mx_attribute_category, da.security_classification, dpc.depth, 0 AS src_pri FROM deployed_package_chain dpc - INNER JOIN dynamic_attribute da - ON da.package_id = dpc.package_id - INNER JOIN gobject g - ON g.gobject_id = dpc.gobject_id - INNER JOIN template_definition td - ON td.template_definition_id = g.template_definition_id - LEFT JOIN data_type dt - ON dt.mx_data_type = da.mx_data_type + INNER JOIN dynamic_attribute da ON da.package_id = dpc.package_id + INNER JOIN gobject g ON g.gobject_id = dpc.gobject_id + INNER JOIN template_definition td ON td.template_definition_id = g.template_definition_id WHERE td.category_id IN (1, 3, 4, 10, 11, 13, 17, 24, 26) AND da.attribute_name NOT LIKE '[_]%' AND da.attribute_name NOT LIKE '%.Description' AND da.mx_attribute_category IN (2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 24) -) ranked -WHERE rn = 1 -ORDER BY tag_name, attribute_name"; + UNION ALL + SELECT + dpc.gobject_id, g.tag_name, + CASE WHEN pi.primitive_name IS NULL OR pi.primitive_name = '' + THEN ad.attribute_name + ELSE pi.primitive_name + '.' + ad.attribute_name END AS attribute_name, + ad.mx_data_type, ad.is_array, + CASE WHEN ad.is_array = 1 + THEN CONVERT(int, CONVERT(varbinary(2), + SUBSTRING(ad.mx_value, 15, 2) + SUBSTRING(ad.mx_value, 13, 2), 2)) + ELSE NULL END AS array_dimension, + ad.mx_attribute_category, ad.security_classification, dpc.depth, 1 AS src_pri + FROM deployed_package_chain dpc + INNER JOIN primitive_instance pi ON pi.package_id = dpc.package_id + INNER JOIN attribute_definition ad ON ad.primitive_definition_id = pi.primitive_definition_id + INNER JOIN gobject g ON g.gobject_id = dpc.gobject_id + INNER JOIN template_definition td ON td.template_definition_id = g.template_definition_id + WHERE td.category_id IN (1, 3, 4, 10, 11, 13, 17, 24, 26) + AND ad.attribute_name NOT LIKE '[_]%' + AND ad.attribute_name NOT LIKE '%.Description' +), +ranked AS ( + SELECT c.*, ROW_NUMBER() OVER ( + PARTITION BY c.gobject_id, c.attribute_name ORDER BY c.src_pri, c.depth) AS rn + FROM candidate c +) +SELECT + r.gobject_id, r.tag_name, r.attribute_name, + r.tag_name + '.' + r.attribute_name + + CASE WHEN r.is_array = 1 THEN '[]' ELSE '' END AS full_tag_reference, + r.mx_data_type, dt.description AS data_type_name, r.is_array, r.array_dimension, + r.mx_attribute_category, r.security_classification, + CASE WHEN r.src_pri = 0 AND EXISTS ( + SELECT 1 FROM deployed_package_chain dpc2 + INNER JOIN primitive_instance pi ON pi.package_id = dpc2.package_id AND pi.primitive_name = r.attribute_name + INNER JOIN primitive_definition pd ON pd.primitive_definition_id = pi.primitive_definition_id AND pd.primitive_name = 'HistoryExtension' + WHERE dpc2.gobject_id = r.gobject_id + ) THEN 1 ELSE 0 END AS is_historized, + CASE WHEN r.src_pri = 0 AND EXISTS ( + SELECT 1 FROM deployed_package_chain dpc2 + INNER JOIN primitive_instance pi ON pi.package_id = dpc2.package_id AND pi.primitive_name = r.attribute_name + INNER JOIN primitive_definition pd ON pd.primitive_definition_id = pi.primitive_definition_id AND pd.primitive_name = 'AlarmExtension' + WHERE dpc2.gobject_id = r.gobject_id + ) THEN 1 ELSE 0 END AS is_alarm +FROM ranked r +LEFT JOIN data_type dt ON dt.mx_data_type = r.mx_data_type +WHERE r.rn = 1 +ORDER BY r.tag_name, r.attribute_name"; } diff --git a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyRepositoryOptions.cs b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyRepositoryOptions.cs new file mode 100644 index 0000000..c58e43a --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyRepositoryOptions.cs @@ -0,0 +1,47 @@ +namespace ZB.MOM.WW.MxGateway.Server.Galaxy; + +/// +/// Connection settings for the AVEVA System Platform Galaxy Repository (ZB) database. +/// Bound to the MxGateway:Galaxy configuration section. +/// +public sealed class GalaxyRepositoryOptions +{ + public const string SectionName = "MxGateway:Galaxy"; + + /// + /// Default SQL Server connection string for the Galaxy Repository database. + /// Single source of truth shared with the integration-test fallback so the + /// production default and the live-test default cannot drift. + /// + public const string DefaultConnectionString = + "Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;"; + + /// The SQL Server connection string for the Galaxy Repository database. + public string ConnectionString { get; init; } = DefaultConnectionString; + + /// The timeout in seconds for SQL commands executed against the Galaxy Repository. + public int CommandTimeoutSeconds { get; init; } = 60; + + /// + /// Interval (seconds) between background refreshes of the dashboard Galaxy summary + /// cache. SQL is hit at most once per interval regardless of dashboard render rate. + /// + public int DashboardRefreshIntervalSeconds { get; init; } = 30; + + /// Default on-disk path for the persisted Galaxy browse snapshot. + public const string DefaultSnapshotCachePath = + @"C:\ProgramData\MxGateway\galaxy-snapshot.json"; + + /// + /// Whether the gateway persists the latest successful Galaxy browse dataset to + /// disk. When enabled, the cache reloads that snapshot at startup so clients can + /// still browse last-known data while the Galaxy database is unreachable. + /// + public bool PersistSnapshot { get; init; } = true; + + /// + /// File path for the persisted Galaxy browse snapshot. Ignored when + /// is . + /// + public string SnapshotCachePath { get; init; } = DefaultSnapshotCachePath; +} diff --git a/src/MxGateway.Server/Galaxy/GalaxyRepositoryServiceCollectionExtensions.cs b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyRepositoryServiceCollectionExtensions.cs similarity index 80% rename from src/MxGateway.Server/Galaxy/GalaxyRepositoryServiceCollectionExtensions.cs rename to src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyRepositoryServiceCollectionExtensions.cs index 35cf727..c8729ff 100644 --- a/src/MxGateway.Server/Galaxy/GalaxyRepositoryServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyRepositoryServiceCollectionExtensions.cs @@ -1,6 +1,6 @@ using Microsoft.Extensions.Options; -namespace MxGateway.Server.Galaxy; +namespace ZB.MOM.WW.MxGateway.Server.Galaxy; public static class GalaxyRepositoryServiceCollectionExtensions { @@ -16,8 +16,10 @@ public static class GalaxyRepositoryServiceCollectionExtensions services.AddSingleton(sp => new GalaxyRepository(sp.GetRequiredService>().Value)); + services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddHostedService(); diff --git a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyTagLookup.cs b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyTagLookup.cs new file mode 100644 index 0000000..d8073f1 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyTagLookup.cs @@ -0,0 +1,8 @@ +using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; + +namespace ZB.MOM.WW.MxGateway.Server.Galaxy; + +public sealed record GalaxyTagLookup( + GalaxyObject Object, + GalaxyAttribute? Attribute, + string ContainedPath); diff --git a/src/MxGateway.Server/Galaxy/IGalaxyDeployNotifier.cs b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/IGalaxyDeployNotifier.cs similarity index 95% rename from src/MxGateway.Server/Galaxy/IGalaxyDeployNotifier.cs rename to src/ZB.MOM.WW.MxGateway.Server/Galaxy/IGalaxyDeployNotifier.cs index 6bcef2e..8dfd032 100644 --- a/src/MxGateway.Server/Galaxy/IGalaxyDeployNotifier.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/IGalaxyDeployNotifier.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Galaxy; +namespace ZB.MOM.WW.MxGateway.Server.Galaxy; /// Publishes Galaxy repository deploy events to subscribers. public interface IGalaxyDeployNotifier diff --git a/src/MxGateway.Server/Galaxy/IGalaxyHierarchyCache.cs b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/IGalaxyHierarchyCache.cs similarity index 96% rename from src/MxGateway.Server/Galaxy/IGalaxyHierarchyCache.cs rename to src/ZB.MOM.WW.MxGateway.Server/Galaxy/IGalaxyHierarchyCache.cs index 027a867..0737276 100644 --- a/src/MxGateway.Server/Galaxy/IGalaxyHierarchyCache.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/IGalaxyHierarchyCache.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Galaxy; +namespace ZB.MOM.WW.MxGateway.Server.Galaxy; /// Cache for Galaxy Repository hierarchy data. public interface IGalaxyHierarchyCache diff --git a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/IGalaxyHierarchySnapshotStore.cs b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/IGalaxyHierarchySnapshotStore.cs new file mode 100644 index 0000000..53556d5 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/IGalaxyHierarchySnapshotStore.cs @@ -0,0 +1,28 @@ +namespace ZB.MOM.WW.MxGateway.Server.Galaxy; + +/// +/// Persists the latest Galaxy Repository browse dataset to disk and reloads +/// it at startup. Lets serve last-known +/// browse data when the Galaxy database is unreachable on a cold start. +/// +public interface IGalaxyHierarchySnapshotStore +{ + /// + /// Writes to disk, replacing any previous + /// snapshot atomically. A no-op when snapshot persistence is disabled. + /// + /// The browse dataset to persist. + /// Token to cancel the asynchronous operation. + Task SaveAsync(GalaxyHierarchySnapshot snapshot, CancellationToken cancellationToken); + + /// + /// Reads the persisted Galaxy browse dataset. + /// + /// Token to cancel the asynchronous operation. + /// + /// The persisted snapshot, or when none exists, + /// persistence is disabled, or the on-disk file uses an unrecognized + /// schema version. + /// + Task TryLoadAsync(CancellationToken cancellationToken); +} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/IGalaxyRepository.cs b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/IGalaxyRepository.cs new file mode 100644 index 0000000..6621837 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/IGalaxyRepository.cs @@ -0,0 +1,30 @@ +namespace ZB.MOM.WW.MxGateway.Server.Galaxy; + +/// +/// Abstraction over consumed by +/// . Exists so the cache can be unit-tested +/// against an in-memory fake that throws a +/// from (the unavailable-backend code +/// path) without standing up a real Microsoft.Data.SqlClient +/// SqlConnection against a bogus host/port. The production gateway +/// wires the concrete ; the SQL surface itself +/// stays covered by ZB.MOM.WW.MxGateway.IntegrationTests.Galaxy.GalaxyRepositoryLiveTests. +/// +public interface IGalaxyRepository +{ + /// Tests the connection to the Galaxy Repository database. + /// Token to cancel the asynchronous operation. + Task TestConnectionAsync(CancellationToken ct = default); + + /// Retrieves the last deployment time from the Galaxy Repository. + /// Token to cancel the asynchronous operation. + Task GetLastDeployTimeAsync(CancellationToken ct = default); + + /// Retrieves the complete hierarchy of Galaxy objects from the repository. + /// Token to cancel the asynchronous operation. + Task> GetHierarchyAsync(CancellationToken ct = default); + + /// Retrieves all attributes for Galaxy objects from the repository. + /// Token to cancel the asynchronous operation. + Task> GetAttributesAsync(CancellationToken ct = default); +} diff --git a/src/MxGateway.Server/GatewayApplication.cs b/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs similarity index 86% rename from src/MxGateway.Server/GatewayApplication.cs rename to src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs index bb19b90..d06a4e2 100644 --- a/src/MxGateway.Server/GatewayApplication.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs @@ -1,24 +1,25 @@ using Microsoft.AspNetCore.Hosting.StaticWebAssets; -using MxGateway.Contracts; -using MxGateway.Server.Configuration; -using MxGateway.Server.Dashboard; -using MxGateway.Server.Diagnostics; -using MxGateway.Server.Galaxy; -using MxGateway.Server.Grpc; -using MxGateway.Server.Metrics; -using MxGateway.Server.Security.Authentication; -using MxGateway.Server.Security.Authorization; -using MxGateway.Server.Sessions; -using MxGateway.Server.Workers; +using ZB.MOM.WW.MxGateway.Contracts; +using ZB.MOM.WW.MxGateway.Server.Alarms; +using ZB.MOM.WW.MxGateway.Server.Configuration; +using ZB.MOM.WW.MxGateway.Server.Dashboard; +using ZB.MOM.WW.MxGateway.Server.Diagnostics; +using ZB.MOM.WW.MxGateway.Server.Galaxy; +using ZB.MOM.WW.MxGateway.Server.Grpc; +using ZB.MOM.WW.MxGateway.Server.Metrics; +using ZB.MOM.WW.MxGateway.Server.Security.Authentication; +using ZB.MOM.WW.MxGateway.Server.Security.Authorization; +using ZB.MOM.WW.MxGateway.Server.Sessions; +using ZB.MOM.WW.MxGateway.Server.Workers; -namespace MxGateway.Server; +namespace ZB.MOM.WW.MxGateway.Server; /// /// Configures and builds the gateway web application. /// public static class GatewayApplication { - private const string StaticAssetsManifestFileName = "MxGateway.Server.staticwebassets.endpoints.json"; + private const string StaticAssetsManifestFileName = "ZB.MOM.WW.MxGateway.Server.staticwebassets.endpoints.json"; /// /// Builds a configured web application with all gateway services and middleware. @@ -64,6 +65,7 @@ public static class GatewayApplication builder.Services.AddSingleton(); builder.Services.AddWorkerProcessLauncher(); builder.Services.AddGatewaySessions(); + builder.Services.AddGatewayAlarms(); builder.Services.AddGatewayDashboard(); builder.Services.AddGalaxyRepository(); @@ -107,7 +109,7 @@ public static class GatewayApplication return directory.FullName; } - string serverProjectPath = Path.Combine(directory.FullName, "src", "MxGateway.Server"); + string serverProjectPath = Path.Combine(directory.FullName, "src", "ZB.MOM.WW.MxGateway.Server"); if (IsServerContentRoot(serverProjectPath)) { return serverProjectPath; diff --git a/src/MxGateway.Server/GatewayHealthReply.cs b/src/ZB.MOM.WW.MxGateway.Server/GatewayHealthReply.cs similarity index 76% rename from src/MxGateway.Server/GatewayHealthReply.cs rename to src/ZB.MOM.WW.MxGateway.Server/GatewayHealthReply.cs index c4a7ad7..33cac1c 100644 --- a/src/MxGateway.Server/GatewayHealthReply.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/GatewayHealthReply.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server; +namespace ZB.MOM.WW.MxGateway.Server; public sealed record GatewayHealthReply( string Status, diff --git a/src/MxGateway.Server/Grpc/EventStreamService.cs b/src/ZB.MOM.WW.MxGateway.Server/Grpc/EventStreamService.cs similarity index 94% rename from src/MxGateway.Server/Grpc/EventStreamService.cs rename to src/ZB.MOM.WW.MxGateway.Server/Grpc/EventStreamService.cs index 11ae968..5d76c9e 100644 --- a/src/MxGateway.Server/Grpc/EventStreamService.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Grpc/EventStreamService.cs @@ -1,13 +1,13 @@ using System.Runtime.CompilerServices; using System.Threading.Channels; using Microsoft.Extensions.Options; -using MxGateway.Contracts.Proto; -using MxGateway.Server.Configuration; -using MxGateway.Server.Metrics; -using MxGateway.Server.Sessions; -using MxGateway.Server.Workers; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Server.Configuration; +using ZB.MOM.WW.MxGateway.Server.Metrics; +using ZB.MOM.WW.MxGateway.Server.Sessions; +using ZB.MOM.WW.MxGateway.Server.Workers; -namespace MxGateway.Server.Grpc; +namespace ZB.MOM.WW.MxGateway.Server.Grpc; public sealed class EventStreamService( ISessionManager sessionManager, @@ -26,7 +26,7 @@ public sealed class EventStreamService( StreamEventsRequest request, [EnumeratorCancellation] CancellationToken cancellationToken) { - if (!sessionManager.TryGetSession(request.SessionId, out GatewaySession session)) + if (!sessionManager.TryGetSession(request.SessionId, out GatewaySession? session) || session is null) { throw new SessionManagerException( SessionManagerErrorCode.SessionNotFound, diff --git a/src/MxGateway.Server/Grpc/GalaxyProtoMapper.cs b/src/ZB.MOM.WW.MxGateway.Server/Grpc/GalaxyProtoMapper.cs similarity index 95% rename from src/MxGateway.Server/Grpc/GalaxyProtoMapper.cs rename to src/ZB.MOM.WW.MxGateway.Server/Grpc/GalaxyProtoMapper.cs index 4a1f258..5102709 100644 --- a/src/MxGateway.Server/Grpc/GalaxyProtoMapper.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Grpc/GalaxyProtoMapper.cs @@ -1,7 +1,7 @@ -using MxGateway.Contracts.Proto.Galaxy; -using MxGateway.Server.Galaxy; +using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; +using ZB.MOM.WW.MxGateway.Server.Galaxy; -namespace MxGateway.Server.Grpc; +namespace ZB.MOM.WW.MxGateway.Server.Grpc; /// /// Maps + rows produced diff --git a/src/MxGateway.Server/Grpc/GalaxyRepositoryGrpcService.cs b/src/ZB.MOM.WW.MxGateway.Server/Grpc/GalaxyRepositoryGrpcService.cs similarity index 93% rename from src/MxGateway.Server/Grpc/GalaxyRepositoryGrpcService.cs rename to src/ZB.MOM.WW.MxGateway.Server/Grpc/GalaxyRepositoryGrpcService.cs index 8690409..d9193f7 100644 --- a/src/MxGateway.Server/Grpc/GalaxyRepositoryGrpcService.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Grpc/GalaxyRepositoryGrpcService.cs @@ -1,13 +1,13 @@ using Google.Protobuf.WellKnownTypes; using Grpc.Core; using Microsoft.Data.SqlClient; -using MxGateway.Contracts.Proto.Galaxy; -using GalaxyDb = MxGateway.Server.Galaxy; -using MxGateway.Server.Security.Authentication; -using MxGateway.Server.Security.Authorization; -using ProtoGalaxyRepository = MxGateway.Contracts.Proto.Galaxy.GalaxyRepository; +using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; +using GalaxyDb = ZB.MOM.WW.MxGateway.Server.Galaxy; +using ZB.MOM.WW.MxGateway.Server.Security.Authentication; +using ZB.MOM.WW.MxGateway.Server.Security.Authorization; +using ProtoGalaxyRepository = ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.GalaxyRepository; -namespace MxGateway.Server.Grpc; +namespace ZB.MOM.WW.MxGateway.Server.Grpc; /// /// gRPC surface that exposes the Galaxy Repository to clients. DiscoverHierarchy @@ -17,7 +17,7 @@ namespace MxGateway.Server.Grpc; /// direct SQL probe since callers use it as a health check. /// public sealed class GalaxyRepositoryGrpcService( - GalaxyDb.GalaxyRepository repository, + GalaxyDb.IGalaxyRepository repository, GalaxyDb.IGalaxyHierarchyCache cache, GalaxyDb.IGalaxyDeployNotifier notifier, IGatewayRequestIdentityAccessor identityAccessor, @@ -115,6 +115,11 @@ public sealed class GalaxyRepositoryGrpcService( { DateTimeOffset? lastSeen = request.LastSeenDeployTime?.ToDateTimeOffset(); + // The caller's identity (and therefore its browse-subtree constraints) is fixed + // for the lifetime of the stream, so resolve the subtrees once rather than per + // streamed event. + IReadOnlyList browseSubtrees = ResolveBrowseSubtrees(); + await foreach (GalaxyDb.GalaxyDeployEventInfo info in notifier .SubscribeAsync(context.CancellationToken) .ConfigureAwait(false)) @@ -129,7 +134,7 @@ public sealed class GalaxyRepositoryGrpcService( } lastSeen = null; - await responseStream.WriteAsync(MapDeployEvent(info, ResolveBrowseSubtrees()), context.CancellationToken).ConfigureAwait(false); + await responseStream.WriteAsync(MapDeployEvent(info, browseSubtrees), context.CancellationToken).ConfigureAwait(false); } } diff --git a/src/MxGateway.Server/Grpc/IEventStreamService.cs b/src/ZB.MOM.WW.MxGateway.Server/Grpc/IEventStreamService.cs similarity index 85% rename from src/MxGateway.Server/Grpc/IEventStreamService.cs rename to src/ZB.MOM.WW.MxGateway.Server/Grpc/IEventStreamService.cs index 5996a81..ace7fce 100644 --- a/src/MxGateway.Server/Grpc/IEventStreamService.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Grpc/IEventStreamService.cs @@ -1,6 +1,6 @@ -using MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Contracts.Proto; -namespace MxGateway.Server.Grpc; +namespace ZB.MOM.WW.MxGateway.Server.Grpc; /// /// Streams MXAccess events to gRPC clients. diff --git a/src/MxGateway.Server/Grpc/MxAccessGatewayService.cs b/src/ZB.MOM.WW.MxGateway.Server/Grpc/MxAccessGatewayService.cs similarity index 52% rename from src/MxGateway.Server/Grpc/MxAccessGatewayService.cs rename to src/ZB.MOM.WW.MxGateway.Server/Grpc/MxAccessGatewayService.cs index 87b4ad4..170266e 100644 --- a/src/MxGateway.Server/Grpc/MxAccessGatewayService.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Grpc/MxAccessGatewayService.cs @@ -1,15 +1,16 @@ using System.Diagnostics; using Grpc.Core; using Google.Protobuf.WellKnownTypes; -using MxGateway.Contracts; -using MxGateway.Contracts.Proto; -using MxGateway.Server.Metrics; -using MxGateway.Server.Security.Authentication; -using MxGateway.Server.Security.Authorization; -using MxGateway.Server.Sessions; -using MxGateway.Server.Workers; +using ZB.MOM.WW.MxGateway.Contracts; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Server.Alarms; +using ZB.MOM.WW.MxGateway.Server.Metrics; +using ZB.MOM.WW.MxGateway.Server.Security.Authentication; +using ZB.MOM.WW.MxGateway.Server.Security.Authorization; +using ZB.MOM.WW.MxGateway.Server.Sessions; +using ZB.MOM.WW.MxGateway.Server.Workers; -namespace MxGateway.Server.Grpc; +namespace ZB.MOM.WW.MxGateway.Server.Grpc; /// gRPC service implementation for MXAccess Gateway operations. public sealed class MxAccessGatewayService( @@ -21,9 +22,8 @@ public sealed class MxAccessGatewayService( IEventStreamService eventStreamService, GatewayMetrics metrics, ILogger logger, - IAlarmRpcDispatcher? alarmRpcDispatcher = null) : MxAccessGateway.MxAccessGatewayBase + IGatewayAlarmService alarmService) : MxAccessGateway.MxAccessGatewayBase { - private readonly IAlarmRpcDispatcher alarmRpcDispatcher = alarmRpcDispatcher ?? new NotWiredAlarmRpcDispatcher(); /// public override async Task OpenSession( OpenSessionRequest request, @@ -54,6 +54,8 @@ public sealed class MxAccessGatewayService( reply.Capabilities.Add("unary-invoke"); reply.Capabilities.Add("server-stream-events"); reply.Capabilities.Add("bulk-subscribe-commands"); + reply.Capabilities.Add("bulk-read-commands"); + reply.Capabilities.Add("bulk-write-commands"); reply.Capabilities.Add("unary-acknowledge-alarm"); reply.Capabilities.Add("server-stream-active-alarms"); @@ -109,7 +111,7 @@ public sealed class MxAccessGatewayService( MxCommand commandToInvoke = bulkConstraintPlan?.Command ?? command; if (bulkConstraintPlan is { HasAllowedItems: false }) { - return CreateDeniedBulkReply(request, bulkConstraintPlan); + return bulkConstraintPlan.CreateDeniedReply(request); } MxCommandRequest invokeRequest = request.Clone(); @@ -122,7 +124,7 @@ public sealed class MxAccessGatewayService( MxCommandReply publicReply = mapper.MapCommandReply(workerReply); if (bulkConstraintPlan is not null) { - publicReply = MergeDeniedBulkResults(publicReply, command.Kind, bulkConstraintPlan); + publicReply = bulkConstraintPlan.MergeDeniedInto(publicReply); } session.TrackCommandReply(commandToInvoke, publicReply); @@ -161,13 +163,13 @@ public sealed class MxAccessGatewayService( /// /// - /// PR A.3 — surfaces the public AcknowledgeAlarm RPC. The gateway resolves the - /// session and returns a successful reply; the actual worker-side ack call ships - /// in PR A.2 which adds the MxAccess alarm subscription + worker command - /// handler. Clients calling this method today receive an OK reply with a - /// "worker alarm path not yet wired" diagnostic — no PERMISSION_DENIED, no - /// UNIMPLEMENTED, so the .NET / Python / Go / Java / Rust SDK call sites land - /// on a stable surface. + /// Surfaces the public AcknowledgeAlarm RPC. Acknowledgement is + /// session-less: the gateway routes it through the always-on + /// monitor session. An + /// alarm_full_reference that parses as a canonical GUID forwards + /// to AcknowledgeAlarmCommand; a Provider!Group.Tag + /// reference forwards to AcknowledgeAlarmByNameCommand; anything + /// else returns an InvalidRequest diagnostic in the reply. /// public override async Task AcknowledgeAlarm( AcknowledgeAlarmRequest request, @@ -176,25 +178,12 @@ public sealed class MxAccessGatewayService( try { ArgumentNullException.ThrowIfNull(request); - if (string.IsNullOrEmpty(request.SessionId)) - { - throw new RpcException(new Status(StatusCode.InvalidArgument, "session_id is required.")); - } if (string.IsNullOrEmpty(request.AlarmFullReference)) { throw new RpcException(new Status(StatusCode.InvalidArgument, "alarm_full_reference is required.")); } - // Validate the session exists. Throws SessionManagerException → mapped to - // gRPC NotFound by the caller's MapException. - _ = ResolveSession(request.SessionId); - - // PR A.6 — delegate to the alarm dispatcher. NotWiredAlarmRpcDispatcher - // (default) returns OK + a worker-pending diagnostic. Production - // WorkerAlarmRpcDispatcher (dev-rig follow-up) routes through the - // worker IPC to AlarmClient.AlarmAckByGUID with full operator-identity - // fidelity. - return await alarmRpcDispatcher.AcknowledgeAsync(request, context.CancellationToken) + return await alarmService.AcknowledgeAsync(request, context.CancellationToken) .ConfigureAwait(false); } catch (Exception exception) when (exception is not RpcException) @@ -205,38 +194,27 @@ public sealed class MxAccessGatewayService( /// /// - /// PR A.3 — surfaces the public QueryActiveAlarms RPC as an empty stream until - /// PR A.2 adds the worker-side QueryActiveAlarmsCommand that walks the - /// MxAccess active-alarm collection. Clients can call the RPC and iterate the - /// stream; today the stream completes immediately. Once A.2 ships, this - /// handler will translate the request into a WorkerCommand and stream the - /// resulting snapshots. + /// Surfaces the public StreamAlarms RPC — the session-less central + /// alarm feed. The stream opens with one active_alarm per + /// currently-active alarm, then a single snapshot_complete, then + /// a transition for every subsequent change. Served by the + /// gateway's always-on monitor; any + /// number of clients fan out from the single monitor. /// - public override async Task QueryActiveAlarms( - QueryActiveAlarmsRequest request, - IServerStreamWriter responseStream, + public override async Task StreamAlarms( + StreamAlarmsRequest request, + IServerStreamWriter responseStream, ServerCallContext context) { try { ArgumentNullException.ThrowIfNull(request); - if (string.IsNullOrEmpty(request.SessionId)) - { - throw new RpcException(new Status(StatusCode.InvalidArgument, "session_id is required.")); - } - _ = ResolveSession(request.SessionId); - - // PR A.7 — delegate to the alarm dispatcher. NotWiredAlarmRpcDispatcher - // (default) yields an empty stream. Production WorkerAlarmRpcDispatcher - // (dev-rig follow-up) walks the worker's IMxAccessAlarmConsumer - // SnapshotActiveAlarms output and translates each AlarmRecord into an - // ActiveAlarmSnapshot. - await foreach (ActiveAlarmSnapshot snapshot in alarmRpcDispatcher - .QueryActiveAlarmsAsync(request, context.CancellationToken) + await foreach (AlarmFeedMessage message in alarmService + .StreamAsync(request.AlarmFilterPrefix, context.CancellationToken) .WithCancellation(context.CancellationToken) .ConfigureAwait(false)) { - await responseStream.WriteAsync(snapshot).ConfigureAwait(false); + await responseStream.WriteAsync(message).ConfigureAwait(false); } } catch (Exception exception) when (exception is not RpcException) @@ -252,7 +230,7 @@ public sealed class MxAccessGatewayService( private GatewaySession ResolveSession(string sessionId) { - if (!sessionManager.TryGetSession(sessionId, out GatewaySession session)) + if (!sessionManager.TryGetSession(sessionId, out GatewaySession? session) || session is null) { throw new SessionManagerException( SessionManagerErrorCode.SessionNotFound, @@ -303,6 +281,54 @@ public sealed class MxAccessGatewayService( command.AdviseItemBulk.ItemHandles, cancellationToken) .ConfigureAwait(false); + case MxCommandKind.ReadBulk: + return await FilterReadBulkAsync( + identity, + command, + command.ReadBulk.ServerHandle, + command.ReadBulk.TagAddresses, + cancellationToken) + .ConfigureAwait(false); + case MxCommandKind.WriteBulk: + return await FilterWriteBulkAsync( + identity, + session, + command, + command.WriteBulk.ServerHandle, + command.WriteBulk.Entries, + entry => entry.ItemHandle, + cancellationToken) + .ConfigureAwait(false); + case MxCommandKind.Write2Bulk: + return await FilterWriteBulkAsync( + identity, + session, + command, + command.Write2Bulk.ServerHandle, + command.Write2Bulk.Entries, + entry => entry.ItemHandle, + cancellationToken) + .ConfigureAwait(false); + case MxCommandKind.WriteSecuredBulk: + return await FilterWriteBulkAsync( + identity, + session, + command, + command.WriteSecuredBulk.ServerHandle, + command.WriteSecuredBulk.Entries, + entry => entry.ItemHandle, + cancellationToken) + .ConfigureAwait(false); + case MxCommandKind.WriteSecured2Bulk: + return await FilterWriteBulkAsync( + identity, + session, + command, + command.WriteSecured2Bulk.ServerHandle, + command.WriteSecured2Bulk.Entries, + entry => entry.ItemHandle, + cancellationToken) + .ConfigureAwait(false); case MxCommandKind.Write: await EnforceWriteHandleAsync( identity, @@ -437,7 +463,136 @@ public sealed class MxAccessGatewayService( filtered.SubscribeBulk.TagAddresses.Add(allowed); } - return new BulkConstraintPlan(filtered, tagAddresses.Count, denied, allowed.Count > 0); + return new SubscribeBulkConstraintPlan(filtered, tagAddresses.Count, denied, allowed.Count > 0); + } + + private async Task FilterReadBulkAsync( + ApiKeyIdentity? identity, + MxCommand command, + int serverHandle, + IReadOnlyList tagAddresses, + CancellationToken cancellationToken) + { + // Mirrors FilterTagBulkAsync but produces BulkReadResult denial entries + // so the reply payload merges into BulkReadReply.Results, not + // BulkSubscribeReply.Results. + Dictionary denied = []; + List allowed = []; + for (int index = 0; index < tagAddresses.Count; index++) + { + string tagAddress = tagAddresses[index]; + ConstraintFailure? failure = await constraintEnforcer + .CheckReadTagAsync(identity, tagAddress, cancellationToken) + .ConfigureAwait(false); + if (failure is null) + { + allowed.Add(tagAddress); + continue; + } + + await constraintEnforcer.RecordDenialAsync(identity, command.Kind.ToString(), tagAddress, failure, cancellationToken) + .ConfigureAwait(false); + denied[index] = new BulkReadResult + { + ServerHandle = serverHandle, + TagAddress = tagAddress, + WasSuccessful = false, + WasCached = false, + ErrorMessage = failure.Message, + }; + } + + if (denied.Count == 0) + { + return null; + } + + MxCommand filtered = command.Clone(); + filtered.ReadBulk.TagAddresses.Clear(); + filtered.ReadBulk.TagAddresses.Add(allowed); + + return new ReadBulkConstraintPlan(filtered, tagAddresses.Count, denied, allowed.Count > 0); + } + + private async Task FilterWriteBulkAsync( + ApiKeyIdentity? identity, + GatewaySession session, + MxCommand command, + int serverHandle, + Google.Protobuf.Collections.RepeatedField entries, + Func getItemHandle, + CancellationToken cancellationToken) where TEntry : class + { + // The four bulk-write families each carry a different per-entry message + // shape (WriteBulkEntry / Write2BulkEntry / WriteSecuredBulkEntry / + // WriteSecured2BulkEntry), but the constraint check itself is identical + // — "is this caller allowed to write to this server+item handle?". + // Parameterising on TEntry + getItemHandle keeps a single filter + // routine for all four and avoids duplicating CheckWriteHandleAsync + // calls. + Dictionary denied = []; + List allowed = []; + for (int index = 0; index < entries.Count; index++) + { + TEntry entry = entries[index]; + int itemHandle = getItemHandle(entry); + ConstraintFailure? failure = await constraintEnforcer + .CheckWriteHandleAsync(identity, session, serverHandle, itemHandle, cancellationToken) + .ConfigureAwait(false); + if (failure is null) + { + allowed.Add(entry); + continue; + } + + await constraintEnforcer.RecordDenialAsync( + identity, + command.Kind.ToString(), + itemHandle.ToString(System.Globalization.CultureInfo.InvariantCulture), + failure, + cancellationToken) + .ConfigureAwait(false); + denied[index] = new BulkWriteResult + { + ServerHandle = serverHandle, + ItemHandle = itemHandle, + WasSuccessful = false, + ErrorMessage = failure.Message, + }; + } + + if (denied.Count == 0) + { + return null; + } + + MxCommand filtered = command.Clone(); + ReplaceWriteBulkEntries(filtered, allowed); + return new WriteBulkConstraintPlan(filtered, entries.Count, denied, allowed.Count > 0); + } + + private static void ReplaceWriteBulkEntries(MxCommand command, IReadOnlyList allowed) + where TEntry : class + { + switch (command.Kind) + { + case MxCommandKind.WriteBulk: + command.WriteBulk.Entries.Clear(); + command.WriteBulk.Entries.Add((IEnumerable)allowed); + break; + case MxCommandKind.Write2Bulk: + command.Write2Bulk.Entries.Clear(); + command.Write2Bulk.Entries.Add((IEnumerable)allowed); + break; + case MxCommandKind.WriteSecuredBulk: + command.WriteSecuredBulk.Entries.Clear(); + command.WriteSecuredBulk.Entries.Add((IEnumerable)allowed); + break; + case MxCommandKind.WriteSecured2Bulk: + command.WriteSecured2Bulk.Entries.Clear(); + command.WriteSecured2Bulk.Entries.Add((IEnumerable)allowed); + break; + } } private async Task FilterHandleBulkAsync( @@ -482,90 +637,221 @@ public sealed class MxAccessGatewayService( filtered.AdviseItemBulk.ItemHandles.Clear(); filtered.AdviseItemBulk.ItemHandles.Add(allowed); - return new BulkConstraintPlan(filtered, itemHandles.Count, denied, allowed.Count > 0); + return new SubscribeBulkConstraintPlan(filtered, itemHandles.Count, denied, allowed.Count > 0); } - private static MxCommandReply CreateDeniedBulkReply( - MxCommandRequest request, - BulkConstraintPlan plan) + /// + /// Polymorphic constraint plan returned from . + /// Each concrete subtype is keyed to a specific bulk-reply shape — the + /// SubscribeResult-based AddItem/Advise/Subscribe family, the + /// BulkWriteResult-based Write* bulk family, and the BulkReadResult-based + /// ReadBulk command. Subtypes own their own merge / denied-reply build + /// logic so the Invoke dispatch site never branches on reply shape. + /// + private abstract record BulkConstraintPlan( + MxCommand Command, + int OriginalCount, + bool HasAllowedItems) { - MxCommandReply reply = new() + /// Builds a reply containing only the denied entries (used when no items survived filtering). + public abstract MxCommandReply CreateDeniedReply(MxCommandRequest request); + + /// Splices denied entries back into the worker's allowed-only reply in original-index order. + public abstract MxCommandReply MergeDeniedInto(MxCommandReply reply); + } + + private sealed record SubscribeBulkConstraintPlan( + MxCommand Command, + int OriginalCount, + IReadOnlyDictionary DeniedResults, + bool HasAllowedItems) + : BulkConstraintPlan(Command, OriginalCount, HasAllowedItems) + { + public override MxCommandReply CreateDeniedReply(MxCommandRequest request) { - SessionId = request.SessionId, - CorrelationId = request.ClientCorrelationId, - Kind = request.Command.Kind, - ProtocolStatus = MxAccessGrpcMapper.Ok(), - }; - SetBulkPayload(reply, request.Command.Kind, BuildMergedBulkReply(new BulkSubscribeReply(), plan)); - return reply; - } - - private static MxCommandReply MergeDeniedBulkResults( - MxCommandReply reply, - MxCommandKind commandKind, - BulkConstraintPlan plan) - { - BulkSubscribeReply allowed = GetBulkPayload(reply, commandKind) ?? new BulkSubscribeReply(); - SetBulkPayload(reply, commandKind, BuildMergedBulkReply(allowed, plan)); - return reply; - } - - private static BulkSubscribeReply BuildMergedBulkReply( - BulkSubscribeReply allowed, - BulkConstraintPlan plan) - { - Queue allowedResults = new(allowed.Results); - BulkSubscribeReply merged = new(); - for (int index = 0; index < plan.OriginalCount; index++) - { - if (plan.DeniedResults.TryGetValue(index, out SubscribeResult? denied)) + MxCommandReply reply = new() { - merged.Results.Add(denied); - } - else if (allowedResults.TryDequeue(out SubscribeResult? allowedResult)) - { - merged.Results.Add(allowedResult); - } + SessionId = request.SessionId, + CorrelationId = request.ClientCorrelationId, + Kind = request.Command.Kind, + ProtocolStatus = MxAccessGrpcMapper.Ok(), + }; + SetPayload(reply, BuildMerged(new BulkSubscribeReply())); + return reply; } - return merged; - } + public override MxCommandReply MergeDeniedInto(MxCommandReply reply) + { + BulkSubscribeReply allowed = GetPayload(reply) ?? new BulkSubscribeReply(); + SetPayload(reply, BuildMerged(allowed)); + return reply; + } - private static BulkSubscribeReply? GetBulkPayload(MxCommandReply reply, MxCommandKind commandKind) - { - return commandKind switch + private BulkSubscribeReply BuildMerged(BulkSubscribeReply allowed) + { + Queue allowedResults = new(allowed.Results); + BulkSubscribeReply merged = new(); + for (int index = 0; index < OriginalCount; index++) + { + if (DeniedResults.TryGetValue(index, out SubscribeResult? denied)) + { + merged.Results.Add(denied); + } + else if (allowedResults.TryDequeue(out SubscribeResult? allowedResult)) + { + merged.Results.Add(allowedResult); + } + } + + return merged; + } + + private BulkSubscribeReply? GetPayload(MxCommandReply reply) => Command.Kind switch { MxCommandKind.AddItemBulk => reply.AddItemBulk, MxCommandKind.AdviseItemBulk => reply.AdviseItemBulk, MxCommandKind.SubscribeBulk => reply.SubscribeBulk, _ => null, }; - } - private static void SetBulkPayload( - MxCommandReply reply, - MxCommandKind commandKind, - BulkSubscribeReply payload) - { - switch (commandKind) + private void SetPayload(MxCommandReply reply, BulkSubscribeReply payload) { - case MxCommandKind.AddItemBulk: - reply.AddItemBulk = payload; - break; - case MxCommandKind.AdviseItemBulk: - reply.AdviseItemBulk = payload; - break; - case MxCommandKind.SubscribeBulk: - reply.SubscribeBulk = payload; - break; + switch (Command.Kind) + { + case MxCommandKind.AddItemBulk: + reply.AddItemBulk = payload; + break; + case MxCommandKind.AdviseItemBulk: + reply.AdviseItemBulk = payload; + break; + case MxCommandKind.SubscribeBulk: + reply.SubscribeBulk = payload; + break; + } } } - private sealed record BulkConstraintPlan( + private sealed record WriteBulkConstraintPlan( MxCommand Command, int OriginalCount, - IReadOnlyDictionary DeniedResults, - bool HasAllowedItems); + IReadOnlyDictionary DeniedResults, + bool HasAllowedItems) + : BulkConstraintPlan(Command, OriginalCount, HasAllowedItems) + { + public override MxCommandReply CreateDeniedReply(MxCommandRequest request) + { + MxCommandReply reply = new() + { + SessionId = request.SessionId, + CorrelationId = request.ClientCorrelationId, + Kind = request.Command.Kind, + ProtocolStatus = MxAccessGrpcMapper.Ok(), + }; + SetPayload(reply, BuildMerged(new BulkWriteReply())); + return reply; + } + + public override MxCommandReply MergeDeniedInto(MxCommandReply reply) + { + BulkWriteReply allowed = GetPayload(reply) ?? new BulkWriteReply(); + SetPayload(reply, BuildMerged(allowed)); + return reply; + } + + private BulkWriteReply BuildMerged(BulkWriteReply allowed) + { + Queue allowedResults = new(allowed.Results); + BulkWriteReply merged = new(); + for (int index = 0; index < OriginalCount; index++) + { + if (DeniedResults.TryGetValue(index, out BulkWriteResult? denied)) + { + merged.Results.Add(denied); + } + else if (allowedResults.TryDequeue(out BulkWriteResult? allowedResult)) + { + merged.Results.Add(allowedResult); + } + } + + return merged; + } + + private BulkWriteReply? GetPayload(MxCommandReply reply) => Command.Kind switch + { + MxCommandKind.WriteBulk => reply.WriteBulk, + MxCommandKind.Write2Bulk => reply.Write2Bulk, + MxCommandKind.WriteSecuredBulk => reply.WriteSecuredBulk, + MxCommandKind.WriteSecured2Bulk => reply.WriteSecured2Bulk, + _ => null, + }; + + private void SetPayload(MxCommandReply reply, BulkWriteReply payload) + { + switch (Command.Kind) + { + case MxCommandKind.WriteBulk: + reply.WriteBulk = payload; + break; + case MxCommandKind.Write2Bulk: + reply.Write2Bulk = payload; + break; + case MxCommandKind.WriteSecuredBulk: + reply.WriteSecuredBulk = payload; + break; + case MxCommandKind.WriteSecured2Bulk: + reply.WriteSecured2Bulk = payload; + break; + } + } + } + + private sealed record ReadBulkConstraintPlan( + MxCommand Command, + int OriginalCount, + IReadOnlyDictionary DeniedResults, + bool HasAllowedItems) + : BulkConstraintPlan(Command, OriginalCount, HasAllowedItems) + { + public override MxCommandReply CreateDeniedReply(MxCommandRequest request) + { + MxCommandReply reply = new() + { + SessionId = request.SessionId, + CorrelationId = request.ClientCorrelationId, + Kind = request.Command.Kind, + ProtocolStatus = MxAccessGrpcMapper.Ok(), + }; + reply.ReadBulk = BuildMerged(new BulkReadReply()); + return reply; + } + + public override MxCommandReply MergeDeniedInto(MxCommandReply reply) + { + BulkReadReply allowed = reply.ReadBulk ?? new BulkReadReply(); + reply.ReadBulk = BuildMerged(allowed); + return reply; + } + + private BulkReadReply BuildMerged(BulkReadReply allowed) + { + Queue allowedResults = new(allowed.Results); + BulkReadReply merged = new(); + for (int index = 0; index < OriginalCount; index++) + { + if (DeniedResults.TryGetValue(index, out BulkReadResult? denied)) + { + merged.Results.Add(denied); + } + else if (allowedResults.TryDequeue(out BulkReadResult? allowedResult)) + { + merged.Results.Add(allowedResult); + } + } + + return merged; + } + } private RpcException MapException(Exception exception) { diff --git a/src/MxGateway.Server/Grpc/MxAccessGrpcMapper.cs b/src/ZB.MOM.WW.MxGateway.Server/Grpc/MxAccessGrpcMapper.cs similarity index 98% rename from src/MxGateway.Server/Grpc/MxAccessGrpcMapper.cs rename to src/ZB.MOM.WW.MxGateway.Server/Grpc/MxAccessGrpcMapper.cs index ff606e5..6e7e354 100644 --- a/src/MxGateway.Server/Grpc/MxAccessGrpcMapper.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Grpc/MxAccessGrpcMapper.cs @@ -1,7 +1,7 @@ using Google.Protobuf.WellKnownTypes; -using MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Contracts.Proto; -namespace MxGateway.Server.Grpc; +namespace ZB.MOM.WW.MxGateway.Server.Grpc; /// /// Maps between worker IPC types and gRPC contract types. diff --git a/src/MxGateway.Server/Grpc/MxAccessGrpcRequestValidator.cs b/src/ZB.MOM.WW.MxGateway.Server/Grpc/MxAccessGrpcRequestValidator.cs similarity index 91% rename from src/MxGateway.Server/Grpc/MxAccessGrpcRequestValidator.cs rename to src/ZB.MOM.WW.MxGateway.Server/Grpc/MxAccessGrpcRequestValidator.cs index ee81dc2..5de7766 100644 --- a/src/MxGateway.Server/Grpc/MxAccessGrpcRequestValidator.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Grpc/MxAccessGrpcRequestValidator.cs @@ -1,7 +1,7 @@ using Grpc.Core; -using MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Contracts.Proto; -namespace MxGateway.Server.Grpc; +namespace ZB.MOM.WW.MxGateway.Server.Grpc; public sealed class MxAccessGrpcRequestValidator { @@ -99,6 +99,11 @@ public sealed class MxAccessGrpcRequestValidator MxCommandKind.UnAdviseItemBulk => MxCommand.PayloadOneofCase.UnAdviseItemBulk, MxCommandKind.SubscribeBulk => MxCommand.PayloadOneofCase.SubscribeBulk, MxCommandKind.UnsubscribeBulk => MxCommand.PayloadOneofCase.UnsubscribeBulk, + MxCommandKind.WriteBulk => MxCommand.PayloadOneofCase.WriteBulk, + MxCommandKind.Write2Bulk => MxCommand.PayloadOneofCase.Write2Bulk, + MxCommandKind.WriteSecuredBulk => MxCommand.PayloadOneofCase.WriteSecuredBulk, + MxCommandKind.WriteSecured2Bulk => MxCommand.PayloadOneofCase.WriteSecured2Bulk, + MxCommandKind.ReadBulk => MxCommand.PayloadOneofCase.ReadBulk, MxCommandKind.Ping => MxCommand.PayloadOneofCase.Ping, MxCommandKind.GetSessionState => MxCommand.PayloadOneofCase.GetSessionState, MxCommandKind.GetWorkerInfo => MxCommand.PayloadOneofCase.GetWorkerInfo, diff --git a/src/MxGateway.Server/Metrics/GatewayMetrics.cs b/src/ZB.MOM.WW.MxGateway.Server/Metrics/GatewayMetrics.cs similarity index 99% rename from src/MxGateway.Server/Metrics/GatewayMetrics.cs rename to src/ZB.MOM.WW.MxGateway.Server/Metrics/GatewayMetrics.cs index 05145c0..eadd0ce 100644 --- a/src/MxGateway.Server/Metrics/GatewayMetrics.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Metrics/GatewayMetrics.cs @@ -1,7 +1,7 @@ using System.Collections.Concurrent; using System.Diagnostics.Metrics; -namespace MxGateway.Server.Metrics; +namespace ZB.MOM.WW.MxGateway.Server.Metrics; public sealed class GatewayMetrics : IDisposable { diff --git a/src/MxGateway.Server/Metrics/GatewayMetricsSnapshot.cs b/src/ZB.MOM.WW.MxGateway.Server/Metrics/GatewayMetricsSnapshot.cs similarity index 93% rename from src/MxGateway.Server/Metrics/GatewayMetricsSnapshot.cs rename to src/ZB.MOM.WW.MxGateway.Server/Metrics/GatewayMetricsSnapshot.cs index b537a58..c96f570 100644 --- a/src/MxGateway.Server/Metrics/GatewayMetricsSnapshot.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Metrics/GatewayMetricsSnapshot.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Metrics; +namespace ZB.MOM.WW.MxGateway.Server.Metrics; public sealed record GatewayMetricsSnapshot( int OpenSessions, diff --git a/src/MxGateway.Server/Program.cs b/src/ZB.MOM.WW.MxGateway.Server/Program.cs similarity index 90% rename from src/MxGateway.Server/Program.cs rename to src/ZB.MOM.WW.MxGateway.Server/Program.cs index 609f6c4..6b5dcaa 100644 --- a/src/MxGateway.Server/Program.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Program.cs @@ -1,6 +1,6 @@ -using MxGateway.Server; -using MxGateway.Server.Configuration; -using MxGateway.Server.Security.Authentication; +using ZB.MOM.WW.MxGateway.Server; +using ZB.MOM.WW.MxGateway.Server.Configuration; +using ZB.MOM.WW.MxGateway.Server.Security.Authentication; ApiKeyAdminParseResult apiKeyAdminCommand = ApiKeyAdminCommandLineParser.Parse(args); if (apiKeyAdminCommand.IsApiKeyCommand) diff --git a/src/ZB.MOM.WW.MxGateway.Server/Properties/AssemblyInfo.cs b/src/ZB.MOM.WW.MxGateway.Server/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..6e2def0 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Properties/AssemblyInfo.cs @@ -0,0 +1,4 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("ZB.MOM.WW.MxGateway.Tests")] +[assembly: InternalsVisibleTo("ZB.MOM.WW.MxGateway.IntegrationTests")] diff --git a/src/MxGateway.Server/Properties/launchSettings.json b/src/ZB.MOM.WW.MxGateway.Server/Properties/launchSettings.json similarity index 100% rename from src/MxGateway.Server/Properties/launchSettings.json rename to src/ZB.MOM.WW.MxGateway.Server/Properties/launchSettings.json diff --git a/src/MxGateway.Server/Security/Authentication/ApiKeyAdminCliRunner.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyAdminCliRunner.cs similarity index 99% rename from src/MxGateway.Server/Security/Authentication/ApiKeyAdminCliRunner.cs rename to src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyAdminCliRunner.cs index 3fa4bba..f2c99af 100644 --- a/src/MxGateway.Server/Security/Authentication/ApiKeyAdminCliRunner.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyAdminCliRunner.cs @@ -1,6 +1,6 @@ using System.Text.Json; -namespace MxGateway.Server.Security.Authentication; +namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication; /// /// Executes API key administration commands from the CLI. diff --git a/src/MxGateway.Server/Security/Authentication/ApiKeyAdminCommand.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyAdminCommand.cs similarity index 79% rename from src/MxGateway.Server/Security/Authentication/ApiKeyAdminCommand.cs rename to src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyAdminCommand.cs index 4369591..b2d5105 100644 --- a/src/MxGateway.Server/Security/Authentication/ApiKeyAdminCommand.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyAdminCommand.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Security.Authentication; +namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication; public sealed record ApiKeyAdminCommand( ApiKeyAdminCommandKind Kind, diff --git a/src/MxGateway.Server/Security/Authentication/ApiKeyAdminCommandKind.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyAdminCommandKind.cs similarity index 63% rename from src/MxGateway.Server/Security/Authentication/ApiKeyAdminCommandKind.cs rename to src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyAdminCommandKind.cs index d18392f..bf1cf10 100644 --- a/src/MxGateway.Server/Security/Authentication/ApiKeyAdminCommandKind.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyAdminCommandKind.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Security.Authentication; +namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication; public enum ApiKeyAdminCommandKind { diff --git a/src/MxGateway.Server/Security/Authentication/ApiKeyAdminCommandLineParser.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyAdminCommandLineParser.cs similarity index 90% rename from src/MxGateway.Server/Security/Authentication/ApiKeyAdminCommandLineParser.cs rename to src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyAdminCommandLineParser.cs index 235edbb..315c0fc 100644 --- a/src/MxGateway.Server/Security/Authentication/ApiKeyAdminCommandLineParser.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyAdminCommandLineParser.cs @@ -1,4 +1,6 @@ -namespace MxGateway.Server.Security.Authentication; +using ZB.MOM.WW.MxGateway.Server.Security.Authorization; + +namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication; public static class ApiKeyAdminCommandLineParser { @@ -95,6 +97,12 @@ public static class ApiKeyAdminCommandLineParser return ApiKeyAdminParseResult.Fail(validationError); } + string? scopeError = ValidateScopes(kind, scopes); + if (scopeError is not null) + { + return ApiKeyAdminParseResult.Fail(scopeError); + } + return ApiKeyAdminParseResult.Success(new ApiKeyAdminCommand( Kind: kind, Json: json, @@ -152,6 +160,23 @@ public static class ApiKeyAdminCommandLineParser return null; } + private static string? ValidateScopes(ApiKeyAdminCommandKind kind, IReadOnlySet scopes) + { + if (kind != ApiKeyAdminCommandKind.CreateKey) + { + return null; + } + + string[] unknown = scopes.Where(scope => !GatewayScopes.IsKnown(scope)).ToArray(); + if (unknown.Length == 0) + { + return null; + } + + return $"Unknown scope(s): {string.Join(", ", unknown)}. " + + $"Valid scopes are: {string.Join(", ", GatewayScopes.All)}."; + } + private static string KindName(ApiKeyAdminCommandKind kind) { return kind switch diff --git a/src/MxGateway.Server/Security/Authentication/ApiKeyAdminListedKey.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyAdminListedKey.cs similarity index 81% rename from src/MxGateway.Server/Security/Authentication/ApiKeyAdminListedKey.cs rename to src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyAdminListedKey.cs index 286d51d..f621282 100644 --- a/src/MxGateway.Server/Security/Authentication/ApiKeyAdminListedKey.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyAdminListedKey.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Security.Authentication; +namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication; public sealed record ApiKeyAdminListedKey( string KeyId, diff --git a/src/MxGateway.Server/Security/Authentication/ApiKeyAdminOutput.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyAdminOutput.cs similarity index 70% rename from src/MxGateway.Server/Security/Authentication/ApiKeyAdminOutput.cs rename to src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyAdminOutput.cs index 3567160..36b20b1 100644 --- a/src/MxGateway.Server/Security/Authentication/ApiKeyAdminOutput.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyAdminOutput.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Security.Authentication; +namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication; public sealed record ApiKeyAdminOutput( string Command, diff --git a/src/MxGateway.Server/Security/Authentication/ApiKeyAdminParseResult.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyAdminParseResult.cs similarity index 94% rename from src/MxGateway.Server/Security/Authentication/ApiKeyAdminParseResult.cs rename to src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyAdminParseResult.cs index f4955e6..412e7b4 100644 --- a/src/MxGateway.Server/Security/Authentication/ApiKeyAdminParseResult.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyAdminParseResult.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Security.Authentication; +namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication; public sealed record ApiKeyAdminParseResult( bool IsApiKeyCommand, diff --git a/src/MxGateway.Server/Security/Authentication/ApiKeyAuditEntry.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyAuditEntry.cs similarity index 67% rename from src/MxGateway.Server/Security/Authentication/ApiKeyAuditEntry.cs rename to src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyAuditEntry.cs index 7faf191..543fe30 100644 --- a/src/MxGateway.Server/Security/Authentication/ApiKeyAuditEntry.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyAuditEntry.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Security.Authentication; +namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication; public sealed record ApiKeyAuditEntry( string? KeyId, diff --git a/src/MxGateway.Server/Security/Authentication/ApiKeyAuditRecord.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyAuditRecord.cs similarity index 74% rename from src/MxGateway.Server/Security/Authentication/ApiKeyAuditRecord.cs rename to src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyAuditRecord.cs index 7418b1d..44d3240 100644 --- a/src/MxGateway.Server/Security/Authentication/ApiKeyAuditRecord.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyAuditRecord.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Security.Authentication; +namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication; public sealed record ApiKeyAuditRecord( long AuditId, diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyConstraintSerializer.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyConstraintSerializer.cs new file mode 100644 index 0000000..47ff568 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyConstraintSerializer.cs @@ -0,0 +1,28 @@ +using System.Text.Json; + +namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication; + +public static class ApiKeyConstraintSerializer +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + WriteIndented = false, + }; + + public static string? Serialize(ApiKeyConstraints constraints) + { + ArgumentNullException.ThrowIfNull(constraints); + return constraints.IsEmpty ? null : JsonSerializer.Serialize(constraints, JsonOptions); + } + + public static ApiKeyConstraints Deserialize(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return ApiKeyConstraints.Empty; + } + + return JsonSerializer.Deserialize(json, JsonOptions) ?? ApiKeyConstraints.Empty; + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyConstraints.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyConstraints.cs new file mode 100644 index 0000000..b044d2d --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyConstraints.cs @@ -0,0 +1,43 @@ +namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication; + +public sealed record ApiKeyConstraints( + IReadOnlyList ReadSubtrees, + IReadOnlyList WriteSubtrees, + IReadOnlyList ReadTagGlobs, + IReadOnlyList WriteTagGlobs, + int? MaxWriteClassification, + IReadOnlyList BrowseSubtrees, + bool ReadAlarmOnly, + bool ReadHistorizedOnly) +{ + public static ApiKeyConstraints Empty { get; } = new( + ReadSubtrees: Array.Empty(), + WriteSubtrees: Array.Empty(), + ReadTagGlobs: Array.Empty(), + WriteTagGlobs: Array.Empty(), + MaxWriteClassification: null, + BrowseSubtrees: Array.Empty(), + ReadAlarmOnly: false, + ReadHistorizedOnly: false); + + public bool IsEmpty => + ReadSubtrees.Count == 0 + && WriteSubtrees.Count == 0 + && ReadTagGlobs.Count == 0 + && WriteTagGlobs.Count == 0 + && MaxWriteClassification is null + && BrowseSubtrees.Count == 0 + && !ReadAlarmOnly + && !ReadHistorizedOnly; + + public bool HasReadConstraints => + ReadSubtrees.Count > 0 + || ReadTagGlobs.Count > 0 + || ReadAlarmOnly + || ReadHistorizedOnly; + + public bool HasWriteConstraints => + WriteSubtrees.Count > 0 + || WriteTagGlobs.Count > 0 + || MaxWriteClassification is not null; +} diff --git a/src/MxGateway.Server/Security/Authentication/ApiKeyCreateRequest.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyCreateRequest.cs similarity index 78% rename from src/MxGateway.Server/Security/Authentication/ApiKeyCreateRequest.cs rename to src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyCreateRequest.cs index fa3d32d..2092fb5 100644 --- a/src/MxGateway.Server/Security/Authentication/ApiKeyCreateRequest.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyCreateRequest.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Security.Authentication; +namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication; public sealed record ApiKeyCreateRequest( string KeyId, diff --git a/src/MxGateway.Server/Security/Authentication/ApiKeyIdentity.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyIdentity.cs similarity index 81% rename from src/MxGateway.Server/Security/Authentication/ApiKeyIdentity.cs rename to src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyIdentity.cs index 1d1ee53..9c47278 100644 --- a/src/MxGateway.Server/Security/Authentication/ApiKeyIdentity.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyIdentity.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Security.Authentication; +namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication; public sealed record ApiKeyIdentity( string KeyId, diff --git a/src/MxGateway.Server/Security/Authentication/ApiKeyParser.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyParser.cs similarity index 96% rename from src/MxGateway.Server/Security/Authentication/ApiKeyParser.cs rename to src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyParser.cs index 80c3c55..9a60da4 100644 --- a/src/MxGateway.Server/Security/Authentication/ApiKeyParser.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyParser.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Security.Authentication; +namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication; public sealed class ApiKeyParser : IApiKeyParser { diff --git a/src/MxGateway.Server/Security/Authentication/ApiKeyPepperUnavailableException.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyPepperUnavailableException.cs similarity index 74% rename from src/MxGateway.Server/Security/Authentication/ApiKeyPepperUnavailableException.cs rename to src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyPepperUnavailableException.cs index 4dfcf43..3bf9888 100644 --- a/src/MxGateway.Server/Security/Authentication/ApiKeyPepperUnavailableException.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyPepperUnavailableException.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Security.Authentication; +namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication; public sealed class ApiKeyPepperUnavailableException(string pepperSecretName) : InvalidOperationException($"API key pepper secret '{pepperSecretName}' is not configured."); diff --git a/src/MxGateway.Server/Security/Authentication/ApiKeyRecord.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyRecord.cs similarity index 82% rename from src/MxGateway.Server/Security/Authentication/ApiKeyRecord.cs rename to src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyRecord.cs index a27ae57..abe5bff 100644 --- a/src/MxGateway.Server/Security/Authentication/ApiKeyRecord.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyRecord.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Security.Authentication; +namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication; public sealed record ApiKeyRecord( string KeyId, diff --git a/src/MxGateway.Server/Security/Authentication/ApiKeyRecordReader.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyRecordReader.cs similarity index 95% rename from src/MxGateway.Server/Security/Authentication/ApiKeyRecordReader.cs rename to src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyRecordReader.cs index a71598b..297aeec 100644 --- a/src/MxGateway.Server/Security/Authentication/ApiKeyRecordReader.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyRecordReader.cs @@ -1,6 +1,6 @@ using Microsoft.Data.Sqlite; -namespace MxGateway.Server.Security.Authentication; +namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication; /// Reads API key records from SQLite query results. public static class ApiKeyRecordReader diff --git a/src/MxGateway.Server/Security/Authentication/ApiKeyScopeSerializer.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyScopeSerializer.cs similarity index 93% rename from src/MxGateway.Server/Security/Authentication/ApiKeyScopeSerializer.cs rename to src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyScopeSerializer.cs index cc23d95..d098833 100644 --- a/src/MxGateway.Server/Security/Authentication/ApiKeyScopeSerializer.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyScopeSerializer.cs @@ -1,6 +1,6 @@ using System.Text.Json; -namespace MxGateway.Server.Security.Authentication; +namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication; public static class ApiKeyScopeSerializer { diff --git a/src/MxGateway.Server/Security/Authentication/ApiKeySecretGenerator.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeySecretGenerator.cs similarity index 89% rename from src/MxGateway.Server/Security/Authentication/ApiKeySecretGenerator.cs rename to src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeySecretGenerator.cs index c53fa62..70ac7d6 100644 --- a/src/MxGateway.Server/Security/Authentication/ApiKeySecretGenerator.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeySecretGenerator.cs @@ -1,6 +1,6 @@ using System.Security.Cryptography; -namespace MxGateway.Server.Security.Authentication; +namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication; /// Generates cryptographically secure API key secrets. public static class ApiKeySecretGenerator diff --git a/src/MxGateway.Server/Security/Authentication/ApiKeySecretHasher.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeySecretHasher.cs similarity index 90% rename from src/MxGateway.Server/Security/Authentication/ApiKeySecretHasher.cs rename to src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeySecretHasher.cs index 9734003..a04cf62 100644 --- a/src/MxGateway.Server/Security/Authentication/ApiKeySecretHasher.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeySecretHasher.cs @@ -1,9 +1,9 @@ using System.Security.Cryptography; using System.Text; using Microsoft.Extensions.Options; -using MxGateway.Server.Configuration; +using ZB.MOM.WW.MxGateway.Server.Configuration; -namespace MxGateway.Server.Security.Authentication; +namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication; public sealed class ApiKeySecretHasher( IConfiguration configuration, diff --git a/src/MxGateway.Server/Security/Authentication/ApiKeyVerificationFailure.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyVerificationFailure.cs similarity index 72% rename from src/MxGateway.Server/Security/Authentication/ApiKeyVerificationFailure.cs rename to src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyVerificationFailure.cs index c5b07ea..27b12d4 100644 --- a/src/MxGateway.Server/Security/Authentication/ApiKeyVerificationFailure.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyVerificationFailure.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Security.Authentication; +namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication; public enum ApiKeyVerificationFailure { diff --git a/src/MxGateway.Server/Security/Authentication/ApiKeyVerificationResult.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyVerificationResult.cs similarity index 94% rename from src/MxGateway.Server/Security/Authentication/ApiKeyVerificationResult.cs rename to src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyVerificationResult.cs index 7b8a8e0..4c1d469 100644 --- a/src/MxGateway.Server/Security/Authentication/ApiKeyVerificationResult.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyVerificationResult.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Security.Authentication; +namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication; public sealed record ApiKeyVerificationResult( bool Succeeded, diff --git a/src/MxGateway.Server/Security/Authentication/ApiKeyVerifier.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyVerifier.cs similarity index 97% rename from src/MxGateway.Server/Security/Authentication/ApiKeyVerifier.cs rename to src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyVerifier.cs index 2f0755d..3344e3d 100644 --- a/src/MxGateway.Server/Security/Authentication/ApiKeyVerifier.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyVerifier.cs @@ -1,6 +1,6 @@ using System.Security.Cryptography; -namespace MxGateway.Server.Security.Authentication; +namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication; public sealed class ApiKeyVerifier( IApiKeyParser parser, diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/AuthSqliteConnectionFactory.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/AuthSqliteConnectionFactory.cs new file mode 100644 index 0000000..3a875a3 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/AuthSqliteConnectionFactory.cs @@ -0,0 +1,78 @@ +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Options; +using ZB.MOM.WW.MxGateway.Server.Configuration; + +namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication; + +/// +/// Factory for creating SQLite connections to the authentication store. +/// +public sealed class AuthSqliteConnectionFactory(IOptions options) +{ + /// + /// Busy timeout applied to every auth-store connection. SQLite retries a busy + /// database for this long before surfacing SQLITE_BUSY, so the concurrent + /// MarkKeyUsedAsync / audit-append writers degrade gracefully under load + /// instead of failing the request path. + /// + private static readonly TimeSpan BusyTimeout = TimeSpan.FromSeconds(5); + + /// + /// Creates an unopened SQLite connection to the auth database. Prefer + /// , which also applies WAL journaling and the + /// busy timeout. + /// + public SqliteConnection CreateConnection() + { + string sqlitePath = options.Value.Authentication.SqlitePath; + string? directory = Path.GetDirectoryName(sqlitePath); + + if (!string.IsNullOrWhiteSpace(directory)) + { + Directory.CreateDirectory(directory); + } + + SqliteConnectionStringBuilder builder = new() + { + DataSource = sqlitePath, + Mode = SqliteOpenMode.ReadWriteCreate, + Pooling = true, + DefaultTimeout = (int)BusyTimeout.TotalSeconds, + }; + + return new SqliteConnection(builder.ToString()); + } + + /// + /// Creates a SQLite connection, opens it, and configures WAL journaling and a + /// non-zero busy timeout so concurrent readers and writers degrade gracefully + /// rather than surfacing SQLITE_BUSY as a hard failure. + /// + public async Task OpenConnectionAsync(CancellationToken cancellationToken) + { + SqliteConnection connection = CreateConnection(); + try + { + await connection.OpenAsync(cancellationToken).ConfigureAwait(false); + await ConfigureConnectionAsync(connection, cancellationToken).ConfigureAwait(false); + return connection; + } + catch + { + await connection.DisposeAsync().ConfigureAwait(false); + throw; + } + } + + private static async Task ConfigureConnectionAsync( + SqliteConnection connection, + CancellationToken cancellationToken) + { + // WAL is a persistent, database-level setting; re-applying it per connection + // is cheap and a no-op once set. busy_timeout is per-connection state. + await using SqliteCommand command = connection.CreateCommand(); + command.CommandText = + $"PRAGMA journal_mode=WAL; PRAGMA busy_timeout={(int)BusyTimeout.TotalMilliseconds};"; + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } +} diff --git a/src/MxGateway.Server/Security/Authentication/AuthStoreMigrationException.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/AuthStoreMigrationException.cs similarity index 62% rename from src/MxGateway.Server/Security/Authentication/AuthStoreMigrationException.cs rename to src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/AuthStoreMigrationException.cs index 9e9ac0e..303cdc2 100644 --- a/src/MxGateway.Server/Security/Authentication/AuthStoreMigrationException.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/AuthStoreMigrationException.cs @@ -1,3 +1,3 @@ -namespace MxGateway.Server.Security.Authentication; +namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication; public sealed class AuthStoreMigrationException(string message) : InvalidOperationException(message); diff --git a/src/MxGateway.Server/Security/Authentication/AuthStoreMigrationHostedService.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/AuthStoreMigrationHostedService.cs similarity index 88% rename from src/MxGateway.Server/Security/Authentication/AuthStoreMigrationHostedService.cs rename to src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/AuthStoreMigrationHostedService.cs index 76af995..abc0dd4 100644 --- a/src/MxGateway.Server/Security/Authentication/AuthStoreMigrationHostedService.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/AuthStoreMigrationHostedService.cs @@ -1,7 +1,7 @@ using Microsoft.Extensions.Options; -using MxGateway.Server.Configuration; +using ZB.MOM.WW.MxGateway.Server.Configuration; -namespace MxGateway.Server.Security.Authentication; +namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication; /// /// Hosted service that runs authentication store migrations on startup. diff --git a/src/MxGateway.Server/Security/Authentication/AuthStoreServiceCollectionExtensions.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/AuthStoreServiceCollectionExtensions.cs similarity index 95% rename from src/MxGateway.Server/Security/Authentication/AuthStoreServiceCollectionExtensions.cs rename to src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/AuthStoreServiceCollectionExtensions.cs index ee46274..959caa5 100644 --- a/src/MxGateway.Server/Security/Authentication/AuthStoreServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/AuthStoreServiceCollectionExtensions.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Security.Authentication; +namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication; /// /// Extension methods for configuring the SQLite authentication store. diff --git a/src/MxGateway.Server/Security/Authentication/IApiKeyAdminStore.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IApiKeyAdminStore.cs similarity index 96% rename from src/MxGateway.Server/Security/Authentication/IApiKeyAdminStore.cs rename to src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IApiKeyAdminStore.cs index a308283..4f66e8c 100644 --- a/src/MxGateway.Server/Security/Authentication/IApiKeyAdminStore.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IApiKeyAdminStore.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Security.Authentication; +namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication; public interface IApiKeyAdminStore { diff --git a/src/MxGateway.Server/Security/Authentication/IApiKeyAuditStore.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IApiKeyAuditStore.cs similarity index 94% rename from src/MxGateway.Server/Security/Authentication/IApiKeyAuditStore.cs rename to src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IApiKeyAuditStore.cs index 588081a..c672a39 100644 --- a/src/MxGateway.Server/Security/Authentication/IApiKeyAuditStore.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IApiKeyAuditStore.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Security.Authentication; +namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication; /// /// Stores and retrieves audit events for API key operations. diff --git a/src/MxGateway.Server/Security/Authentication/IApiKeyParser.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IApiKeyParser.cs similarity index 85% rename from src/MxGateway.Server/Security/Authentication/IApiKeyParser.cs rename to src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IApiKeyParser.cs index f790a02..ed78417 100644 --- a/src/MxGateway.Server/Security/Authentication/IApiKeyParser.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IApiKeyParser.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Security.Authentication; +namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication; public interface IApiKeyParser { diff --git a/src/MxGateway.Server/Security/Authentication/IApiKeySecretHasher.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IApiKeySecretHasher.cs similarity index 78% rename from src/MxGateway.Server/Security/Authentication/IApiKeySecretHasher.cs rename to src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IApiKeySecretHasher.cs index a6652ae..3359caa 100644 --- a/src/MxGateway.Server/Security/Authentication/IApiKeySecretHasher.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IApiKeySecretHasher.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Security.Authentication; +namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication; public interface IApiKeySecretHasher { diff --git a/src/MxGateway.Server/Security/Authentication/IApiKeyStore.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IApiKeyStore.cs similarity index 95% rename from src/MxGateway.Server/Security/Authentication/IApiKeyStore.cs rename to src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IApiKeyStore.cs index a028157..5ffdd56 100644 --- a/src/MxGateway.Server/Security/Authentication/IApiKeyStore.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IApiKeyStore.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Security.Authentication; +namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication; /// Persists API keys and audit records for authentication and accounting. public interface IApiKeyStore diff --git a/src/MxGateway.Server/Security/Authentication/IApiKeyVerifier.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IApiKeyVerifier.cs similarity index 90% rename from src/MxGateway.Server/Security/Authentication/IApiKeyVerifier.cs rename to src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IApiKeyVerifier.cs index 438650c..977ca9e 100644 --- a/src/MxGateway.Server/Security/Authentication/IApiKeyVerifier.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IApiKeyVerifier.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Security.Authentication; +namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication; /// Verifies API key authorization headers and returns the authenticated identity. public interface IApiKeyVerifier diff --git a/src/MxGateway.Server/Security/Authentication/IAuthStoreMigrator.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IAuthStoreMigrator.cs similarity index 87% rename from src/MxGateway.Server/Security/Authentication/IAuthStoreMigrator.cs rename to src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IAuthStoreMigrator.cs index 52c5d64..b147175 100644 --- a/src/MxGateway.Server/Security/Authentication/IAuthStoreMigrator.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IAuthStoreMigrator.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Security.Authentication; +namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication; /// Migrates authentication storage between versions. public interface IAuthStoreMigrator diff --git a/src/MxGateway.Server/Security/Authentication/ParsedApiKey.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ParsedApiKey.cs similarity index 51% rename from src/MxGateway.Server/Security/Authentication/ParsedApiKey.cs rename to src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ParsedApiKey.cs index 5a1cc61..a32f043 100644 --- a/src/MxGateway.Server/Security/Authentication/ParsedApiKey.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ParsedApiKey.cs @@ -1,3 +1,3 @@ -namespace MxGateway.Server.Security.Authentication; +namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication; public sealed record ParsedApiKey(string KeyId, string Secret); diff --git a/src/MxGateway.Server/Security/Authentication/SqliteApiKeyAdminStore.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/SqliteApiKeyAdminStore.cs similarity index 85% rename from src/MxGateway.Server/Security/Authentication/SqliteApiKeyAdminStore.cs rename to src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/SqliteApiKeyAdminStore.cs index f36ecea..63c0234 100644 --- a/src/MxGateway.Server/Security/Authentication/SqliteApiKeyAdminStore.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/SqliteApiKeyAdminStore.cs @@ -1,6 +1,6 @@ using Microsoft.Data.Sqlite; -namespace MxGateway.Server.Security.Authentication; +namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication; /// /// SQLite-backed storage for API key administration (create, list, revoke, rotate). @@ -10,8 +10,7 @@ public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectio /// public async Task CreateAsync(ApiKeyCreateRequest request, CancellationToken cancellationToken) { - await using SqliteConnection connection = connectionFactory.CreateConnection(); - await connection.OpenAsync(cancellationToken).ConfigureAwait(false); + await using SqliteConnection connection = await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false); await using SqliteCommand command = connection.CreateCommand(); command.CommandText = """ @@ -44,8 +43,7 @@ public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectio /// public async Task> ListAsync(CancellationToken cancellationToken) { - await using SqliteConnection connection = connectionFactory.CreateConnection(); - await connection.OpenAsync(cancellationToken).ConfigureAwait(false); + await using SqliteConnection connection = await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false); await using SqliteCommand command = connection.CreateCommand(); command.CommandText = """ @@ -70,8 +68,7 @@ public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectio /// public async Task RevokeAsync(string keyId, DateTimeOffset revokedUtc, CancellationToken cancellationToken) { - await using SqliteConnection connection = connectionFactory.CreateConnection(); - await connection.OpenAsync(cancellationToken).ConfigureAwait(false); + await using SqliteConnection connection = await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false); await using SqliteCommand command = connection.CreateCommand(); command.CommandText = """ @@ -94,8 +91,7 @@ public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectio DateTimeOffset rotatedUtc, CancellationToken cancellationToken) { - await using SqliteConnection connection = connectionFactory.CreateConnection(); - await connection.OpenAsync(cancellationToken).ConfigureAwait(false); + await using SqliteConnection connection = await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false); await using SqliteCommand command = connection.CreateCommand(); command.CommandText = """ diff --git a/src/MxGateway.Server/Security/Authentication/SqliteApiKeyAuditStore.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/SqliteApiKeyAuditStore.cs similarity index 86% rename from src/MxGateway.Server/Security/Authentication/SqliteApiKeyAuditStore.cs rename to src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/SqliteApiKeyAuditStore.cs index 8e30aba..ee2fef4 100644 --- a/src/MxGateway.Server/Security/Authentication/SqliteApiKeyAuditStore.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/SqliteApiKeyAuditStore.cs @@ -1,14 +1,13 @@ using Microsoft.Data.Sqlite; -namespace MxGateway.Server.Security.Authentication; +namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication; public sealed class SqliteApiKeyAuditStore(AuthSqliteConnectionFactory connectionFactory) : IApiKeyAuditStore { /// public async Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken cancellationToken) { - await using SqliteConnection connection = connectionFactory.CreateConnection(); - await connection.OpenAsync(cancellationToken).ConfigureAwait(false); + await using SqliteConnection connection = await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false); await using SqliteCommand command = connection.CreateCommand(); command.CommandText = """ @@ -32,8 +31,7 @@ public sealed class SqliteApiKeyAuditStore(AuthSqliteConnectionFactory connectio return []; } - await using SqliteConnection connection = connectionFactory.CreateConnection(); - await connection.OpenAsync(cancellationToken).ConfigureAwait(false); + await using SqliteConnection connection = await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false); await using SqliteCommand command = connection.CreateCommand(); command.CommandText = """ diff --git a/src/MxGateway.Server/Security/Authentication/SqliteApiKeyStore.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/SqliteApiKeyStore.cs similarity index 86% rename from src/MxGateway.Server/Security/Authentication/SqliteApiKeyStore.cs rename to src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/SqliteApiKeyStore.cs index 102386a..a8ecf50 100644 --- a/src/MxGateway.Server/Security/Authentication/SqliteApiKeyStore.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/SqliteApiKeyStore.cs @@ -1,6 +1,6 @@ using Microsoft.Data.Sqlite; -namespace MxGateway.Server.Security.Authentication; +namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication; /// SQLite-based store for API key records. public sealed class SqliteApiKeyStore(AuthSqliteConnectionFactory connectionFactory) : IApiKeyStore @@ -20,8 +20,7 @@ public sealed class SqliteApiKeyStore(AuthSqliteConnectionFactory connectionFact /// public async Task MarkKeyUsedAsync(string keyId, DateTimeOffset usedUtc, CancellationToken cancellationToken) { - await using SqliteConnection connection = connectionFactory.CreateConnection(); - await connection.OpenAsync(cancellationToken).ConfigureAwait(false); + await using SqliteConnection connection = await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false); await using SqliteCommand command = connection.CreateCommand(); command.CommandText = """ @@ -40,8 +39,7 @@ public sealed class SqliteApiKeyStore(AuthSqliteConnectionFactory connectionFact bool requireActive, CancellationToken cancellationToken) { - await using SqliteConnection connection = connectionFactory.CreateConnection(); - await connection.OpenAsync(cancellationToken).ConfigureAwait(false); + await using SqliteConnection connection = await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false); await using SqliteCommand command = connection.CreateCommand(); command.CommandText = requireActive diff --git a/src/MxGateway.Server/Security/Authentication/SqliteAuthSchema.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/SqliteAuthSchema.cs similarity index 80% rename from src/MxGateway.Server/Security/Authentication/SqliteAuthSchema.cs rename to src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/SqliteAuthSchema.cs index 9b309e5..299441a 100644 --- a/src/MxGateway.Server/Security/Authentication/SqliteAuthSchema.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/SqliteAuthSchema.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Security.Authentication; +namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication; public static class SqliteAuthSchema { diff --git a/src/MxGateway.Server/Security/Authentication/SqliteAuthStoreMigrator.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/SqliteAuthStoreMigrator.cs similarity index 97% rename from src/MxGateway.Server/Security/Authentication/SqliteAuthStoreMigrator.cs rename to src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/SqliteAuthStoreMigrator.cs index 36c0637..f5a0e77 100644 --- a/src/MxGateway.Server/Security/Authentication/SqliteAuthStoreMigrator.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/SqliteAuthStoreMigrator.cs @@ -1,6 +1,6 @@ using Microsoft.Data.Sqlite; -namespace MxGateway.Server.Security.Authentication; +namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication; public sealed class SqliteAuthStoreMigrator(AuthSqliteConnectionFactory connectionFactory) : IAuthStoreMigrator { @@ -8,8 +8,7 @@ public sealed class SqliteAuthStoreMigrator(AuthSqliteConnectionFactory connecti /// Cancellation token. public async Task MigrateAsync(CancellationToken cancellationToken) { - await using SqliteConnection connection = connectionFactory.CreateConnection(); - await connection.OpenAsync(cancellationToken).ConfigureAwait(false); + await using SqliteConnection connection = await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false); await using SqliteTransaction transaction = (SqliteTransaction)await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/ConstraintEnforcer.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/ConstraintEnforcer.cs new file mode 100644 index 0000000..bce0752 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/ConstraintEnforcer.cs @@ -0,0 +1,165 @@ +using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; +using ZB.MOM.WW.MxGateway.Server.Galaxy; +using ZB.MOM.WW.MxGateway.Server.Security.Authentication; +using ZB.MOM.WW.MxGateway.Server.Sessions; + +namespace ZB.MOM.WW.MxGateway.Server.Security.Authorization; + +public sealed class ConstraintEnforcer( + IGalaxyHierarchyCache cache, + IApiKeyAuditStore auditStore) : IConstraintEnforcer +{ + public Task CheckReadTagAsync( + ApiKeyIdentity? identity, + string tagAddress, + CancellationToken cancellationToken) + { + ApiKeyConstraints constraints = identity?.EffectiveConstraints ?? ApiKeyConstraints.Empty; + if (!constraints.HasReadConstraints) + { + return Task.FromResult(null); + } + + return Task.FromResult(CheckReadTarget(constraints, tagAddress)); + } + + public Task CheckReadHandleAsync( + ApiKeyIdentity? identity, + GatewaySession session, + int serverHandle, + int itemHandle, + CancellationToken cancellationToken) + { + ApiKeyConstraints constraints = identity?.EffectiveConstraints ?? ApiKeyConstraints.Empty; + if (!constraints.HasReadConstraints) + { + return Task.FromResult(null); + } + + if (!session.TryGetItemRegistration(serverHandle, itemHandle, out SessionItemRegistration registration)) + { + return Task.FromResult(new ConstraintFailure("item_handle", "Item handle is not registered in the constrained session.")); + } + + return Task.FromResult(CheckReadTarget(constraints, registration.TagAddress)); + } + + public Task CheckWriteHandleAsync( + ApiKeyIdentity? identity, + GatewaySession session, + int serverHandle, + int itemHandle, + CancellationToken cancellationToken) + { + ApiKeyConstraints constraints = identity?.EffectiveConstraints ?? ApiKeyConstraints.Empty; + if (!constraints.HasWriteConstraints) + { + return Task.FromResult(null); + } + + if (!session.TryGetItemRegistration(serverHandle, itemHandle, out SessionItemRegistration registration)) + { + return Task.FromResult(new ConstraintFailure("item_handle", "Item handle is not registered in the constrained session.")); + } + + GalaxyTagLookup? target = ResolveTarget(registration.TagAddress); + if (target is null) + { + return Task.FromResult(new ConstraintFailure("tag_metadata", "Tag metadata is not available in the Galaxy hierarchy cache.")); + } + + if (!MatchesPathOrTag(target.ContainedPath, registration.TagAddress, constraints.WriteSubtrees, constraints.WriteTagGlobs)) + { + return Task.FromResult(new ConstraintFailure("write_scope", "Tag is outside the API key write scope.")); + } + + if (constraints.MaxWriteClassification is { } maxClassification) + { + GalaxyAttribute? attribute = target.Attribute; + if (attribute is null) + { + return Task.FromResult(new ConstraintFailure("max_write_classification", "Attribute security classification is not available.")); + } + + if (attribute.SecurityClassification > maxClassification) + { + return Task.FromResult(new ConstraintFailure( + "max_write_classification", + $"Attribute security classification {attribute.SecurityClassification} exceeds allowed maximum {maxClassification}.")); + } + } + + return Task.FromResult(null); + } + + public async Task RecordDenialAsync( + ApiKeyIdentity? identity, + string commandKind, + string target, + ConstraintFailure failure, + CancellationToken cancellationToken) + { + await auditStore.AppendAsync( + new ApiKeyAuditEntry( + KeyId: identity?.KeyId, + EventType: "constraint-denied", + RemoteAddress: null, + Details: $"{commandKind}: {target}: {failure.ConstraintName}: {failure.Message}"), + cancellationToken) + .ConfigureAwait(false); + } + + private ConstraintFailure? CheckReadTarget( + ApiKeyConstraints constraints, + string tagAddress) + { + GalaxyTagLookup? target = ResolveTarget(tagAddress); + if (target is null) + { + return new ConstraintFailure("tag_metadata", "Tag metadata is not available in the Galaxy hierarchy cache."); + } + + if (!MatchesPathOrTag(target.ContainedPath, tagAddress, constraints.ReadSubtrees, constraints.ReadTagGlobs)) + { + return new ConstraintFailure("read_scope", "Tag is outside the API key read scope."); + } + + if (constraints.ReadAlarmOnly && target.Attribute is not { IsAlarm: true }) + { + return new ConstraintFailure("read_alarm_only", "Tag is not an alarm-bearing attribute."); + } + + if (constraints.ReadHistorizedOnly && target.Attribute is not { IsHistorized: true }) + { + return new ConstraintFailure("read_historized_only", "Tag is not a historized attribute."); + } + + return null; + } + + private GalaxyTagLookup? ResolveTarget(string tagAddress) + { + GalaxyHierarchyCacheEntry entry = cache.Current; + return !string.IsNullOrWhiteSpace(tagAddress) + && entry.Index.TagsByAddress.TryGetValue(tagAddress, out GalaxyTagLookup? lookup) + ? lookup + : null; + } + + private static bool MatchesPathOrTag( + string containedPath, + string tagAddress, + IReadOnlyList subtreeGlobs, + IReadOnlyList tagGlobs) + { + bool hasSubtreeConstraint = subtreeGlobs.Count > 0; + bool hasTagConstraint = tagGlobs.Count > 0; + if (!hasSubtreeConstraint && !hasTagConstraint) + { + return true; + } + + return subtreeGlobs.Any(glob => GalaxyGlobMatcher.IsMatch(containedPath, glob)) + || tagGlobs.Any(glob => GalaxyGlobMatcher.IsMatch(tagAddress, glob)); + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/ConstraintFailure.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/ConstraintFailure.cs new file mode 100644 index 0000000..eef4a00 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/ConstraintFailure.cs @@ -0,0 +1,3 @@ +namespace ZB.MOM.WW.MxGateway.Server.Security.Authorization; + +public sealed record ConstraintFailure(string ConstraintName, string Message); diff --git a/src/MxGateway.Server/Security/Authorization/GatewayGrpcAuthorizationInterceptor.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/GatewayGrpcAuthorizationInterceptor.cs similarity index 94% rename from src/MxGateway.Server/Security/Authorization/GatewayGrpcAuthorizationInterceptor.cs rename to src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/GatewayGrpcAuthorizationInterceptor.cs index 5f95f12..ad98a30 100644 --- a/src/MxGateway.Server/Security/Authorization/GatewayGrpcAuthorizationInterceptor.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/GatewayGrpcAuthorizationInterceptor.cs @@ -1,10 +1,10 @@ using Grpc.Core; using Grpc.Core.Interceptors; using Microsoft.Extensions.Options; -using MxGateway.Server.Configuration; -using MxGateway.Server.Security.Authentication; +using ZB.MOM.WW.MxGateway.Server.Configuration; +using ZB.MOM.WW.MxGateway.Server.Security.Authentication; -namespace MxGateway.Server.Security.Authorization; +namespace ZB.MOM.WW.MxGateway.Server.Security.Authorization; public sealed class GatewayGrpcAuthorizationInterceptor( IApiKeyVerifier apiKeyVerifier, diff --git a/src/MxGateway.Server/Security/Authorization/GatewayGrpcScopeResolver.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/GatewayGrpcScopeResolver.cs similarity index 75% rename from src/MxGateway.Server/Security/Authorization/GatewayGrpcScopeResolver.cs rename to src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/GatewayGrpcScopeResolver.cs index 6c44e48..2ec1f2a 100644 --- a/src/MxGateway.Server/Security/Authorization/GatewayGrpcScopeResolver.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/GatewayGrpcScopeResolver.cs @@ -1,7 +1,7 @@ -using MxGateway.Contracts.Proto; -using MxGateway.Contracts.Proto.Galaxy; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; -namespace MxGateway.Server.Security.Authorization; +namespace ZB.MOM.WW.MxGateway.Server.Security.Authorization; public sealed class GatewayGrpcScopeResolver { @@ -18,6 +18,8 @@ public sealed class GatewayGrpcScopeResolver CloseSessionRequest => GatewayScopes.SessionClose, StreamEventsRequest => GatewayScopes.EventsRead, MxCommandRequest commandRequest => ResolveCommandScope(commandRequest.Command?.Kind ?? MxCommandKind.Unspecified), + AcknowledgeAlarmRequest => GatewayScopes.InvokeWrite, + StreamAlarmsRequest => GatewayScopes.EventsRead, TestConnectionRequest or GetLastDeployTimeRequest or DiscoverHierarchyRequest or @@ -31,10 +33,14 @@ public sealed class GatewayGrpcScopeResolver return kind switch { MxCommandKind.Write or - MxCommandKind.Write2 => GatewayScopes.InvokeWrite, + MxCommandKind.Write2 or + MxCommandKind.WriteBulk or + MxCommandKind.Write2Bulk => GatewayScopes.InvokeWrite, MxCommandKind.WriteSecured or MxCommandKind.WriteSecured2 or + MxCommandKind.WriteSecuredBulk or + MxCommandKind.WriteSecured2Bulk or MxCommandKind.AuthenticateUser => GatewayScopes.InvokeSecure, MxCommandKind.ArchestraUserToId or diff --git a/src/MxGateway.Server/Security/Authorization/GatewayRequestIdentityAccessor.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/GatewayRequestIdentityAccessor.cs similarity index 91% rename from src/MxGateway.Server/Security/Authorization/GatewayRequestIdentityAccessor.cs rename to src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/GatewayRequestIdentityAccessor.cs index 8388446..8727deb 100644 --- a/src/MxGateway.Server/Security/Authorization/GatewayRequestIdentityAccessor.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/GatewayRequestIdentityAccessor.cs @@ -1,6 +1,6 @@ -using MxGateway.Server.Security.Authentication; +using ZB.MOM.WW.MxGateway.Server.Security.Authentication; -namespace MxGateway.Server.Security.Authorization; +namespace ZB.MOM.WW.MxGateway.Server.Security.Authorization; public sealed class GatewayRequestIdentityAccessor : IGatewayRequestIdentityAccessor { diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/GatewayScopes.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/GatewayScopes.cs new file mode 100644 index 0000000..9c010e3 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/GatewayScopes.cs @@ -0,0 +1,37 @@ +namespace ZB.MOM.WW.MxGateway.Server.Security.Authorization; + +public static class GatewayScopes +{ + public const string SessionOpen = "session:open"; + public const string SessionClose = "session:close"; + public const string InvokeRead = "invoke:read"; + public const string InvokeWrite = "invoke:write"; + public const string InvokeSecure = "invoke:secure"; + public const string EventsRead = "events:read"; + public const string MetadataRead = "metadata:read"; + public const string Admin = "admin"; + + /// + /// The complete catalog of canonical scope strings the gateway authorization + /// resolver recognizes. Key-creation paths (CLI and dashboard) validate requested + /// scopes against this set so a typo or non-canonical name cannot persist a key + /// whose scope strings the resolver never matches. + /// + public static readonly IReadOnlySet All = new HashSet( + [ + SessionOpen, + SessionClose, + InvokeRead, + InvokeWrite, + InvokeSecure, + EventsRead, + MetadataRead, + Admin, + ], + System.StringComparer.Ordinal); + + /// Determines whether the supplied scope string is a recognized canonical scope. + /// Scope string to check. + /// when the scope is canonical; otherwise . + public static bool IsKnown(string scope) => All.Contains(scope); +} diff --git a/src/MxGateway.Server/Security/Authorization/GrpcAuthorizationServiceCollectionExtensions.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/GrpcAuthorizationServiceCollectionExtensions.cs similarity index 93% rename from src/MxGateway.Server/Security/Authorization/GrpcAuthorizationServiceCollectionExtensions.cs rename to src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/GrpcAuthorizationServiceCollectionExtensions.cs index 0441e3c..f174fc0 100644 --- a/src/MxGateway.Server/Security/Authorization/GrpcAuthorizationServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/GrpcAuthorizationServiceCollectionExtensions.cs @@ -1,8 +1,8 @@ using Grpc.Core.Interceptors; using Microsoft.Extensions.Configuration; -using MxGateway.Server.Configuration; +using ZB.MOM.WW.MxGateway.Server.Configuration; -namespace MxGateway.Server.Security.Authorization; +namespace ZB.MOM.WW.MxGateway.Server.Security.Authorization; /// /// Extension methods for configuring gRPC authorization services. diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/IConstraintEnforcer.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/IConstraintEnforcer.cs new file mode 100644 index 0000000..221b31a --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/IConstraintEnforcer.cs @@ -0,0 +1,33 @@ +using ZB.MOM.WW.MxGateway.Server.Security.Authentication; +using ZB.MOM.WW.MxGateway.Server.Sessions; + +namespace ZB.MOM.WW.MxGateway.Server.Security.Authorization; + +public interface IConstraintEnforcer +{ + Task CheckReadTagAsync( + ApiKeyIdentity? identity, + string tagAddress, + CancellationToken cancellationToken); + + Task CheckReadHandleAsync( + ApiKeyIdentity? identity, + GatewaySession session, + int serverHandle, + int itemHandle, + CancellationToken cancellationToken); + + Task CheckWriteHandleAsync( + ApiKeyIdentity? identity, + GatewaySession session, + int serverHandle, + int itemHandle, + CancellationToken cancellationToken); + + Task RecordDenialAsync( + ApiKeyIdentity? identity, + string commandKind, + string target, + ConstraintFailure failure, + CancellationToken cancellationToken); +} diff --git a/src/MxGateway.Server/Security/Authorization/IGatewayRequestIdentityAccessor.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/IGatewayRequestIdentityAccessor.cs similarity index 82% rename from src/MxGateway.Server/Security/Authorization/IGatewayRequestIdentityAccessor.cs rename to src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/IGatewayRequestIdentityAccessor.cs index bdbee5e..c6fe5f0 100644 --- a/src/MxGateway.Server/Security/Authorization/IGatewayRequestIdentityAccessor.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/IGatewayRequestIdentityAccessor.cs @@ -1,6 +1,6 @@ -using MxGateway.Server.Security.Authentication; +using ZB.MOM.WW.MxGateway.Server.Security.Authentication; -namespace MxGateway.Server.Security.Authorization; +namespace ZB.MOM.WW.MxGateway.Server.Security.Authorization; /// Provides scoped access to the current request's API key identity within a gRPC call context. public interface IGatewayRequestIdentityAccessor diff --git a/src/MxGateway.Server/Sessions/GatewaySession.cs b/src/ZB.MOM.WW.MxGateway.Server/Sessions/GatewaySession.cs similarity index 70% rename from src/MxGateway.Server/Sessions/GatewaySession.cs rename to src/ZB.MOM.WW.MxGateway.Server/Sessions/GatewaySession.cs index ab23387..521705c 100644 --- a/src/MxGateway.Server/Sessions/GatewaySession.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Sessions/GatewaySession.cs @@ -1,7 +1,7 @@ -using MxGateway.Contracts.Proto; -using MxGateway.Server.Workers; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Server.Workers; -namespace MxGateway.Server.Sessions; +namespace ZB.MOM.WW.MxGateway.Server.Sessions; public sealed class GatewaySession { @@ -263,6 +263,17 @@ public sealed class GatewaySession /// Transitions the session to a new state with constraints for terminal states. /// /// Next session state to transition to. + /// + /// is terminal. + /// only allows a transition to . + /// only allows a transition to + /// (or ) — once + /// has started, no late lifecycle callback can revive the + /// session by walking it back to or any earlier + /// state. Both close-related writes (Closing and Closed) go through + /// _syncRoot just like every other state read/write, closing the split-lock + /// race called out in Server-015. + /// public void TransitionTo(SessionState nextState) { lock (_syncRoot) @@ -277,6 +288,13 @@ public sealed class GatewaySession return; } + if (_state is SessionState.Closing + && nextState is not SessionState.Closed + && nextState is not SessionState.Faulted) + { + return; + } + _state = nextState; } } @@ -590,6 +608,116 @@ public sealed class GatewaySession cancellationToken); } + /// + /// Executes a bulk Write command for the specified server and per-item entries. + /// + public Task> WriteBulkAsync( + int serverHandle, + IReadOnlyList entries, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(entries); + + WriteBulkCommand bulkCommand = new() { ServerHandle = serverHandle }; + bulkCommand.Entries.Add(entries); + return InvokeBulkWriteAsync( + new MxCommand + { + Kind = MxCommandKind.WriteBulk, + WriteBulk = bulkCommand, + }, + reply => reply.WriteBulk, + cancellationToken); + } + + /// Executes a bulk Write2 (timestamped) command. + public Task> Write2BulkAsync( + int serverHandle, + IReadOnlyList entries, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(entries); + + Write2BulkCommand bulkCommand = new() { ServerHandle = serverHandle }; + bulkCommand.Entries.Add(entries); + return InvokeBulkWriteAsync( + new MxCommand + { + Kind = MxCommandKind.Write2Bulk, + Write2Bulk = bulkCommand, + }, + reply => reply.Write2Bulk, + cancellationToken); + } + + /// Executes a bulk WriteSecured command. + public Task> WriteSecuredBulkAsync( + int serverHandle, + IReadOnlyList entries, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(entries); + + WriteSecuredBulkCommand bulkCommand = new() { ServerHandle = serverHandle }; + bulkCommand.Entries.Add(entries); + return InvokeBulkWriteAsync( + new MxCommand + { + Kind = MxCommandKind.WriteSecuredBulk, + WriteSecuredBulk = bulkCommand, + }, + reply => reply.WriteSecuredBulk, + cancellationToken); + } + + /// Executes a bulk WriteSecured2 command. + public Task> WriteSecured2BulkAsync( + int serverHandle, + IReadOnlyList entries, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(entries); + + WriteSecured2BulkCommand bulkCommand = new() { ServerHandle = serverHandle }; + bulkCommand.Entries.Add(entries); + return InvokeBulkWriteAsync( + new MxCommand + { + Kind = MxCommandKind.WriteSecured2Bulk, + WriteSecured2Bulk = bulkCommand, + }, + reply => reply.WriteSecured2Bulk, + cancellationToken); + } + + /// + /// Executes a bulk Read command — see ReadBulkCommand's doc + /// comment in the .proto for the cached-vs-snapshot semantics. + /// + public Task> ReadBulkAsync( + int serverHandle, + IReadOnlyList tagAddresses, + TimeSpan timeout, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(tagAddresses); + + ReadBulkCommand bulkCommand = new() + { + ServerHandle = serverHandle, + TimeoutMs = timeout <= TimeSpan.Zero ? 0u : (uint)Math.Min(timeout.TotalMilliseconds, uint.MaxValue), + }; + bulkCommand.TagAddresses.Add(tagAddresses); + return InvokeBulkReadAsync( + new MxCommand + { + Kind = MxCommandKind.ReadBulk, + ReadBulk = bulkCommand, + }, + reply => reply.ReadBulk, + cancellationToken); + } + /// /// Reads events from the worker as an asynchronous enumerable stream. /// @@ -607,6 +735,14 @@ public sealed class GatewaySession /// /// Reason for closing the session. /// Token to cancel the asynchronous operation. + /// + /// Concurrent close attempts are serialized by _closeLock so only one close + /// runs at a time, but every read/write of _state still passes through + /// _syncRoot (via and ) — + /// the close path therefore obeys the same lock discipline as + /// / and a concurrent + /// TransitionTo(Ready) cannot race past a Closing write. + /// public async Task CloseAsync( string reason, CancellationToken cancellationToken) @@ -616,15 +752,11 @@ public sealed class GatewaySession { try { - if (_state is SessionState.Closed) + if (!TryBeginClose(out bool alreadyClosing)) { return new SessionCloseResult(SessionId, SessionState.Closed, AlreadyClosed: true); } - bool alreadyClosing = _closeStarted; - _closeStarted = true; - _state = SessionState.Closing; - if (_workerClient is not null) { try @@ -648,7 +780,7 @@ public sealed class GatewaySession } } - _state = SessionState.Closed; + MarkClosed(); return new SessionCloseResult(SessionId, SessionState.Closed, alreadyClosing); } catch (Exception exception) when (exception is not SessionCloseStartedException) @@ -664,6 +796,40 @@ public sealed class GatewaySession } } + // Returns false when the session is already Closed (caller short-circuits with + // AlreadyClosed: true). Otherwise sets _state = Closing under _syncRoot so a + // concurrent TransitionTo(Ready) — which only refuses to overwrite Closed/Faulted + // — cannot flip the session back to Ready after close started. The `alreadyClosing` + // out parameter mirrors the previous `_closeStarted` check so the surface contract + // (a second concurrent close returns AlreadyClosed: alreadyClosing) is preserved. + private bool TryBeginClose(out bool alreadyClosing) + { + lock (_syncRoot) + { + if (_state is SessionState.Closed) + { + alreadyClosing = _closeStarted; + return false; + } + + alreadyClosing = _closeStarted; + _closeStarted = true; + _state = SessionState.Closing; + return true; + } + } + + // Final terminal transition; under _syncRoot to keep _state writes single-lock. + // Closed is unconditionally terminal — TransitionTo refuses to overwrite it — + // so we don't need to re-check the precondition here. + private void MarkClosed() + { + lock (_syncRoot) + { + _state = SessionState.Closed; + } + } + /// /// Terminates the worker process immediately. /// @@ -677,9 +843,47 @@ public sealed class GatewaySession /// /// Disposes the session and frees associated resources. /// + /// + /// Acquires _closeLock once before disposing so an in-flight + /// finishes before the semaphore is released and + /// reclaimed. Without this gate, the in-flight close's _closeLock.Release() + /// would race the dispose and raise . + /// The acquire is best-effort: a non-cancellable wait that swallows + /// so double-dispose still completes. + /// public async ValueTask DisposeAsync() { - _closeLock.Dispose(); + try + { + // CancellationToken.None — disposal must not be cancelled, and a misbehaving + // close path that never releases would have to be torn down by the worker + // shutdown timeout long before we reach here. + await _closeLock.WaitAsync(CancellationToken.None).ConfigureAwait(false); + try + { + // Hand the slot back so the semaphore's internal counter is consistent + // for any contemporaneous waiter, then dispose. Once disposed, every + // subsequent WaitAsync / Release will throw — but DisposeAsync's contract + // is "no concurrent close after this point", which SessionManager honors. + _closeLock.Release(); + } + catch (ObjectDisposedException) + { + } + } + catch (ObjectDisposedException) + { + // Already disposed (e.g. double-dispose); nothing to gate on. + } + + try + { + _closeLock.Dispose(); + } + catch (ObjectDisposedException) + { + } + if (_workerClient is not null) { await _workerClient.DisposeAsync().ConfigureAwait(false); @@ -690,6 +894,36 @@ public sealed class GatewaySession MxCommand command, Func payloadAccessor, CancellationToken cancellationToken) + { + MxCommandReply reply = await InvokeBulkInternalAsync(command, cancellationToken).ConfigureAwait(false); + return payloadAccessor(reply)?.Results.ToArray() ?? []; + } + + private async Task> InvokeBulkWriteAsync( + MxCommand command, + Func payloadAccessor, + CancellationToken cancellationToken) + { + MxCommandReply reply = await InvokeBulkInternalAsync(command, cancellationToken).ConfigureAwait(false); + return payloadAccessor(reply)?.Results.ToArray() ?? []; + } + + private async Task> InvokeBulkReadAsync( + MxCommand command, + Func payloadAccessor, + CancellationToken cancellationToken) + { + MxCommandReply reply = await InvokeBulkInternalAsync(command, cancellationToken).ConfigureAwait(false); + return payloadAccessor(reply)?.Results.ToArray() ?? []; + } + + // Single round-trip + protocol-status check shared by every bulk variant. + // Callers project the typed reply payload out via their own accessor — the + // outer envelope handling is identical across SubscribeResult-based bulks, + // BulkWriteResult-based writes, and BulkReadResult-based reads. + private async Task InvokeBulkInternalAsync( + MxCommand command, + CancellationToken cancellationToken) { WorkerCommandReply workerReply = await InvokeAsync( new WorkerCommand { Command = command }, @@ -712,18 +946,34 @@ public sealed class GatewaySession string.IsNullOrWhiteSpace(message) ? "Bulk MXAccess command failed." : message); } - return payloadAccessor(reply)?.Results.ToArray() ?? []; + return reply; } + /// + /// Returns the worker client iff both the gateway-side session state AND + /// the worker client's own state are / + /// . The two states can diverge under + /// load: _state only transitions on gateway-driven events (open, + /// close, fault), while can shift on + /// worker-side signals (heartbeat watchdog, pipe disconnect) before the + /// gateway's session-level reaction observes them. When that happens the + /// in-flight RPC fails fast here with both states surfaced in the + /// diagnostic (Server-030) so the actual mismatch is actionable instead + /// of misleading. The session usually transitions to Faulted + /// shortly after. + /// private IWorkerClient GetReadyWorkerClient() { lock (_syncRoot) { if (_state != SessionState.Ready || _workerClient?.State != WorkerClientState.Ready) { + string workerState = _workerClient is null + ? "" + : _workerClient.State.ToString(); throw new SessionManagerException( SessionManagerErrorCode.SessionNotReady, - $"Session {SessionId} is not ready. Current state is {_state}."); + $"Session {SessionId} is not ready. Session state is {_state}; worker state is {workerState}."); } return _workerClient; diff --git a/src/MxGateway.Server/Sessions/ISessionManager.cs b/src/ZB.MOM.WW.MxGateway.Server/Sessions/ISessionManager.cs similarity index 93% rename from src/MxGateway.Server/Sessions/ISessionManager.cs rename to src/ZB.MOM.WW.MxGateway.Server/Sessions/ISessionManager.cs index d9743f5..0c137ee 100644 --- a/src/MxGateway.Server/Sessions/ISessionManager.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Sessions/ISessionManager.cs @@ -1,6 +1,7 @@ -using MxGateway.Contracts.Proto; +using System.Diagnostics.CodeAnalysis; +using ZB.MOM.WW.MxGateway.Contracts.Proto; -namespace MxGateway.Server.Sessions; +namespace ZB.MOM.WW.MxGateway.Server.Sessions; public interface ISessionManager { @@ -20,7 +21,7 @@ public interface ISessionManager /// True if the session exists; otherwise false. bool TryGetSession( string sessionId, - out GatewaySession session); + [MaybeNullWhen(false)] out GatewaySession session); /// Invokes a command on the worker for the specified session. /// Identifier of the session. diff --git a/src/MxGateway.Server/Sessions/ISessionRegistry.cs b/src/ZB.MOM.WW.MxGateway.Server/Sessions/ISessionRegistry.cs similarity index 85% rename from src/MxGateway.Server/Sessions/ISessionRegistry.cs rename to src/ZB.MOM.WW.MxGateway.Server/Sessions/ISessionRegistry.cs index 294e03a..ab1db28 100644 --- a/src/MxGateway.Server/Sessions/ISessionRegistry.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Sessions/ISessionRegistry.cs @@ -1,4 +1,6 @@ -namespace MxGateway.Server.Sessions; +using System.Diagnostics.CodeAnalysis; + +namespace ZB.MOM.WW.MxGateway.Server.Sessions; /// /// Registry for managing active gateway sessions. @@ -28,7 +30,7 @@ public interface ISessionRegistry /// Identifier of the session. /// The retrieved session, if found. /// True if found; false otherwise. - bool TryGet(string sessionId, out GatewaySession session); + bool TryGet(string sessionId, [MaybeNullWhen(false)] out GatewaySession session); /// /// Attempts to remove a session by ID; returns false if not found. @@ -36,7 +38,7 @@ public interface ISessionRegistry /// Identifier of the session to remove. /// The removed session, if found. /// True if removed; false if not found. - bool TryRemove(string sessionId, out GatewaySession session); + bool TryRemove(string sessionId, [MaybeNullWhen(false)] out GatewaySession session); /// /// Returns a snapshot of all sessions in the registry. diff --git a/src/MxGateway.Server/Sessions/ISessionWorkerClientFactory.cs b/src/ZB.MOM.WW.MxGateway.Server/Sessions/ISessionWorkerClientFactory.cs similarity index 82% rename from src/MxGateway.Server/Sessions/ISessionWorkerClientFactory.cs rename to src/ZB.MOM.WW.MxGateway.Server/Sessions/ISessionWorkerClientFactory.cs index b47eada..7f98d38 100644 --- a/src/MxGateway.Server/Sessions/ISessionWorkerClientFactory.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Sessions/ISessionWorkerClientFactory.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Sessions; +namespace ZB.MOM.WW.MxGateway.Server.Sessions; /// /// Creates worker client instances for gateway sessions. @@ -11,7 +11,7 @@ public interface ISessionWorkerClientFactory /// Session to create a worker client for. /// Token to cancel the asynchronous operation. /// A worker client connected to the worker process. - Task CreateAsync( + Task CreateAsync( GatewaySession session, CancellationToken cancellationToken); } diff --git a/src/MxGateway.Server/Sessions/SessionCloseResult.cs b/src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionCloseResult.cs similarity index 56% rename from src/MxGateway.Server/Sessions/SessionCloseResult.cs rename to src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionCloseResult.cs index 951c788..6cab07c 100644 --- a/src/MxGateway.Server/Sessions/SessionCloseResult.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionCloseResult.cs @@ -1,6 +1,6 @@ -using MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Contracts.Proto; -namespace MxGateway.Server.Sessions; +namespace ZB.MOM.WW.MxGateway.Server.Sessions; public sealed record SessionCloseResult( string SessionId, diff --git a/src/MxGateway.Server/Sessions/SessionCloseStartedException.cs b/src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionCloseStartedException.cs similarity index 90% rename from src/MxGateway.Server/Sessions/SessionCloseStartedException.cs rename to src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionCloseStartedException.cs index 77b7e58..c943760 100644 --- a/src/MxGateway.Server/Sessions/SessionCloseStartedException.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionCloseStartedException.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Sessions; +namespace ZB.MOM.WW.MxGateway.Server.Sessions; internal sealed class SessionCloseStartedException : Exception { diff --git a/src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionItemRegistration.cs b/src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionItemRegistration.cs new file mode 100644 index 0000000..1cc7994 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionItemRegistration.cs @@ -0,0 +1,6 @@ +namespace ZB.MOM.WW.MxGateway.Server.Sessions; + +public sealed record SessionItemRegistration( + int ServerHandle, + int ItemHandle, + string TagAddress); diff --git a/src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionLeaseMonitorHostedService.cs b/src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionLeaseMonitorHostedService.cs new file mode 100644 index 0000000..12cf6aa --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionLeaseMonitorHostedService.cs @@ -0,0 +1,45 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using ZB.MOM.WW.MxGateway.Server.Configuration; + +namespace ZB.MOM.WW.MxGateway.Server.Sessions; + +public sealed class SessionLeaseMonitorHostedService( + ISessionManager sessionManager, + IOptions options, + ILogger logger, + TimeProvider? timeProvider = null) : BackgroundService +{ + private readonly TimeProvider _timeProvider = timeProvider ?? TimeProvider.System; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + TimeSpan interval = TimeSpan.FromSeconds(Math.Max(1, options.Value.Sessions.LeaseSweepIntervalSeconds)); + using PeriodicTimer timer = new(interval, _timeProvider); + + try + { + while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false)) + { + try + { + await sessionManager + .CloseExpiredLeasesAsync(_timeProvider.GetUtcNow(), stoppingToken) + .ConfigureAwait(false); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + return; + } + catch (Exception exception) + { + logger.LogWarning(exception, "Session lease sweep failed."); + } + } + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + } + } +} diff --git a/src/MxGateway.Server/Sessions/SessionManager.cs b/src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionManager.cs similarity index 76% rename from src/MxGateway.Server/Sessions/SessionManager.cs rename to src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionManager.cs index 3ea5a3f..a648762 100644 --- a/src/MxGateway.Server/Sessions/SessionManager.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionManager.cs @@ -1,15 +1,16 @@ +using System.Diagnostics.CodeAnalysis; using System.Security.Cryptography; using Google.Protobuf.WellKnownTypes; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; -using MxGateway.Contracts; -using MxGateway.Contracts.Proto; -using MxGateway.Server.Configuration; -using MxGateway.Server.Metrics; -using MxGateway.Server.Workers; +using ZB.MOM.WW.MxGateway.Contracts; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Server.Configuration; +using ZB.MOM.WW.MxGateway.Server.Metrics; +using ZB.MOM.WW.MxGateway.Server.Workers; -namespace MxGateway.Server.Sessions; +namespace ZB.MOM.WW.MxGateway.Server.Sessions; public sealed class SessionManager : ISessionManager { @@ -68,6 +69,7 @@ public sealed class SessionManager : ISessionManager EnsureSessionCapacity(); GatewaySession? session = null; + bool sessionOpenedRecorded = false; try { session = CreateSession(request, clientIdentity); @@ -86,8 +88,7 @@ public sealed class SessionManager : ISessionManager session.AttachWorkerClient(workerClient); session.MarkReady(); _metrics.SessionOpened(); - - await TryAutoSubscribeAlarmsAsync(session, cancellationToken).ConfigureAwait(false); + sessionOpenedRecorded = true; return session; } @@ -100,6 +101,14 @@ public sealed class SessionManager : ISessionManager await session.DisposeAsync().ConfigureAwait(false); } + // If SessionOpened() already incremented the open-session gauge, + // a failure after that point (e.g. auto-subscribe rejection) must + // decrement it again so mxgateway.sessions.open does not leak. + if (sessionOpenedRecorded) + { + _metrics.SessionRemoved(); + } + ReleaseSessionSlot(); _metrics.Fault(SessionManagerErrorCode.OpenFailed.ToString()); _logger.LogWarning( @@ -122,7 +131,7 @@ public sealed class SessionManager : ISessionManager /// True if session found; otherwise false. public bool TryGetSession( string sessionId, - out GatewaySession session) + [MaybeNullWhen(false)] out GatewaySession session) { return _registry.TryGet(sessionId, out session); } @@ -287,7 +296,7 @@ public sealed class SessionManager : ISessionManager private GatewaySession GetRequiredSession(string sessionId) { - if (!_registry.TryGet(sessionId, out GatewaySession session)) + if (!_registry.TryGet(sessionId, out GatewaySession? session) || session is null) { throw new SessionManagerException( SessionManagerErrorCode.SessionNotFound, @@ -399,100 +408,4 @@ public sealed class SessionManager : ISessionManager return Convert.ToBase64String(bytes); } - /// - /// If Alarms.Enabled is configured, issue a - /// SubscribeAlarmsCommand on the freshly-Ready session so the - /// worker's wnwrap consumer starts polling. Failure handling is - /// governed by Alarms.RequireSubscribeOnOpen: - /// - /// true — propagate the failure to fault the session. - /// false (default) — log a warning and let the session continue serving data subscriptions. - /// - /// - private async Task TryAutoSubscribeAlarmsAsync( - GatewaySession session, - CancellationToken cancellationToken) - { - AlarmsOptions alarms = _options.Alarms; - if (!alarms.Enabled) return; - - string subscription = ResolveAlarmSubscription(alarms); - if (string.IsNullOrWhiteSpace(subscription)) - { - const string diagnostic = - "Alarms.Enabled is true but no SubscriptionExpression / DefaultArea is configured."; - if (alarms.RequireSubscribeOnOpen) - { - throw new SessionManagerException( - SessionManagerErrorCode.OpenFailed, diagnostic); - } - _logger.LogWarning( - "Auto-subscribe skipped for session {SessionId}: {Diagnostic}", - session.SessionId, diagnostic); - return; - } - - WorkerCommand command = new WorkerCommand - { - Command = new MxCommand - { - Kind = MxCommandKind.SubscribeAlarms, - SubscribeAlarms = new SubscribeAlarmsCommand - { - SubscriptionExpression = subscription, - }, - }, - EnqueueTimestamp = Timestamp.FromDateTimeOffset(_timeProvider.GetUtcNow()), - }; - - try - { - WorkerCommandReply reply = await session.InvokeAsync(command, cancellationToken) - .ConfigureAwait(false); - ProtocolStatusCode? code = reply.Reply?.ProtocolStatus?.Code; - if (code != ProtocolStatusCode.Ok) - { - string diagnostic = reply.Reply?.DiagnosticMessage - ?? reply.Reply?.ProtocolStatus?.Message - ?? "Worker rejected SubscribeAlarms."; - if (alarms.RequireSubscribeOnOpen) - { - throw new SessionManagerException( - SessionManagerErrorCode.OpenFailed, - $"Auto-subscribe failed for session {session.SessionId}: {diagnostic}"); - } - _logger.LogWarning( - "Auto-subscribe failed for session {SessionId} (status {StatusCode}): {Diagnostic}", - session.SessionId, code, diagnostic); - return; - } - _logger.LogInformation( - "Alarm auto-subscribe succeeded for session {SessionId} on {Subscription}.", - session.SessionId, subscription); - } - catch (SessionManagerException) - { - throw; - } - catch (Exception ex) when (!alarms.RequireSubscribeOnOpen) - { - _logger.LogWarning( - ex, - "Auto-subscribe threw for session {SessionId} on {Subscription}; alarm path remains inactive.", - session.SessionId, subscription); - } - } - - private static string ResolveAlarmSubscription(AlarmsOptions alarms) - { - if (!string.IsNullOrWhiteSpace(alarms.SubscriptionExpression)) - { - return alarms.SubscriptionExpression; - } - if (!string.IsNullOrWhiteSpace(alarms.DefaultArea)) - { - return $@"\\{Environment.MachineName}\Galaxy!{alarms.DefaultArea}"; - } - return string.Empty; - } } diff --git a/src/MxGateway.Server/Sessions/SessionManagerErrorCode.cs b/src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionManagerErrorCode.cs similarity index 80% rename from src/MxGateway.Server/Sessions/SessionManagerErrorCode.cs rename to src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionManagerErrorCode.cs index f7e723c..215c9fc 100644 --- a/src/MxGateway.Server/Sessions/SessionManagerErrorCode.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionManagerErrorCode.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Sessions; +namespace ZB.MOM.WW.MxGateway.Server.Sessions; public enum SessionManagerErrorCode { diff --git a/src/MxGateway.Server/Sessions/SessionManagerException.cs b/src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionManagerException.cs similarity index 96% rename from src/MxGateway.Server/Sessions/SessionManagerException.cs rename to src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionManagerException.cs index 1bbb077..09335c8 100644 --- a/src/MxGateway.Server/Sessions/SessionManagerException.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionManagerException.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Sessions; +namespace ZB.MOM.WW.MxGateway.Server.Sessions; public sealed class SessionManagerException : Exception { diff --git a/src/MxGateway.Server/Sessions/SessionOpenRequest.cs b/src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionOpenRequest.cs similarity index 88% rename from src/MxGateway.Server/Sessions/SessionOpenRequest.cs rename to src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionOpenRequest.cs index ae62817..c48eacf 100644 --- a/src/MxGateway.Server/Sessions/SessionOpenRequest.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionOpenRequest.cs @@ -1,7 +1,7 @@ using Google.Protobuf.WellKnownTypes; -using MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Contracts.Proto; -namespace MxGateway.Server.Sessions; +namespace ZB.MOM.WW.MxGateway.Server.Sessions; public sealed record SessionOpenRequest( string? RequestedBackend, diff --git a/src/MxGateway.Server/Sessions/SessionRegistry.cs b/src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionRegistry.cs similarity index 82% rename from src/MxGateway.Server/Sessions/SessionRegistry.cs rename to src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionRegistry.cs index fbeb842..ce145cc 100644 --- a/src/MxGateway.Server/Sessions/SessionRegistry.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionRegistry.cs @@ -1,7 +1,8 @@ using System.Collections.Concurrent; -using MxGateway.Contracts.Proto; +using System.Diagnostics.CodeAnalysis; +using ZB.MOM.WW.MxGateway.Contracts.Proto; -namespace MxGateway.Server.Sessions; +namespace ZB.MOM.WW.MxGateway.Server.Sessions; /// /// Thread-safe registry of active gateway sessions. @@ -38,9 +39,9 @@ public sealed class SessionRegistry : ISessionRegistry /// The retrieved session if found. public bool TryGet( string sessionId, - out GatewaySession session) + [MaybeNullWhen(false)] out GatewaySession session) { - return _sessions.TryGetValue(sessionId, out session!); + return _sessions.TryGetValue(sessionId, out session); } /// @@ -50,9 +51,9 @@ public sealed class SessionRegistry : ISessionRegistry /// The removed session if found. public bool TryRemove( string sessionId, - out GatewaySession session) + [MaybeNullWhen(false)] out GatewaySession session) { - return _sessions.TryRemove(sessionId, out session!); + return _sessions.TryRemove(sessionId, out session); } /// diff --git a/src/MxGateway.Server/Sessions/SessionServiceCollectionExtensions.cs b/src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionServiceCollectionExtensions.cs similarity index 88% rename from src/MxGateway.Server/Sessions/SessionServiceCollectionExtensions.cs rename to src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionServiceCollectionExtensions.cs index 1f1e67b..decc180 100644 --- a/src/MxGateway.Server/Sessions/SessionServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionServiceCollectionExtensions.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Sessions; +namespace ZB.MOM.WW.MxGateway.Server.Sessions; /// Service collection extensions for session management. public static class SessionServiceCollectionExtensions @@ -11,7 +11,6 @@ public static class SessionServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); services.AddHostedService(); services.AddHostedService(); diff --git a/src/MxGateway.Server/Sessions/SessionShutdownHostedService.cs b/src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionShutdownHostedService.cs similarity index 95% rename from src/MxGateway.Server/Sessions/SessionShutdownHostedService.cs rename to src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionShutdownHostedService.cs index 07f3f14..426fa29 100644 --- a/src/MxGateway.Server/Sessions/SessionShutdownHostedService.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionShutdownHostedService.cs @@ -1,7 +1,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -namespace MxGateway.Server.Sessions; +namespace ZB.MOM.WW.MxGateway.Server.Sessions; /// Hosted service that cleanly shuts down all gateway sessions on application shutdown. public sealed class SessionShutdownHostedService( diff --git a/src/MxGateway.Server/Sessions/SessionWorkerClientFactory.cs b/src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionWorkerClientFactory.cs similarity index 96% rename from src/MxGateway.Server/Sessions/SessionWorkerClientFactory.cs rename to src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionWorkerClientFactory.cs index 82cc23d..eb95139 100644 --- a/src/MxGateway.Server/Sessions/SessionWorkerClientFactory.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionWorkerClientFactory.cs @@ -1,13 +1,13 @@ using System.IO.Pipes; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using MxGateway.Contracts; -using MxGateway.Contracts.Proto; -using MxGateway.Server.Configuration; -using MxGateway.Server.Metrics; -using MxGateway.Server.Workers; +using ZB.MOM.WW.MxGateway.Contracts; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Server.Configuration; +using ZB.MOM.WW.MxGateway.Server.Metrics; +using ZB.MOM.WW.MxGateway.Server.Workers; -namespace MxGateway.Server.Sessions; +namespace ZB.MOM.WW.MxGateway.Server.Sessions; /// Factory for creating worker clients and launching worker processes. public sealed class SessionWorkerClientFactory : ISessionWorkerClientFactory diff --git a/src/ZB.MOM.WW.MxGateway.Server/Workers/IRunningProcessInspector.cs b/src/ZB.MOM.WW.MxGateway.Server/Workers/IRunningProcessInspector.cs new file mode 100644 index 0000000..fda5caa --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Workers/IRunningProcessInspector.cs @@ -0,0 +1,29 @@ +namespace ZB.MOM.WW.MxGateway.Server.Workers; + +/// +/// Abstraction over OS process enumeration and termination. Exists so the +/// orphan-worker cleanup logic can be unit-tested without spawning real +/// processes. +/// +public interface IRunningProcessInspector +{ + /// + /// Enumerates currently running processes whose image name (without the + /// .exe extension) matches . + /// + /// Process image name to match, without extension. + /// The matching running processes. + IReadOnlyList GetProcessesByName(string processName); + + /// Forcibly terminates the process with the given identifier. + /// Identifier of the process to terminate. + void Kill(int processId); +} + +/// Identifying information for a running process candidate. +/// Operating-system process identifier. +/// +/// Fully-qualified path to the process main module, or +/// when it could not be read (e.g. access denied). +/// +public sealed record RunningProcessInfo(int ProcessId, string? ExecutablePath); diff --git a/src/MxGateway.Server/Workers/IWorkerClient.cs b/src/ZB.MOM.WW.MxGateway.Server/Workers/IWorkerClient.cs similarity index 95% rename from src/MxGateway.Server/Workers/IWorkerClient.cs rename to src/ZB.MOM.WW.MxGateway.Server/Workers/IWorkerClient.cs index d5f3379..abad654 100644 --- a/src/MxGateway.Server/Workers/IWorkerClient.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Workers/IWorkerClient.cs @@ -1,6 +1,6 @@ -using MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Contracts.Proto; -namespace MxGateway.Server.Workers; +namespace ZB.MOM.WW.MxGateway.Server.Workers; /// Manages communication with a single worker process via a named pipe. public interface IWorkerClient : IAsyncDisposable diff --git a/src/MxGateway.Server/Workers/IWorkerProcess.cs b/src/ZB.MOM.WW.MxGateway.Server/Workers/IWorkerProcess.cs similarity index 95% rename from src/MxGateway.Server/Workers/IWorkerProcess.cs rename to src/ZB.MOM.WW.MxGateway.Server/Workers/IWorkerProcess.cs index f9bc378..0249509 100644 --- a/src/MxGateway.Server/Workers/IWorkerProcess.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Workers/IWorkerProcess.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Workers; +namespace ZB.MOM.WW.MxGateway.Server.Workers; /// /// Abstraction over a worker process with lifecycle and exit-code operations. diff --git a/src/MxGateway.Server/Workers/IWorkerProcessFactory.cs b/src/ZB.MOM.WW.MxGateway.Server/Workers/IWorkerProcessFactory.cs similarity index 89% rename from src/MxGateway.Server/Workers/IWorkerProcessFactory.cs rename to src/ZB.MOM.WW.MxGateway.Server/Workers/IWorkerProcessFactory.cs index 7f85404..0be08d9 100644 --- a/src/MxGateway.Server/Workers/IWorkerProcessFactory.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Workers/IWorkerProcessFactory.cs @@ -1,6 +1,6 @@ using System.Diagnostics; -namespace MxGateway.Server.Workers; +namespace ZB.MOM.WW.MxGateway.Server.Workers; /// Factory for creating and starting worker processes. public interface IWorkerProcessFactory diff --git a/src/MxGateway.Server/Workers/IWorkerProcessLauncher.cs b/src/ZB.MOM.WW.MxGateway.Server/Workers/IWorkerProcessLauncher.cs similarity index 90% rename from src/MxGateway.Server/Workers/IWorkerProcessLauncher.cs rename to src/ZB.MOM.WW.MxGateway.Server/Workers/IWorkerProcessLauncher.cs index 2b9607c..a70a463 100644 --- a/src/MxGateway.Server/Workers/IWorkerProcessLauncher.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Workers/IWorkerProcessLauncher.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Workers; +namespace ZB.MOM.WW.MxGateway.Server.Workers; public interface IWorkerProcessLauncher { diff --git a/src/MxGateway.Server/Workers/IWorkerStartupProbe.cs b/src/ZB.MOM.WW.MxGateway.Server/Workers/IWorkerStartupProbe.cs similarity index 92% rename from src/MxGateway.Server/Workers/IWorkerStartupProbe.cs rename to src/ZB.MOM.WW.MxGateway.Server/Workers/IWorkerStartupProbe.cs index 9a6b840..1a94274 100644 --- a/src/MxGateway.Server/Workers/IWorkerStartupProbe.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Workers/IWorkerStartupProbe.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Workers; +namespace ZB.MOM.WW.MxGateway.Server.Workers; public interface IWorkerStartupProbe { diff --git a/src/ZB.MOM.WW.MxGateway.Server/Workers/OrphanWorkerCleanupHostedService.cs b/src/ZB.MOM.WW.MxGateway.Server/Workers/OrphanWorkerCleanupHostedService.cs new file mode 100644 index 0000000..efa4a49 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Workers/OrphanWorkerCleanupHostedService.cs @@ -0,0 +1,30 @@ +namespace ZB.MOM.WW.MxGateway.Server.Workers; + +/// +/// Hosted service that terminates leftover MXAccess worker processes once on +/// gateway startup, before the server begins accepting sessions. +/// +public sealed class OrphanWorkerCleanupHostedService( + OrphanWorkerTerminator terminator, + ILogger logger) : IHostedService +{ + /// + public Task StartAsync(CancellationToken cancellationToken) + { + try + { + terminator.TerminateOrphans(); + } + catch (Exception exception) + { + // Orphan cleanup is best-effort; a failure here must not prevent + // the gateway from starting. + logger.LogWarning(exception, "Orphan worker cleanup failed on startup."); + } + + return Task.CompletedTask; + } + + /// + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Workers/OrphanWorkerTerminator.cs b/src/ZB.MOM.WW.MxGateway.Server/Workers/OrphanWorkerTerminator.cs new file mode 100644 index 0000000..7ac29c2 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Workers/OrphanWorkerTerminator.cs @@ -0,0 +1,138 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using ZB.MOM.WW.MxGateway.Server.Configuration; +using ZB.MOM.WW.MxGateway.Server.Metrics; + +namespace ZB.MOM.WW.MxGateway.Server.Workers; + +/// +/// Terminates leftover MXAccess worker processes on gateway startup. +/// +/// Per gateway.md ("first version should terminate orphaned workers +/// on startup") and CLAUDE.md, a gateway restart does not reattach old +/// workers. After an unclean gateway crash, x86 worker processes — each +/// holding an MXAccess COM instance on an STA — survive indefinitely. This +/// terminator finds those processes by executable name/path and kills them +/// before the restarted gateway accepts sessions. +/// +/// +public sealed class OrphanWorkerTerminator +{ + private readonly IRunningProcessInspector _inspector; + private readonly GatewayMetrics _metrics; + private readonly WorkerOptions _workerOptions; + private readonly ILogger _logger; + + /// Initializes a new instance of the class. + /// Gateway configuration options. + /// Running-process inspector. + /// Gateway metrics collector. + /// Optional logger for diagnostic output. + public OrphanWorkerTerminator( + IOptions gatewayOptions, + IRunningProcessInspector inspector, + GatewayMetrics metrics, + ILogger? logger = null) + { + ArgumentNullException.ThrowIfNull(gatewayOptions); + _inspector = inspector ?? throw new ArgumentNullException(nameof(inspector)); + _metrics = metrics ?? throw new ArgumentNullException(nameof(metrics)); + _workerOptions = gatewayOptions.Value.Worker; + _logger = logger ?? NullLogger.Instance; + } + + /// + /// Finds and kills every leftover worker process. Safe to call once at + /// startup before any session-owned worker is launched. + /// + /// The number of orphan worker processes that were terminated. + public int TerminateOrphans() + { + string? configuredPath = ResolveConfiguredExecutablePath(); + string processName = ResolveProcessName(configuredPath); + int currentProcessId = Environment.ProcessId; + + int terminated = 0; + foreach (RunningProcessInfo candidate in _inspector.GetProcessesByName(processName)) + { + if (candidate.ProcessId == currentProcessId) + { + continue; + } + + if (!IsOrphanWorker(candidate, configuredPath)) + { + continue; + } + + try + { + _inspector.Kill(candidate.ProcessId); + _metrics.WorkerKilled("OrphanStartupCleanup"); + terminated++; + _logger.LogWarning( + "Terminated orphan worker process {ProcessId} ({ExecutablePath}) left over from a previous gateway run.", + candidate.ProcessId, + candidate.ExecutablePath ?? processName); + } + catch (Exception exception) + { + // The process may have already exited, or be inaccessible. + // A failure to kill one orphan must not block gateway startup. + _logger.LogWarning( + exception, + "Failed to terminate orphan worker process {ProcessId}.", + candidate.ProcessId); + } + } + + if (terminated > 0) + { + _logger.LogInformation("Terminated {Count} orphan worker process(es) on startup.", terminated); + } + + return terminated; + } + + private static bool IsOrphanWorker(RunningProcessInfo candidate, string? configuredPath) + { + // When the executable path is readable, require an exact match against + // the configured worker path so unrelated processes that merely share + // the image name are never killed. + if (candidate.ExecutablePath is { } path) + { + return configuredPath is not null + && string.Equals(path, configuredPath, StringComparison.OrdinalIgnoreCase); + } + + // A null path means the x64 gateway could not introspect the module — + // the expected case for the x86 worker. Image-name match is the only + // signal available; treat it as an orphan. + return true; + } + + private string? ResolveConfiguredExecutablePath() + { + try + { + return Path.GetFullPath(_workerOptions.ExecutablePath); + } + catch (Exception exception) when (exception is ArgumentException + or NotSupportedException + or PathTooLongException) + { + _logger.LogWarning( + exception, + "Configured worker executable path '{ExecutablePath}' is not a valid filesystem path; " + + "orphan cleanup will match by image name only.", + _workerOptions.ExecutablePath); + return null; + } + } + + private static string ResolveProcessName(string? configuredPath) + { + string source = configuredPath ?? "ZB.MOM.WW.MxGateway.Worker.exe"; + return Path.GetFileNameWithoutExtension(source); + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Workers/SystemRunningProcessInspector.cs b/src/ZB.MOM.WW.MxGateway.Server/Workers/SystemRunningProcessInspector.cs new file mode 100644 index 0000000..5eabef1 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Workers/SystemRunningProcessInspector.cs @@ -0,0 +1,55 @@ +using System.Diagnostics; + +namespace ZB.MOM.WW.MxGateway.Server.Workers; + +/// +/// backed by . +/// +public sealed class SystemRunningProcessInspector : IRunningProcessInspector +{ + /// + public IReadOnlyList GetProcessesByName(string processName) + { + List results = []; + Process[] processes = Process.GetProcessesByName(processName); + try + { + foreach (Process process in processes) + { + results.Add(new RunningProcessInfo(process.Id, TryGetExecutablePath(process))); + } + } + finally + { + foreach (Process process in processes) + { + process.Dispose(); + } + } + + return results; + } + + /// + public void Kill(int processId) + { + using Process process = Process.GetProcessById(processId); + process.Kill(entireProcessTree: true); + } + + private static string? TryGetExecutablePath(Process process) + { + try + { + return process.MainModule?.FileName; + } + catch (Exception exception) when (exception is InvalidOperationException + or System.ComponentModel.Win32Exception + or NotSupportedException) + { + // Access to the main module can be denied (e.g. a 64-bit gateway + // querying a 32-bit worker, or a process owned by another user). + return null; + } + } +} diff --git a/src/MxGateway.Server/Workers/SystemWorkerProcess.cs b/src/ZB.MOM.WW.MxGateway.Server/Workers/SystemWorkerProcess.cs similarity index 94% rename from src/MxGateway.Server/Workers/SystemWorkerProcess.cs rename to src/ZB.MOM.WW.MxGateway.Server/Workers/SystemWorkerProcess.cs index 35f4e32..ac86232 100644 --- a/src/MxGateway.Server/Workers/SystemWorkerProcess.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Workers/SystemWorkerProcess.cs @@ -1,6 +1,6 @@ using System.Diagnostics; -namespace MxGateway.Server.Workers; +namespace ZB.MOM.WW.MxGateway.Server.Workers; /// /// Wraps a System.Diagnostics.Process as an IWorkerProcess. diff --git a/src/MxGateway.Server/Workers/SystemWorkerProcessFactory.cs b/src/ZB.MOM.WW.MxGateway.Server/Workers/SystemWorkerProcessFactory.cs similarity index 92% rename from src/MxGateway.Server/Workers/SystemWorkerProcessFactory.cs rename to src/ZB.MOM.WW.MxGateway.Server/Workers/SystemWorkerProcessFactory.cs index 509d7bd..ff26647 100644 --- a/src/MxGateway.Server/Workers/SystemWorkerProcessFactory.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Workers/SystemWorkerProcessFactory.cs @@ -1,6 +1,6 @@ using System.Diagnostics; -namespace MxGateway.Server.Workers; +namespace ZB.MOM.WW.MxGateway.Server.Workers; /// /// Factory that creates system processes for workers. diff --git a/src/MxGateway.Server/Workers/WorkerClient.cs b/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerClient.cs similarity index 87% rename from src/MxGateway.Server/Workers/WorkerClient.cs rename to src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerClient.cs index 7ee0785..3761ddb 100644 --- a/src/MxGateway.Server/Workers/WorkerClient.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerClient.cs @@ -4,11 +4,11 @@ using System.Threading.Channels; using Google.Protobuf.WellKnownTypes; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; -using MxGateway.Contracts; -using MxGateway.Contracts.Proto; -using MxGateway.Server.Metrics; +using ZB.MOM.WW.MxGateway.Contracts; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Server.Metrics; -namespace MxGateway.Server.Workers; +namespace ZB.MOM.WW.MxGateway.Server.Workers; public sealed class WorkerClient : IWorkerClient { @@ -389,7 +389,17 @@ public sealed class WorkerClient : IWorkerClient } } - /// Monitors worker heartbeat and detects stale sessions. + /// + /// Monitors worker heartbeat and detects stale sessions. Mirrors + /// Worker-023 on the worker side: while a command is in flight on the + /// gateway↔worker pipe, the heartbeat watchdog is suppressed up to + /// — the worker + /// may be busy executing a slow STA command and the heartbeat write may + /// be queued behind a long event-drain burst (Server-031), neither of + /// which indicates the worker is actually hung. Once the oldest pending + /// command exceeds the ceiling, the fault fires anyway so a truly stuck + /// COM call doesn't hide the worker forever. + /// private async Task HeartbeatLoopAsync() { try @@ -409,6 +419,17 @@ public sealed class WorkerClient : IWorkerClient continue; } + // Server-031: if a command is in flight and hasn't yet exceeded + // the stuck-command ceiling, the gap is more likely caused by + // pipe-write contention (event drain holding the writer lock) + // or a legitimately slow STA command than by a hung worker. + // Wait for the ceiling before faulting on heartbeat alone. + if (TryGetOldestPendingCommandAge(out TimeSpan oldestCommandAge) + && oldestCommandAge <= _options.HeartbeatStuckCeiling) + { + continue; + } + _metrics?.HeartbeatFailed(SessionId); SetFaulted( WorkerClientErrorCode.HeartbeatExpired, @@ -421,6 +442,35 @@ public sealed class WorkerClient : IWorkerClient } } + /// + /// Returns the age of the oldest pending command on the worker pipe, + /// measured via against + /// , or false when no + /// commands are pending. Used by the heartbeat watchdog (Server-031) + /// to decide whether a heartbeat gap is plausibly the result of + /// pipe-write contention rather than a hung worker. + /// + private bool TryGetOldestPendingCommandAge(out TimeSpan oldestAge) + { + long oldestStart = long.MaxValue; + foreach (PendingCommand pending in _pendingCommands.Values) + { + if (pending.StartTimestamp < oldestStart) + { + oldestStart = pending.StartTimestamp; + } + } + + if (oldestStart == long.MaxValue) + { + oldestAge = TimeSpan.Zero; + return false; + } + + oldestAge = _timeProvider.GetElapsedTime(oldestStart); + return true; + } + /// Routes received envelope to appropriate handler. /// The envelope to dispatch. /// Cancellation token. @@ -457,7 +507,19 @@ public sealed class WorkerClient : IWorkerClient } } - /// Enqueues a worker event for client consumption. + /// + /// Enqueues a worker event for client consumption. Server-032: the + /// channel is configured with + /// and a brief consumer hiccup is now tolerated for up to + /// + /// (default 5s) before the worker is faulted. Pre-Server-032 the code + /// used TryWrite (non-blocking) which never honored the + /// configured FullModeTimeout — the worker faulted on the first + /// missed slot even though the wait-mode channel would have absorbed + /// the burst. The diagnostic now names the capacity, current depth, and + /// the actionable fix (attach StreamEvents or raise + /// MxGateway:Events:QueueCapacity). + /// /// The event to enqueue. /// Cancellation token. private async Task EnqueueWorkerEventAsync( @@ -469,18 +531,41 @@ public sealed class WorkerClient : IWorkerClient _metrics?.EventReceived(SessionId, workerEvent.Event.Family.ToString()); } - if (!_events.Writer.TryWrite(workerEvent)) + if (_events.Writer.TryWrite(workerEvent)) { - _metrics?.QueueOverflow("worker-events"); - SetFaulted( - WorkerClientErrorCode.ProtocolViolation, - "Worker event channel rejected an event.", - null); + int queueDepth = Interlocked.Increment(ref _eventQueueDepth); + _metrics?.SetWorkerEventQueueDepth(queueDepth); return; } - int queueDepth = Interlocked.Increment(ref _eventQueueDepth); - _metrics?.SetWorkerEventQueueDepth(queueDepth); + // Channel is full; honor the configured wait timeout before declaring + // the consumer dead and faulting the worker (Server-032). + using CancellationTokenSource fullModeCts = CancellationTokenSource + .CreateLinkedTokenSource(cancellationToken); + fullModeCts.CancelAfter(_options.EventChannelFullModeTimeout); + try + { + await _events.Writer.WriteAsync(workerEvent, fullModeCts.Token).ConfigureAwait(false); + int queueDepth = Interlocked.Increment(ref _eventQueueDepth); + _metrics?.SetWorkerEventQueueDepth(queueDepth); + return; + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + // Only the full-mode timeout fired — the outer cancellation is + // a different concern and is rethrown by the await above when it + // triggers. + } + + _metrics?.QueueOverflow("worker-events"); + int depthAtOverflow = Volatile.Read(ref _eventQueueDepth); + SetFaulted( + WorkerClientErrorCode.ProtocolViolation, + $"Worker event channel rejected an event after waiting " + + $"{_options.EventChannelFullModeTimeout.TotalMilliseconds:F0} ms; " + + $"channel depth is {depthAtOverflow} of {_options.EventChannelCapacity} capacity. " + + $"Attach a StreamEvents consumer or raise MxGateway:Events:QueueCapacity.", + null); } /// Completes pending command with worker reply. diff --git a/src/MxGateway.Server/Workers/WorkerClientConnection.cs b/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerClientConnection.cs similarity index 97% rename from src/MxGateway.Server/Workers/WorkerClientConnection.cs rename to src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerClientConnection.cs index 7e3eb36..0a46f19 100644 --- a/src/MxGateway.Server/Workers/WorkerClientConnection.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerClientConnection.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Workers; +namespace ZB.MOM.WW.MxGateway.Server.Workers; public sealed class WorkerClientConnection { diff --git a/src/MxGateway.Server/Workers/WorkerClientErrorCode.cs b/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerClientErrorCode.cs similarity index 84% rename from src/MxGateway.Server/Workers/WorkerClientErrorCode.cs rename to src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerClientErrorCode.cs index 0b994e5..a6a9542 100644 --- a/src/MxGateway.Server/Workers/WorkerClientErrorCode.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerClientErrorCode.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Workers; +namespace ZB.MOM.WW.MxGateway.Server.Workers; public enum WorkerClientErrorCode { diff --git a/src/MxGateway.Server/Workers/WorkerClientException.cs b/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerClientException.cs similarity index 96% rename from src/MxGateway.Server/Workers/WorkerClientException.cs rename to src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerClientException.cs index 8b644ff..90fe506 100644 --- a/src/MxGateway.Server/Workers/WorkerClientException.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerClientException.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Workers; +namespace ZB.MOM.WW.MxGateway.Server.Workers; /// /// Exception raised when communication with a worker process fails. diff --git a/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerClientOptions.cs b/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerClientOptions.cs new file mode 100644 index 0000000..7c66651 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerClientOptions.cs @@ -0,0 +1,68 @@ +namespace ZB.MOM.WW.MxGateway.Server.Workers; + +/// Configurable options for worker client behavior. +public sealed class WorkerClientOptions +{ + /// Default maximum age of a heartbeat before the client enters faulted state. + public static readonly TimeSpan DefaultHeartbeatGrace = TimeSpan.FromSeconds(15); + + /// Default interval for checking heartbeat staleness. + public static readonly TimeSpan DefaultHeartbeatCheckInterval = TimeSpan.FromSeconds(1); + + /// Default timeout when the event queue is full. + public static readonly TimeSpan DefaultEventChannelFullModeTimeout = TimeSpan.FromSeconds(5); + + /// + /// Default ceiling on the in-flight-command heartbeat skip. Mirrors + /// + /// on the worker side (Worker-023). When a command has been in flight + /// longer than this, the gateway-side heartbeat watchdog fires + /// regardless of pending commands — a truly stuck COM call shouldn't + /// hide the worker forever. + /// + public static readonly TimeSpan DefaultHeartbeatStuckCeiling = TimeSpan.FromSeconds(75); + + /// Initializes options with default values. + public WorkerClientOptions() + { + HeartbeatGrace = DefaultHeartbeatGrace; + HeartbeatCheckInterval = DefaultHeartbeatCheckInterval; + EventChannelCapacity = 1_024; + EventChannelFullModeTimeout = DefaultEventChannelFullModeTimeout; + MaxPendingCommands = 128; + HeartbeatStuckCeiling = DefaultHeartbeatStuckCeiling; + } + + /// Maximum allowed age of the last heartbeat before faulting the client. + public TimeSpan HeartbeatGrace { get; init; } + + /// Interval at which to check for heartbeat expiration. + public TimeSpan HeartbeatCheckInterval { get; init; } + + /// Maximum number of events buffered before backpressure is applied. + public int EventChannelCapacity { get; init; } + + /// + /// Time to wait for the gateway-side event channel to drain before + /// faulting the worker. Honored by EnqueueWorkerEventAsync via + /// WriteAsync; with the channel configured for + /// BoundedChannelFullMode.Wait, a transient backlog only faults + /// after the configured timeout has elapsed (Server-032). Pre-Server-032 + /// the field was declared but unused — overflow faulted immediately. + /// + public TimeSpan EventChannelFullModeTimeout { get; init; } + + /// Maximum number of concurrent pending commands. + public int MaxPendingCommands { get; init; } + + /// + /// Server-031: ceiling on the in-flight-command heartbeat-skip. When + /// a command has been pending on the gateway↔worker pipe for longer + /// than this, the gateway-side HeartbeatLoopAsync fires the + /// HeartbeatExpired fault even if commands are still pending; + /// a truly stuck COM call shouldn't keep the watchdog suppressed + /// indefinitely. Mirrors Worker-023's HeartbeatStuckCeiling on + /// the worker side. + /// + public TimeSpan HeartbeatStuckCeiling { get; init; } +} diff --git a/src/MxGateway.Server/Workers/WorkerClientState.cs b/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerClientState.cs similarity index 71% rename from src/MxGateway.Server/Workers/WorkerClientState.cs rename to src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerClientState.cs index 9ba3be1..f892fa9 100644 --- a/src/MxGateway.Server/Workers/WorkerClientState.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerClientState.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Workers; +namespace ZB.MOM.WW.MxGateway.Server.Workers; public enum WorkerClientState { diff --git a/src/MxGateway.Server/Workers/WorkerEnvelopeValidator.cs b/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerEnvelopeValidator.cs similarity index 94% rename from src/MxGateway.Server/Workers/WorkerEnvelopeValidator.cs rename to src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerEnvelopeValidator.cs index c075979..a215727 100644 --- a/src/MxGateway.Server/Workers/WorkerEnvelopeValidator.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerEnvelopeValidator.cs @@ -1,6 +1,6 @@ -using MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Contracts.Proto; -namespace MxGateway.Server.Workers; +namespace ZB.MOM.WW.MxGateway.Server.Workers; /// /// Validates worker envelope messages against protocol expectations. diff --git a/src/MxGateway.Server/Workers/WorkerExecutableValidator.cs b/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerExecutableValidator.cs similarity index 97% rename from src/MxGateway.Server/Workers/WorkerExecutableValidator.cs rename to src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerExecutableValidator.cs index 45054d2..5a9a9af 100644 --- a/src/MxGateway.Server/Workers/WorkerExecutableValidator.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerExecutableValidator.cs @@ -1,7 +1,7 @@ using System.Buffers.Binary; -using MxGateway.Server.Configuration; +using ZB.MOM.WW.MxGateway.Server.Configuration; -namespace MxGateway.Server.Workers; +namespace ZB.MOM.WW.MxGateway.Server.Workers; internal static class WorkerExecutableValidator { diff --git a/src/MxGateway.Server/Workers/WorkerFrameProtocolErrorCode.cs b/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerFrameProtocolErrorCode.cs similarity index 84% rename from src/MxGateway.Server/Workers/WorkerFrameProtocolErrorCode.cs rename to src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerFrameProtocolErrorCode.cs index 904dc29..69fdeb3 100644 --- a/src/MxGateway.Server/Workers/WorkerFrameProtocolErrorCode.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerFrameProtocolErrorCode.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Workers; +namespace ZB.MOM.WW.MxGateway.Server.Workers; public enum WorkerFrameProtocolErrorCode { diff --git a/src/MxGateway.Server/Workers/WorkerFrameProtocolException.cs b/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerFrameProtocolException.cs similarity index 96% rename from src/MxGateway.Server/Workers/WorkerFrameProtocolException.cs rename to src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerFrameProtocolException.cs index 3dcc48d..06c067c 100644 --- a/src/MxGateway.Server/Workers/WorkerFrameProtocolException.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerFrameProtocolException.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Workers; +namespace ZB.MOM.WW.MxGateway.Server.Workers; /// /// Exception thrown when a worker frame protocol violation occurs. diff --git a/src/MxGateway.Server/Workers/WorkerFrameProtocolOptions.cs b/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerFrameProtocolOptions.cs similarity index 96% rename from src/MxGateway.Server/Workers/WorkerFrameProtocolOptions.cs rename to src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerFrameProtocolOptions.cs index 4d95237..ad9d083 100644 --- a/src/MxGateway.Server/Workers/WorkerFrameProtocolOptions.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerFrameProtocolOptions.cs @@ -1,6 +1,6 @@ -using MxGateway.Contracts; +using ZB.MOM.WW.MxGateway.Contracts; -namespace MxGateway.Server.Workers; +namespace ZB.MOM.WW.MxGateway.Server.Workers; /// /// Configuration for the worker frame protocol connection. diff --git a/src/MxGateway.Server/Workers/WorkerFrameReader.cs b/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerFrameReader.cs similarity index 97% rename from src/MxGateway.Server/Workers/WorkerFrameReader.cs rename to src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerFrameReader.cs index 8ab148b..076d5e9 100644 --- a/src/MxGateway.Server/Workers/WorkerFrameReader.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerFrameReader.cs @@ -1,8 +1,8 @@ using System.Buffers.Binary; using Google.Protobuf; -using MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Contracts.Proto; -namespace MxGateway.Server.Workers; +namespace ZB.MOM.WW.MxGateway.Server.Workers; public sealed class WorkerFrameReader { diff --git a/src/MxGateway.Server/Workers/WorkerFrameWriter.cs b/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerFrameWriter.cs similarity index 96% rename from src/MxGateway.Server/Workers/WorkerFrameWriter.cs rename to src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerFrameWriter.cs index eddddaa..999fab5 100644 --- a/src/MxGateway.Server/Workers/WorkerFrameWriter.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerFrameWriter.cs @@ -1,8 +1,8 @@ using System.Buffers.Binary; using Google.Protobuf; -using MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Contracts.Proto; -namespace MxGateway.Server.Workers; +namespace ZB.MOM.WW.MxGateway.Server.Workers; /// /// Writes length-prefixed WorkerEnvelope protobuf messages to a stream. diff --git a/src/MxGateway.Server/Workers/WorkerProcessCommandLine.cs b/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerProcessCommandLine.cs similarity index 96% rename from src/MxGateway.Server/Workers/WorkerProcessCommandLine.cs rename to src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerProcessCommandLine.cs index 74d74e1..fe5afa0 100644 --- a/src/MxGateway.Server/Workers/WorkerProcessCommandLine.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerProcessCommandLine.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Workers; +namespace ZB.MOM.WW.MxGateway.Server.Workers; /// /// Represents a worker process command line. diff --git a/src/MxGateway.Server/Workers/WorkerProcessHandle.cs b/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerProcessHandle.cs similarity index 96% rename from src/MxGateway.Server/Workers/WorkerProcessHandle.cs rename to src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerProcessHandle.cs index 35211e3..0ffb814 100644 --- a/src/MxGateway.Server/Workers/WorkerProcessHandle.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerProcessHandle.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Workers; +namespace ZB.MOM.WW.MxGateway.Server.Workers; /// Handle to a running worker process with metadata. public sealed class WorkerProcessHandle : IDisposable diff --git a/src/MxGateway.Server/Workers/WorkerProcessLaunchErrorCode.cs b/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerProcessLaunchErrorCode.cs similarity index 84% rename from src/MxGateway.Server/Workers/WorkerProcessLaunchErrorCode.cs rename to src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerProcessLaunchErrorCode.cs index 9638f3e..a1510e2 100644 --- a/src/MxGateway.Server/Workers/WorkerProcessLaunchErrorCode.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerProcessLaunchErrorCode.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Workers; +namespace ZB.MOM.WW.MxGateway.Server.Workers; public enum WorkerProcessLaunchErrorCode { diff --git a/src/MxGateway.Server/Workers/WorkerProcessLaunchException.cs b/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerProcessLaunchException.cs similarity index 96% rename from src/MxGateway.Server/Workers/WorkerProcessLaunchException.cs rename to src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerProcessLaunchException.cs index cb4f60f..9b1ef55 100644 --- a/src/MxGateway.Server/Workers/WorkerProcessLaunchException.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerProcessLaunchException.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Workers; +namespace ZB.MOM.WW.MxGateway.Server.Workers; public sealed class WorkerProcessLaunchException : Exception { diff --git a/src/MxGateway.Server/Workers/WorkerProcessLaunchRequest.cs b/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerProcessLaunchRequest.cs similarity index 79% rename from src/MxGateway.Server/Workers/WorkerProcessLaunchRequest.cs rename to src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerProcessLaunchRequest.cs index d477d3c..13ec1cf 100644 --- a/src/MxGateway.Server/Workers/WorkerProcessLaunchRequest.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerProcessLaunchRequest.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Workers; +namespace ZB.MOM.WW.MxGateway.Server.Workers; public sealed record WorkerProcessLaunchRequest( string SessionId, diff --git a/src/MxGateway.Server/Workers/WorkerProcessLauncher.cs b/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerProcessLauncher.cs similarity index 98% rename from src/MxGateway.Server/Workers/WorkerProcessLauncher.cs rename to src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerProcessLauncher.cs index 468fb6f..c8a6ab7 100644 --- a/src/MxGateway.Server/Workers/WorkerProcessLauncher.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerProcessLauncher.cs @@ -2,12 +2,12 @@ using System.Diagnostics; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; -using MxGateway.Server.Configuration; -using MxGateway.Server.Metrics; +using ZB.MOM.WW.MxGateway.Server.Configuration; +using ZB.MOM.WW.MxGateway.Server.Metrics; using Polly; using Polly.Retry; -namespace MxGateway.Server.Workers; +namespace ZB.MOM.WW.MxGateway.Server.Workers; /// /// Launches worker processes with startup probing and error handling. diff --git a/src/MxGateway.Server/Workers/WorkerProcessStartedProbe.cs b/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerProcessStartedProbe.cs similarity index 95% rename from src/MxGateway.Server/Workers/WorkerProcessStartedProbe.cs rename to src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerProcessStartedProbe.cs index b76c1fb..1e19a98 100644 --- a/src/MxGateway.Server/Workers/WorkerProcessStartedProbe.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerProcessStartedProbe.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Workers; +namespace ZB.MOM.WW.MxGateway.Server.Workers; public sealed class WorkerProcessStartedProbe : IWorkerStartupProbe { diff --git a/src/MxGateway.Server/Workers/WorkerServiceCollectionExtensions.cs b/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerServiceCollectionExtensions.cs similarity index 57% rename from src/MxGateway.Server/Workers/WorkerServiceCollectionExtensions.cs rename to src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerServiceCollectionExtensions.cs index d387573..d15e914 100644 --- a/src/MxGateway.Server/Workers/WorkerServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerServiceCollectionExtensions.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Server.Workers; +namespace ZB.MOM.WW.MxGateway.Server.Workers; /// Service collection extensions for worker process management. public static class WorkerServiceCollectionExtensions @@ -11,6 +11,13 @@ public static class WorkerServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); + // Terminate workers leaked by a previous unclean gateway run before the + // server accepts sessions. Registered ahead of AddGatewaySessions so the + // cleanup hosted service starts before the session subsystem. + services.AddSingleton(); + services.AddSingleton(); + services.AddHostedService(); + return services; } } diff --git a/src/MxGateway.Server/MxGateway.Server.csproj b/src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj similarity index 84% rename from src/MxGateway.Server/MxGateway.Server.csproj rename to src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj index 1cc8c4a..eefe591 100644 --- a/src/MxGateway.Server/MxGateway.Server.csproj +++ b/src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj @@ -13,7 +13,7 @@ - + diff --git a/src/MxGateway.Server/appsettings.Development.json b/src/ZB.MOM.WW.MxGateway.Server/appsettings.Development.json similarity index 100% rename from src/MxGateway.Server/appsettings.Development.json rename to src/ZB.MOM.WW.MxGateway.Server/appsettings.Development.json diff --git a/src/MxGateway.Server/appsettings.json b/src/ZB.MOM.WW.MxGateway.Server/appsettings.json similarity index 81% rename from src/MxGateway.Server/appsettings.json rename to src/ZB.MOM.WW.MxGateway.Server/appsettings.json index 55a6e02..bc9dde3 100644 --- a/src/MxGateway.Server/appsettings.json +++ b/src/ZB.MOM.WW.MxGateway.Server/appsettings.json @@ -28,7 +28,7 @@ "RequiredGroup": "GwAdmin" }, "Worker": { - "ExecutablePath": "src\\MxGateway.Worker\\bin\\x86\\Release\\MxGateway.Worker.exe", + "ExecutablePath": "src\\ZB.MOM.WW.MxGateway.Worker\\bin\\x86\\Release\\ZB.MOM.WW.MxGateway.Worker.exe", "RequiredArchitecture": "X86", "StartupTimeoutSeconds": 30, "ShutdownTimeoutSeconds": 10, @@ -65,7 +65,15 @@ "Galaxy": { "ConnectionString": "Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;", "CommandTimeoutSeconds": 60, - "DashboardRefreshIntervalSeconds": 30 + "DashboardRefreshIntervalSeconds": 30, + "PersistSnapshot": true, + "SnapshotCachePath": "C:\\ProgramData\\MxGateway\\galaxy-snapshot.json" + }, + "Alarms": { + "Enabled": true, + "SubscriptionExpression": "\\\\DESKTOP-6JL3KKO\\Galaxy!DEV", + "DefaultArea": "", + "ReconcileIntervalSeconds": 30 } } } diff --git a/src/MxGateway.Server/libman.json b/src/ZB.MOM.WW.MxGateway.Server/libman.json similarity index 100% rename from src/MxGateway.Server/libman.json rename to src/ZB.MOM.WW.MxGateway.Server/libman.json diff --git a/src/ZB.MOM.WW.MxGateway.Server/wwwroot/css/dashboard.css b/src/ZB.MOM.WW.MxGateway.Server/wwwroot/css/dashboard.css new file mode 100644 index 0000000..47ef3aa --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/wwwroot/css/dashboard.css @@ -0,0 +1,518 @@ +/* ============================================================================ + MXAccess Gateway dashboard — view layer. + Layers over theme.css (the technical-light design system). Every colour, + font, and surface here resolves to a theme.css token — no hard-coded hex. + theme.css owns the tokens and the component library; this sheet only wires + the dashboard's own class names and Bootstrap widgets into that system. + ========================================================================= */ + +body.dashboard-body { min-height: 100vh; } + +/* ── App bar ───────────────────────────────────────────────────────────────── + theme.css styles .app-bar / .brand / .mark / .spacer. Here we centre the row + and add the inline nav and the signed-in-user cluster. */ +.app-bar { align-items: center; gap: 1.25rem; } +.app-bar .brand { color: var(--ink); } +.app-bar .brand:hover { text-decoration: none; } + +.app-nav { display: flex; flex-wrap: wrap; gap: 0.15rem; } +.app-nav a { + font-size: 0.82rem; + color: var(--ink-soft); + padding: 0.25rem 0.6rem; + border-radius: 4px; +} +.app-nav a:hover { color: var(--ink); background: #f0f0ec; text-decoration: none; } +.app-nav a.active { + color: var(--accent-deep); + background: #e7ecfb; + font-weight: 600; +} + +.app-user { + display: flex; + align-items: center; + gap: 0.6rem; + font-size: 0.8rem; + color: var(--ink-soft); +} +.app-user form { margin: 0; } + +/* ── Page header ───────────────────────────────────────────────────────────── + h1 in sans, the sub-line in monospace as a quiet meta crumb. */ +.dashboard-page-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1rem; + animation: rise 0.4s ease both; +} +.dashboard-page-header h1 { + font-size: 1.15rem; + font-weight: 600; + letter-spacing: 0.01em; + margin: 0; +} +.dashboard-page-header .text-secondary { + margin-top: 0.15rem; + font-family: var(--mono); + font-size: 0.78rem; + color: var(--ink-faint); +} + +/* ── KPI / metric cards ────────────────────────────────────────────────────── + The MetricCard component renders .metric-card with label/value/detail; this + is the technical-light aggregate card — uppercase eyebrow, big mono number. */ +.metric-grid { + display: grid; + gap: 0.75rem; + grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr)); + margin-bottom: 1rem; + animation: rise 0.4s ease both; + animation-delay: 0.04s; +} +.metric-grid.compact { grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr)); } + +.metric-card { + background: var(--card); + border: 1px solid var(--rule); + border-radius: 8px; + box-shadow: none; +} +.metric-card .card-body { padding: 0.7rem 0.9rem; } +.metric-label { + font-size: 0.68rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--ink-faint); +} +.metric-card-wide { grid-column: span 2; } + +.metric-value { + margin-top: 0.25rem; + font-family: var(--mono); + font-variant-numeric: tabular-nums; + font-size: 1.5rem; + font-weight: 600; + line-height: 1.1; + color: var(--ink); + /* break-word, not anywhere: keep date/number tokens whole, wrap at spaces. */ + overflow-wrap: break-word; +} +.metric-detail { + margin-top: 0.15rem; + font-size: 0.78rem; + color: var(--ink-faint); + overflow-wrap: anywhere; +} + +/* ── Section panels ────────────────────────────────────────────────────────── + Each .dashboard-section is a raised panel: white card, hairline border. */ +.dashboard-section { + background: var(--card); + border: 1px solid var(--rule); + border-radius: 8px; + margin-top: 1rem; + padding: 0.9rem; + animation: rise 0.4s ease both; + animation-delay: 0.09s; +} + +.section-heading { margin-bottom: 0.6rem; } +.section-heading h2 { + font-size: 0.74rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--ink-faint); + margin: 0; +} + +/* ── Data tables ───────────────────────────────────────────────────────────── + Dense, hairline-ruled, uppercase head on a faint fill. */ +.dashboard-table { + width: 100%; + border-collapse: collapse; + margin-bottom: 0; + font-size: 0.85rem; + background: var(--card); +} +.dashboard-table th, +.dashboard-table td { + padding: 0.45rem 0.8rem; + text-align: left; + vertical-align: middle; + border-bottom: 1px solid var(--rule); +} +.dashboard-table th { + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--ink-faint); + background: #fbfbf9; + white-space: nowrap; +} +.dashboard-table td { + max-width: 26rem; + /* break-word, not anywhere: only split a word when it genuinely cannot + fit, so the column keeps whole words like "unconstrained" intact. */ + overflow-wrap: break-word; +} +.dashboard-table tbody tr:last-child td { border-bottom: none; } +.dashboard-table tbody tr:hover { background: #f3f6fd; } + +/* Key/value detail tables: left label column, monospace values, zebra rows. */ +.details-table th { + width: 16rem; + text-transform: none; + letter-spacing: 0; + font-size: 0.82rem; + font-weight: 500; + color: var(--ink-soft); + background: var(--card); +} +.details-table td { + font-family: var(--mono); + font-variant-numeric: tabular-nums; + font-size: 0.82rem; + color: var(--ink); +} +.details-table tbody tr:nth-child(even) { background: #fbfbf9; } +.details-table tbody tr:hover { background: #fbfbf9; } + +/* Inline code: monospace, accent ink, no Bootstrap pink. */ +code { + font-family: var(--mono); + font-size: 0.82rem; + color: var(--accent-deep); +} + +/* Status chips never wrap, even in a narrow table column. */ +.chip { white-space: nowrap; } + +/* ── Empty / placeholder state ───────────────────────────────────────────────*/ +.empty-state { + background: #fbfbf9; + border: 1px dashed var(--rule-strong); + border-radius: 6px; + color: var(--ink-faint); + padding: 1rem 1.1rem; + font-size: 0.85rem; +} + +/* ── Buttons ───────────────────────────────────────────────────────────────── + Flatten Bootstrap buttons onto the single accent + hairline palette. */ +.btn { border-radius: 5px; font-size: 0.82rem; font-weight: 500; white-space: nowrap; } +.btn-primary { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} +.btn-primary:hover, +.btn-primary:focus { + background: var(--accent-deep); + border-color: var(--accent-deep); + color: #fff; +} +.btn-outline-secondary { + color: var(--ink-soft); + background: var(--card); + border-color: var(--rule-strong); +} +.btn-outline-secondary:hover { + color: var(--ink); + background: #f0f0ec; + border-color: var(--rule-strong); +} +.btn-outline-danger { + color: var(--bad); + background: var(--card); + border-color: #eec3c3; +} +.btn-outline-danger:hover { + color: #fff; + background: var(--bad); + border-color: var(--bad); +} + +/* ── Forms ───────────────────────────────────────────────────────────────────*/ +.form-label { + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--ink-faint); +} +.form-control, +.form-select { + font-size: 0.85rem; + border-color: var(--rule-strong); + border-radius: 5px; +} +.form-control:focus, +.form-select:focus { + border-color: var(--accent); + box-shadow: 0 0 0 0.15rem rgba(47, 95, 208, 0.15); +} + +/* ── Alerts ──────────────────────────────────────────────────────────────────*/ +.alert { border-radius: 6px; border-width: 1px; font-size: 0.85rem; } +.alert-success { color: var(--ok); background: var(--ok-bg); border-color: #c6e6cd; } +.alert-danger { color: var(--bad); background: var(--bad-bg); border-color: #eec3c3; } + +/* ── Login ───────────────────────────────────────────────────────────────────*/ +.dashboard-login { max-width: 24rem; margin: 0 auto; } +.login-card { + background: var(--card); + border: 1px solid var(--rule); + border-radius: 8px; +} + +/* ── API key management ──────────────────────────────────────────────────────*/ +.api-key-management-grid { + display: grid; + gap: 0.75rem; + grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr)); +} +.scope-grid { + display: grid; + gap: 0.35rem 0.75rem; + grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); +} +.one-time-secret { + display: block; + overflow-wrap: anywhere; + white-space: normal; + font-family: var(--mono); +} +.api-key-create-modal { display: block; } +.api-key-create-modal .modal-body { + max-height: min(70vh, 44rem); + overflow-y: auto; +} +.modal-content { + border: 1px solid var(--rule-strong); + border-radius: 8px; +} + +@media (max-width: 700px) { + .page { padding: 0.85rem; } + .dashboard-page-header { + flex-direction: column; + align-items: flex-start; + } + .details-table th { width: 9rem; } +} + +/* ── Browse tab ─────────────────────────────────────────────────────────────── + Two-pane layout: the Galaxy hierarchy tree on the left, the live + subscription panel on the right. Both panes are .dashboard-section cards. */ +.browse-layout { + display: grid; + gap: 1rem; + grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr); + align-items: start; +} +.browse-panel { margin-top: 0; } + +.browse-search { margin-bottom: 0.6rem; } +.browse-search-note { + margin-top: 0.5rem; + font-size: 0.74rem; + color: var(--ink-faint); +} + +.browse-tree { + max-height: 32rem; + overflow-y: auto; + border: 1px solid var(--rule); + border-radius: 6px; + padding: 0.3rem 0; + background: #fbfbf9; +} +.browse-search-results { padding: 0.15rem 0; } + +.tree-children { margin-left: 0.95rem; border-left: 1px solid var(--rule); } + +.tree-row, +.tree-attr { + display: flex; + align-items: center; + gap: 0.35rem; + padding: 0.12rem 0.5rem; + font-size: 0.82rem; + white-space: nowrap; +} +.tree-row:hover, +.tree-attr:hover { background: #eef2fb; } + +.tree-toggle { + flex: none; + width: 1.1rem; + height: 1.1rem; + line-height: 1; + padding: 0; + border: none; + background: transparent; + color: var(--ink-faint); + font-size: 0.7rem; + cursor: pointer; +} +.tree-toggle-empty { cursor: default; } + +.tree-label { + display: flex; + align-items: center; + gap: 0.35rem; + cursor: pointer; + overflow: hidden; + text-overflow: ellipsis; +} +.tree-icon { color: var(--accent); font-size: 0.72rem; } +.tree-name { color: var(--ink); } +.tree-row-area .tree-name { font-weight: 600; } +.tree-tag { font-size: 0.72rem; color: var(--ink-faint); } + +.tree-attr { cursor: context-menu; } +.attr-icon { flex: none; color: var(--ink-faint); width: 0.6rem; text-align: center; } +.attr-name { font-family: var(--mono); color: var(--ink); } +.attr-type { font-size: 0.72rem; color: var(--ink-faint); } +.attr-flag { + font-size: 0.62rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 0.02rem 0.28rem; + border-radius: 3px; +} +.attr-flag-alarm { color: var(--bad); background: var(--bad-bg); } +.attr-flag-hist { color: var(--accent-deep); background: #e7ecfb; } + +/* ── Subscription panel ───────────────────────────────────────────────────── */ +.sub-panel-meta { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.76rem; + color: var(--ink-faint); + margin-bottom: 0.5rem; + min-height: 1.6rem; +} +.sub-clear { margin-left: auto; } +.sub-table td { vertical-align: middle; } +.sub-value { + font-family: var(--mono); + font-variant-numeric: tabular-nums; + color: var(--ink); + font-weight: 600; +} +.sub-actions-col { width: 1%; white-space: nowrap; } + +.quality-chip { + font-family: var(--mono); + font-size: 0.74rem; + font-weight: 600; + padding: 0.05rem 0.4rem; + border-radius: 3px; +} +.quality-good { color: var(--ok); background: var(--ok-bg); } +.quality-bad { color: var(--bad); background: var(--bad-bg); } +.sub-error { + margin-left: 0.3rem; + color: var(--bad); + font-weight: 700; + cursor: help; +} + +/* ── Right-click context menu ─────────────────────────────────────────────── */ +.context-menu-overlay { + position: fixed; + inset: 0; + z-index: 1040; + background: transparent; +} +.context-menu { + position: fixed; + z-index: 1050; + min-width: 13rem; + background: var(--card); + border: 1px solid var(--rule-strong); + border-radius: 6px; + box-shadow: 0 6px 20px rgba(27, 29, 33, 0.16); + padding: 0.25rem; +} +.context-menu-head { + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--ink-faint); + padding: 0.3rem 0.5rem; + border-bottom: 1px solid var(--rule); + margin-bottom: 0.2rem; + overflow: hidden; + text-overflow: ellipsis; +} +.context-menu-item { + display: block; + width: 100%; + text-align: left; + border: none; + background: transparent; + color: var(--ink); + font-size: 0.83rem; + padding: 0.4rem 0.5rem; + border-radius: 4px; + cursor: pointer; +} +.context-menu-item:hover { background: #eef2fb; color: var(--accent-deep); } + +/* ── Alarms tab ───────────────────────────────────────────────────────────── */ +.alarm-filters { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 0.75rem 1rem; +} +.alarm-filter-check { + display: flex; + align-items: center; + gap: 0.35rem; + font-size: 0.82rem; + color: var(--ink-soft); +} +.alarm-filter-field { display: flex; flex-direction: column; gap: 0.2rem; } +.alarm-filter-field input[type="number"] { width: 7rem; } +.alarm-filter-grow { flex: 1 1 14rem; } +.alarm-filter-grow input { width: 100%; } + +.alarm-state { + display: inline-block; + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 0.1rem 0.45rem; + border-radius: 3px; + white-space: nowrap; +} +.alarm-state-active { color: var(--bad); background: var(--bad-bg); } +.alarm-state-acked { color: var(--warn); background: var(--warn-bg); } +.alarm-state-other { color: var(--idle); background: var(--idle-bg); } +.alarm-severity { + font-family: var(--mono); + font-variant-numeric: tabular-nums; + font-weight: 600; +} +.alarm-desc { + margin-top: 0.1rem; + font-size: 0.74rem; + color: var(--ink-faint); +} + +@media (max-width: 960px) { + .browse-layout { grid-template-columns: minmax(0, 1fr); } +} diff --git a/src/ZB.MOM.WW.MxGateway.Server/wwwroot/css/theme.css b/src/ZB.MOM.WW.MxGateway.Server/wwwroot/css/theme.css new file mode 100644 index 0000000..0d587ce --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/wwwroot/css/theme.css @@ -0,0 +1,379 @@ +/* ============================================================================ + Technical-Light design system — portable theme layer + ---------------------------------------------------------------------------- + A refined technical-light aesthetic: warm-neutral paper, hairline rules, + IBM Plex type, monospace tabular numerics, status carried by colour. Built + to layer over Bootstrap 5 via --bs-* overrides, but every rule below works + standalone — Bootstrap is optional. + + HOW TO ADOPT + 1. Serve the three IBM Plex woff2 files (shipped in fonts/) and fix the + @font-face url() paths below to wherever you serve them. + 2. Include this file once, globally. Add view-specific rules in a separate + stylesheet — never edit the token block per-view. + 3. Status is colour, not iconography. Use the .s-* / .chip-* / .kv .v.* + helpers; do not hand-pick hex values in feature CSS. + ========================================================================= */ + +/* ── Vendored fonts (embedded woff2, no network/CDN fetch) ─────────────────── + Adjust these url()s to your asset route. If you cannot vendor the fonts the + --sans / --mono fallback stacks below degrade gracefully to system fonts. */ +@font-face { + font-family: 'IBM Plex Sans'; + font-style: normal; font-weight: 400; font-display: swap; + src: url('/fonts/ibm-plex-sans-400.woff2') format('woff2'); +} +@font-face { + font-family: 'IBM Plex Sans'; + font-style: normal; font-weight: 600; font-display: swap; + src: url('/fonts/ibm-plex-sans-600.woff2') format('woff2'); +} +@font-face { + font-family: 'IBM Plex Mono'; + font-style: normal; font-weight: 500; font-display: swap; + src: url('/fonts/ibm-plex-mono-500.woff2') format('woff2'); +} + +/* ── Design tokens ─────────────────────────────────────────────────────────── + The single source of truth. Re-theme by editing only this block. */ +:root { + /* Surfaces & ink */ + --paper: #f4f4f1; /* page background — warm off-white, never pure */ + --card: #ffffff; /* raised surfaces: cards, bars, table heads */ + --ink: #1b1d21; /* primary text */ + --ink-soft: #5a6066; /* secondary text, labels */ + --ink-faint: #8b9097; /* tertiary text, captions, units */ + --rule: #e4e4df; /* hairline borders / row dividers */ + --rule-strong: #d2d2cb; /* emphasised hairlines: bar underline, pills */ + + /* Accent */ + --accent: #2f5fd0; /* links, sort arrows, primary actions */ + --accent-deep: #1e3f99; /* hover / pressed accent, raw-value emphasis */ + + /* Status — foreground */ + --ok: #2f9e44; + --warn: #e8920c; + --bad: #e03131; + --idle: #868e96; + + /* Status — tinted backgrounds (pair with the matching foreground) */ + --ok-bg: #e9f6ec; + --warn-bg: #fdf1dd; + --bad-bg: #fceaea; + --idle-bg: #eef0f2; + + /* Type stacks — Plex first, graceful system fallback */ + --mono: 'IBM Plex Mono', ui-monospace, 'Cascadia Mono', Consolas, monospace; + --sans: 'IBM Plex Sans', system-ui, -apple-system, 'Segoe UI', sans-serif; + + /* Bootstrap 5 overrides — harmless if Bootstrap is absent */ + --bs-body-bg: var(--paper); + --bs-body-color: var(--ink); + --bs-body-font-family: var(--sans); + --bs-body-font-size: 0.9rem; + --bs-primary: var(--accent); + --bs-border-color: var(--rule); + --bs-emphasis-color: var(--ink); +} + +/* ── Base ──────────────────────────────────────────────────────────────────── + The faint top-right radial is the one deliberate flourish — a soft sheen, + not a gradient wash. Keep it subtle. */ +body { + background: + radial-gradient(1200px 480px at 88% -8%, #ffffff 0%, rgba(255,255,255,0) 70%), + var(--paper); + color: var(--ink); + font-family: var(--sans); + font-size: 0.9rem; + -webkit-font-smoothing: antialiased; +} + +/* Any numeric / fixed-width text. Tabular figures so columns of digits align. */ +.numeric, +.mono { font-family: var(--mono); font-variant-numeric: tabular-nums; } + +a { color: var(--accent); text-decoration: none; } +a:hover { color: var(--accent-deep); text-decoration: underline; } + +/* ── App chrome: top bar ───────────────────────────────────────────────────── + One bar across the top: brand, breadcrumb crumbs, a flex spacer, then meta + text and any status pill pushed hard right. */ +.app-bar { + display: flex; + align-items: baseline; + gap: 1rem; + padding: 0.85rem 1.25rem; + background: var(--card); + border-bottom: 1px solid var(--rule-strong); +} +.app-bar .brand { + font-weight: 600; + font-size: 1.05rem; + letter-spacing: 0.02em; +} +.app-bar .brand .mark { color: var(--accent); } /* the one accent glyph */ +.app-bar .crumb { color: var(--ink-faint); font-size: 0.85rem; } +.app-bar .spacer { flex: 1; } /* pushes meta/pill right */ +.app-bar .meta { + font-family: var(--mono); + font-size: 0.78rem; + color: var(--ink-soft); +} + +/* ── Connection / liveness pill ────────────────────────────────────────────── + A rounded pill with a dot, driven entirely by data-state. Use for any + live-link health indicator (websocket, SSE, polling). */ +.conn-pill { + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-size: 0.74rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + padding: 0.2rem 0.6rem; + border-radius: 999px; + border: 1px solid var(--rule-strong); + color: var(--ink-soft); + background: var(--card); +} +.conn-pill .dot { + width: 7px; height: 7px; border-radius: 50%; + background: var(--idle); +} +.conn-pill[data-state="connected"] { color: var(--ok); border-color: #bfe3c6; background: var(--ok-bg); } +.conn-pill[data-state="connected"] .dot { background: var(--ok); } +.conn-pill[data-state="connecting"] { color: var(--warn); border-color: #f0d9ab; background: var(--warn-bg); } +.conn-pill[data-state="connecting"] .dot { background: var(--warn); animation: pulse 1.1s ease-in-out infinite; } +.conn-pill[data-state="disconnected"] { color: var(--bad); border-color: #f0c0c0; background: var(--bad-bg); } +.conn-pill[data-state="disconnected"] .dot { background: var(--bad); } + +@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.25; } } + +/* ── Status text helpers ───────────────────────────────────────────────────── + Recolour a value in place — counts, ratios, error totals. */ +.s-ok { color: var(--ok); } +.s-warn { color: var(--warn); } +.s-bad { color: var(--bad); } +.s-idle { color: var(--idle); } + +/* ── State chip ────────────────────────────────────────────────────────────── + Compact rectangular badge for an enumerated state (bound/recovering/…). + Squarer than the pill; use the pill for liveness, the chip for state. */ +.chip { + display: inline-block; + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 0.15rem 0.5rem; + border-radius: 4px; + border: 1px solid transparent; +} +.chip-ok { color: var(--ok); background: var(--ok-bg); border-color: #c6e6cd; } +.chip-warn { color: #b56a00; background: var(--warn-bg); border-color: #efd6a6; } +.chip-bad { color: var(--bad); background: var(--bad-bg); border-color: #eec3c3; } +.chip-idle { color: var(--ink-soft); background: var(--idle-bg); border-color: var(--rule-strong); } + +/* ── Panel — the base raised surface ───────────────────────────────────────── + A white card with a hairline border and 8px radius. .panel-head is the + uppercase eyebrow label that sits on top. */ +.panel { + background: var(--card); + border: 1px solid var(--rule); + border-radius: 8px; +} +.panel-head { + font-size: 0.74rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--ink-faint); + padding: 0.6rem 0.9rem; + border-bottom: 1px solid var(--rule); +} + +/* ── Page wrapper ──────────────────────────────────────────────────────────── + Centred, capped width, even gutter. */ +.page { padding: 1.25rem; max-width: 1680px; margin: 0 auto; } + +/* ── Reveal-on-paint ───────────────────────────────────────────────────────── + Add .rise to top-level sections; stagger with inline animation-delay + (.02s, .08s, .14s …) so panels settle in sequence, not all at once. */ +@keyframes rise { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } } +.rise { animation: rise 0.4s ease both; } + +/* ════════════════════════════════════════════════════════════════════════════ + COMPONENT LIBRARY + Generic, reusable pieces. View-specific layout belongs in a separate sheet. + ════════════════════════════════════════════════════════════════════════════ */ + +/* ── KPI / aggregate cards ─────────────────────────────────────────────────── + A responsive strip of headline numbers. .agg-card.alert / .caution tint the + whole card when a watched metric goes non-zero. */ +.agg-grid { + display: grid; + grid-template-columns: repeat(6, 1fr); + gap: 0.75rem; + margin-bottom: 1rem; +} +@media (max-width: 1100px) { .agg-grid { grid-template-columns: repeat(3, 1fr); } } +@media (max-width: 620px) { .agg-grid { grid-template-columns: repeat(2, 1fr); } } + +.agg-card { + background: var(--card); + border: 1px solid var(--rule); + border-radius: 8px; + padding: 0.7rem 0.9rem; +} +.agg-label { + font-size: 0.68rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--ink-faint); +} +.agg-value { + margin-top: 0.25rem; + font-size: 1.5rem; + font-weight: 600; + line-height: 1.1; + display: flex; + align-items: baseline; + gap: 0.35rem; +} +.agg-sub { /* trailing "/ 54", "ms" etc. — quieter */ + font-size: 0.85rem; + font-weight: 400; + color: var(--ink-faint); +} +.agg-card.alert { border-color: #eec3c3; background: var(--bad-bg); } +.agg-card.alert .agg-value { color: var(--bad); } +.agg-card.caution { border-color: #efd6a6; background: var(--warn-bg); } +.agg-card.caution .agg-value { color: #b56a00; } + +/* ── Metric card + key/value rows ──────────────────────────────────────────── + A .panel-head over a stack of .kv rows: label left, monospace value right. + Zebra striping on even rows. .v.warn / .v.bad / .v.ok recolour a value. */ +.card-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(290px, 1fr)); + gap: 0.85rem; + margin-bottom: 1rem; +} +.metric-card { + background: var(--card); + border: 1px solid var(--rule); + border-radius: 8px; + overflow: hidden; +} +.metric-card .panel-head { margin: 0; } + +.kv { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 1rem; + padding: 0.32rem 0.9rem; + font-size: 0.85rem; +} +.kv:nth-child(even) { background: #fbfbf9; } +.kv .k { color: var(--ink-soft); } +.kv .v { + font-family: var(--mono); + font-variant-numeric: tabular-nums; + text-align: right; +} +.kv .v.warn { color: var(--warn); } +.kv .v.bad { color: var(--bad); } +.kv .v.ok { color: var(--ok); } + +/* ── Toolbar ───────────────────────────────────────────────────────────────── + Filter/search row that sits inside a .panel above a table. */ +.toolbar { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.6rem 0.9rem; + border-bottom: 1px solid var(--rule); +} +.toolbar .spacer { flex: 1; } +.tb-search { max-width: 280px; } +.tb-state { max-width: 150px; } +.tb-check { + display: flex; align-items: center; gap: 0.35rem; + font-size: 0.82rem; color: var(--ink-soft); white-space: nowrap; + user-select: none; +} +.tb-count { font-family: var(--mono); font-size: 0.78rem; color: var(--ink-faint); } + +/* ── Data table ────────────────────────────────────────────────────────────── + Dense, hairline-ruled table. Uppercase sticky head on a faint fill; numeric + columns get .num (right-aligned, monospace). Rows are clickable by default — + drop the cursor/hover rules if yours are not. */ +.table-wrap { overflow-x: auto; } + +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 0.85rem; +} +.data-table th, +.data-table td { + padding: 0.45rem 0.8rem; + text-align: left; + white-space: nowrap; + border-bottom: 1px solid var(--rule); +} +.data-table th { + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--ink-faint); + background: #fbfbf9; + position: sticky; + top: 0; +} +.data-table th.num, +.data-table td.num { text-align: right; font-family: var(--mono); } + +.data-table th.sortable { cursor: pointer; user-select: none; } +.data-table th.sortable:hover { color: var(--ink); } +.data-table th.sorted-asc::after { content: ' \2191'; color: var(--accent); } +.data-table th.sorted-desc::after { content: ' \2193'; color: var(--accent); } + +.data-table tbody tr { cursor: pointer; transition: background 0.08s; } +.data-table tbody tr:hover { background: #f3f6fd; } +.data-table tbody tr:last-child td { border-bottom: none; } + +.empty-row { + text-align: center !important; + color: var(--ink-faint); + padding: 1.6rem !important; + font-style: italic; +} + +/* ── Direction / category tag ──────────────────────────────────────────────── + Tiny inline tag for a per-row category (e.g. read vs write). */ +.dir-tag { + font-size: 0.68rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 0.1rem 0.4rem; + border-radius: 3px; +} +.dir-read { color: var(--accent-deep); background: #e7ecfb; } +.dir-write { color: #8a5a00; background: var(--warn-bg); } + +/* ── Inline notice ─────────────────────────────────────────────────────────── + A .panel with a warning tint — for "this thing is gone / degraded" banners. */ +.notice { + padding: 0.85rem 1.1rem; + margin-bottom: 1rem; + color: #b56a00; + background: var(--warn-bg); + border-color: #efd6a6; +} diff --git a/src/ZB.MOM.WW.MxGateway.Server/wwwroot/fonts/ibm-plex-mono-500.woff2 b/src/ZB.MOM.WW.MxGateway.Server/wwwroot/fonts/ibm-plex-mono-500.woff2 new file mode 100644 index 0000000..99c2610 Binary files /dev/null and b/src/ZB.MOM.WW.MxGateway.Server/wwwroot/fonts/ibm-plex-mono-500.woff2 differ diff --git a/src/ZB.MOM.WW.MxGateway.Server/wwwroot/fonts/ibm-plex-sans-400.woff2 b/src/ZB.MOM.WW.MxGateway.Server/wwwroot/fonts/ibm-plex-sans-400.woff2 new file mode 100644 index 0000000..93bcd64 Binary files /dev/null and b/src/ZB.MOM.WW.MxGateway.Server/wwwroot/fonts/ibm-plex-sans-400.woff2 differ diff --git a/src/ZB.MOM.WW.MxGateway.Server/wwwroot/fonts/ibm-plex-sans-600.woff2 b/src/ZB.MOM.WW.MxGateway.Server/wwwroot/fonts/ibm-plex-sans-600.woff2 new file mode 100644 index 0000000..0ac91d6 Binary files /dev/null and b/src/ZB.MOM.WW.MxGateway.Server/wwwroot/fonts/ibm-plex-sans-600.woff2 differ diff --git a/src/MxGateway.Server/wwwroot/lib/bootstrap/css/bootstrap.min.css b/src/ZB.MOM.WW.MxGateway.Server/wwwroot/lib/bootstrap/css/bootstrap.min.css similarity index 100% rename from src/MxGateway.Server/wwwroot/lib/bootstrap/css/bootstrap.min.css rename to src/ZB.MOM.WW.MxGateway.Server/wwwroot/lib/bootstrap/css/bootstrap.min.css diff --git a/src/MxGateway.Server/wwwroot/lib/bootstrap/js/bootstrap.bundle.min.js b/src/ZB.MOM.WW.MxGateway.Server/wwwroot/lib/bootstrap/js/bootstrap.bundle.min.js similarity index 100% rename from src/MxGateway.Server/wwwroot/lib/bootstrap/js/bootstrap.bundle.min.js rename to src/ZB.MOM.WW.MxGateway.Server/wwwroot/lib/bootstrap/js/bootstrap.bundle.min.js diff --git a/src/MxGateway.Tests/Configuration/GatewayOptionsTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsTests.cs similarity index 95% rename from src/MxGateway.Tests/Configuration/GatewayOptionsTests.cs rename to src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsTests.cs index c9ed0b6..405a21a 100644 --- a/src/MxGateway.Tests/Configuration/GatewayOptionsTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsTests.cs @@ -1,9 +1,9 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; -using MxGateway.Server.Configuration; +using ZB.MOM.WW.MxGateway.Server.Configuration; -namespace MxGateway.Tests.Configuration; +namespace ZB.MOM.WW.MxGateway.Tests.Configuration; public sealed class GatewayOptionsTests { @@ -18,7 +18,7 @@ public sealed class GatewayOptionsTests Assert.Equal("MxGateway:ApiKeyPepper", options.Authentication.PepperSecretName); Assert.True(options.Authentication.RunMigrationsOnStartup); - Assert.Equal(@"src\MxGateway.Worker\bin\x86\Release\MxGateway.Worker.exe", options.Worker.ExecutablePath); + Assert.Equal(@"src\ZB.MOM.WW.MxGateway.Worker\bin\x86\Release\ZB.MOM.WW.MxGateway.Worker.exe", options.Worker.ExecutablePath); Assert.Equal(WorkerArchitecture.X86, options.Worker.RequiredArchitecture); Assert.Equal(30, options.Worker.StartupTimeoutSeconds); Assert.Equal(3, options.Worker.StartupProbeRetryAttempts); @@ -59,7 +59,7 @@ public sealed class GatewayOptionsTests new Dictionary { ["MxGateway:Authentication:Mode"] = "Disabled", - ["MxGateway:Worker:ExecutablePath"] = @"C:\Gateway\MxGateway.Worker.exe", + ["MxGateway:Worker:ExecutablePath"] = @"C:\Gateway\ZB.MOM.WW.MxGateway.Worker.exe", ["MxGateway:Sessions:MaxSessions"] = "12", ["MxGateway:Sessions:DefaultLeaseSeconds"] = "900", ["MxGateway:Events:QueueCapacity"] = "256", @@ -68,7 +68,7 @@ public sealed class GatewayOptionsTests }); Assert.Equal(AuthenticationMode.Disabled, options.Authentication.Mode); - Assert.Equal(@"C:\Gateway\MxGateway.Worker.exe", options.Worker.ExecutablePath); + Assert.Equal(@"C:\Gateway\ZB.MOM.WW.MxGateway.Worker.exe", options.Worker.ExecutablePath); Assert.Equal(12, options.Sessions.MaxSessions); Assert.Equal(900, options.Sessions.DefaultLeaseSeconds); Assert.Equal(256, options.Events.QueueCapacity); diff --git a/src/MxGateway.Tests/Contracts/ClientBehaviorFixtureTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Contracts/ClientBehaviorFixtureTests.cs similarity index 99% rename from src/MxGateway.Tests/Contracts/ClientBehaviorFixtureTests.cs rename to src/ZB.MOM.WW.MxGateway.Tests/Contracts/ClientBehaviorFixtureTests.cs index 3b9dac2..282f327 100644 --- a/src/MxGateway.Tests/Contracts/ClientBehaviorFixtureTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Contracts/ClientBehaviorFixtureTests.cs @@ -1,9 +1,9 @@ using System.Text.Json; using Google.Protobuf; -using MxGateway.Contracts; -using MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Contracts; +using ZB.MOM.WW.MxGateway.Contracts.Proto; -namespace MxGateway.Tests.Contracts; +namespace ZB.MOM.WW.MxGateway.Tests.Contracts; /// /// Tests for behavior fixture manifests and contract conformance. diff --git a/src/MxGateway.Tests/Contracts/ClientProtoInputTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Contracts/ClientProtoInputTests.cs similarity index 97% rename from src/MxGateway.Tests/Contracts/ClientProtoInputTests.cs rename to src/ZB.MOM.WW.MxGateway.Tests/Contracts/ClientProtoInputTests.cs index a701cb1..4848871 100644 --- a/src/MxGateway.Tests/Contracts/ClientProtoInputTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Contracts/ClientProtoInputTests.cs @@ -1,9 +1,9 @@ using System.Text.Json; using Google.Protobuf; -using MxGateway.Contracts; -using MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Contracts; +using ZB.MOM.WW.MxGateway.Contracts.Proto; -namespace MxGateway.Tests.Contracts; +namespace ZB.MOM.WW.MxGateway.Tests.Contracts; public sealed class ClientProtoInputTests { diff --git a/src/MxGateway.Tests/Contracts/CrossLanguageSmokeMatrixTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Contracts/CrossLanguageSmokeMatrixTests.cs similarity index 99% rename from src/MxGateway.Tests/Contracts/CrossLanguageSmokeMatrixTests.cs rename to src/ZB.MOM.WW.MxGateway.Tests/Contracts/CrossLanguageSmokeMatrixTests.cs index ab95a84..a983a5d 100644 --- a/src/MxGateway.Tests/Contracts/CrossLanguageSmokeMatrixTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Contracts/CrossLanguageSmokeMatrixTests.cs @@ -1,6 +1,6 @@ using System.Text.Json; -namespace MxGateway.Tests.Contracts; +namespace ZB.MOM.WW.MxGateway.Tests.Contracts; public sealed class CrossLanguageSmokeMatrixTests { diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Contracts/GatewayContractInfoTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Contracts/GatewayContractInfoTests.cs new file mode 100644 index 0000000..cf371e5 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Contracts/GatewayContractInfoTests.cs @@ -0,0 +1,38 @@ +using ZB.MOM.WW.MxGateway.Contracts; + +namespace ZB.MOM.WW.MxGateway.Tests.Contracts; + +public sealed class GatewayContractInfoTests +{ + /// Verifies that the default backend name is "mxaccess-worker". + [Fact] + public void DefaultBackendName_IsMxAccessWorker() + { + Assert.Equal("mxaccess-worker", GatewayContractInfo.DefaultBackendName); + } + + /// + /// Pins the current + /// constant at 3. Both the alarm proto extension (`AcknowledgeAlarm` / + /// `QueryActiveAlarms` RPCs, the `OnAlarmTransitionEvent` body, and the + /// alarm command/reply payload cases) and the bulk write/read command + /// family extension (`WriteBulk` / `Write2Bulk` / `WriteSecuredBulk` / + /// `WriteSecured2Bulk` / `ReadBulk` plus their `BulkWriteReply` and + /// `BulkReadReply` payloads) shipped under version 3 — both were strictly + /// additive contract changes, so neither required a further bump. A + /// future breaking contract change should bump this constant and update + /// this test in lock-step. + /// + [Fact] + public void GatewayProtocolVersion_IsVersionThree() + { + Assert.Equal(3u, GatewayContractInfo.GatewayProtocolVersion); + } + + /// Verifies that the worker protocol version starts at version one. + [Fact] + public void WorkerProtocolVersion_StartsAtVersionOne() + { + Assert.Equal(1u, GatewayContractInfo.WorkerProtocolVersion); + } +} diff --git a/src/MxGateway.Tests/Contracts/ParityFixtureMatrixTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Contracts/ParityFixtureMatrixTests.cs similarity index 99% rename from src/MxGateway.Tests/Contracts/ParityFixtureMatrixTests.cs rename to src/ZB.MOM.WW.MxGateway.Tests/Contracts/ParityFixtureMatrixTests.cs index 2b61e9a..c8700f5 100644 --- a/src/MxGateway.Tests/Contracts/ParityFixtureMatrixTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Contracts/ParityFixtureMatrixTests.cs @@ -1,7 +1,7 @@ using System.Text.Json; -using MxGateway.Contracts; +using ZB.MOM.WW.MxGateway.Contracts; -namespace MxGateway.Tests.Contracts; +namespace ZB.MOM.WW.MxGateway.Tests.Contracts; public sealed class ParityFixtureMatrixTests { diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Contracts/ProtobufContractRoundTripTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Contracts/ProtobufContractRoundTripTests.cs new file mode 100644 index 0000000..b940644 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Contracts/ProtobufContractRoundTripTests.cs @@ -0,0 +1,1228 @@ +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; +using ZB.MOM.WW.MxGateway.Contracts; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; + +namespace ZB.MOM.WW.MxGateway.Tests.Contracts; + +public sealed class ProtobufContractRoundTripTests +{ + /// Verifies that gateway descriptor contains expected public service methods. + [Fact] + public void GatewayDescriptor_ContainsInitialPublicServiceMethods() + { + var service = Assert.Single( + MxaccessGatewayReflection.Descriptor.Services, + descriptor => descriptor.Name == "MxAccessGateway"); + + Assert.Contains(service.Methods, method => method.Name == "OpenSession"); + Assert.Contains(service.Methods, method => method.Name == "CloseSession"); + Assert.Contains(service.Methods, method => method.Name == "Invoke"); + Assert.Contains(service.Methods, method => method.Name == "StreamEvents"); + Assert.Contains(service.Methods, method => method.Name == "AcknowledgeAlarm"); + Assert.Contains(service.Methods, method => method.Name == "StreamAlarms"); + } + + /// Verifies that worker envelope descriptor contains required correlation fields. + [Fact] + public void WorkerEnvelopeDescriptor_ContainsRequiredCorrelationFields() + { + var fields = WorkerEnvelope.Descriptor.Fields.InDeclarationOrder(); + + Assert.Contains(fields, field => field.Name == "protocol_version"); + Assert.Contains(fields, field => field.Name == "session_id"); + Assert.Contains(fields, field => field.Name == "sequence"); + Assert.Contains(fields, field => field.Name == "correlation_id"); + } + + /// Verifies that command request round-trips through serialization. + [Fact] + public void CommandRequest_RoundTripsMethodSpecificPayload() + { + var original = new MxCommandRequest + { + SessionId = "session-1", + ClientCorrelationId = "client-correlation-1", + Command = new MxCommand + { + Kind = MxCommandKind.Register, + Register = new RegisterCommand + { + ClientName = "mxaccessgw-test-client", + }, + }, + }; + + var parsed = MxCommandRequest.Parser.ParseFrom(original.ToByteArray()); + + Assert.Equal(original, parsed); + Assert.Equal(MxCommand.PayloadOneofCase.Register, parsed.Command.PayloadCase); + } + + /// Verifies that command reply round-trips with return values and statuses. + [Fact] + public void CommandReply_RoundTripsHResultReturnValueOutParamsAndStatuses() + { + var original = new MxCommandReply + { + SessionId = "session-1", + CorrelationId = "gateway-correlation-1", + Kind = MxCommandKind.AddItem, + ProtocolStatus = new ProtocolStatus + { + Code = ProtocolStatusCode.Ok, + }, + Hresult = 0, + ReturnValue = new MxValue + { + DataType = MxDataType.Integer, + Int32Value = 1234, + VariantType = "VT_I4", + }, + AddItem = new AddItemReply + { + ItemHandle = 1234, + }, + }; + original.Statuses.Add(new MxStatusProxy + { + Success = 1, + Category = MxStatusCategory.Ok, + DetectedBy = MxStatusSource.RespondingLmx, + Detail = 0, + }); + + var parsed = MxCommandReply.Parser.ParseFrom(original.ToByteArray()); + + Assert.Equal(original, parsed); + Assert.True(parsed.HasHresult); + Assert.Equal(MxCommandReply.PayloadOneofCase.AddItem, parsed.PayloadCase); + Assert.Single(parsed.Statuses); + } + + /// Verifies that event round-trips with value, status, and sequence. + [Fact] + public void Event_RoundTripsValueStatusSequenceAndBufferedBody() + { + var timestamp = Timestamp.FromDateTime(new DateTime(2026, 4, 26, 20, 0, 0, DateTimeKind.Utc)); + var original = new MxEvent + { + Family = MxEventFamily.OnBufferedDataChange, + SessionId = "session-1", + ServerHandle = 10, + ItemHandle = 20, + Value = new MxValue + { + DataType = MxDataType.Float, + ArrayValue = new MxArray + { + ElementDataType = MxDataType.Float, + FloatValues = new FloatArray + { + Values = { 1.5f, 2.5f }, + }, + Dimensions = { 2 }, + VariantType = "VT_ARRAY|VT_R4", + }, + }, + Quality = 192, + SourceTimestamp = timestamp, + WorkerSequence = 42, + WorkerTimestamp = timestamp, + GatewayReceiveTimestamp = timestamp, + OnBufferedDataChange = new OnBufferedDataChangeEvent + { + DataType = MxDataType.Float, + QualityValues = new MxArray + { + ElementDataType = MxDataType.Integer, + Int32Values = new Int32Array + { + Values = { 192, 192 }, + }, + Dimensions = { 2 }, + }, + TimestampValues = new MxArray + { + ElementDataType = MxDataType.Time, + TimestampValues = new TimestampArray + { + Values = { timestamp, timestamp }, + }, + Dimensions = { 2 }, + }, + }, + }; + original.Statuses.Add(new MxStatusProxy + { + Success = 1, + Category = MxStatusCategory.Ok, + DetectedBy = MxStatusSource.RespondingNmx, + Detail = 0, + }); + + var parsed = MxEvent.Parser.ParseFrom(original.ToByteArray()); + + Assert.Equal(original, parsed); + Assert.Equal(MxEvent.BodyOneofCase.OnBufferedDataChange, parsed.BodyCase); + Assert.Single(parsed.Statuses); + } + + /// Verifies that worker envelope round-trips through serialization preserving protocol and command fields. + [Fact] + public void WorkerEnvelope_RoundTripsProtocolFieldsAndCommandBody() + { + var original = new WorkerEnvelope + { + ProtocolVersion = GatewayContractInfo.WorkerProtocolVersion, + SessionId = "session-1", + Sequence = 7, + CorrelationId = "gateway-correlation-1", + WorkerCommand = new WorkerCommand + { + EnqueueTimestamp = Timestamp.FromDateTime( + new DateTime(2026, 4, 26, 20, 5, 0, DateTimeKind.Utc)), + Command = new MxCommand + { + Kind = MxCommandKind.Advise, + Advise = new AdviseCommand + { + ServerHandle = 10, + ItemHandle = 20, + }, + }, + }, + }; + + var parsed = WorkerEnvelope.Parser.ParseFrom(original.ToByteArray()); + + Assert.Equal(original, parsed); + Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerCommand, parsed.BodyCase); + Assert.Equal(MxCommand.PayloadOneofCase.Advise, parsed.WorkerCommand.Command.PayloadCase); + } + + /// Verifies that an OnAlarmTransition event round-trips with full payload. + [Fact] + public void Event_RoundTripsOnAlarmTransitionWithFullPayload() + { + var raise = Timestamp.FromDateTime(new DateTime(2026, 5, 1, 12, 0, 0, DateTimeKind.Utc)); + var ack = Timestamp.FromDateTime(new DateTime(2026, 5, 1, 12, 0, 30, DateTimeKind.Utc)); + var original = new MxEvent + { + Family = MxEventFamily.OnAlarmTransition, + SessionId = "session-1", + WorkerSequence = 99, + WorkerTimestamp = ack, + GatewayReceiveTimestamp = ack, + OnAlarmTransition = new OnAlarmTransitionEvent + { + AlarmFullReference = "Tank01.Level.HiHi", + SourceObjectReference = "Tank01", + AlarmTypeName = "AnalogLimitAlarm.HiHi", + TransitionKind = AlarmTransitionKind.Acknowledge, + Severity = 750, + OriginalRaiseTimestamp = raise, + TransitionTimestamp = ack, + OperatorUser = "operator1", + OperatorComment = "investigating", + Category = "Process", + Description = "Tank 01 high-high level", + CurrentValue = new MxValue + { + DataType = MxDataType.Float, + FloatValue = 95.4f, + VariantType = "VT_R4", + }, + LimitValue = new MxValue + { + DataType = MxDataType.Float, + FloatValue = 90.0f, + VariantType = "VT_R4", + }, + }, + }; + + var parsed = MxEvent.Parser.ParseFrom(original.ToByteArray()); + + Assert.Equal(original, parsed); + Assert.Equal(MxEvent.BodyOneofCase.OnAlarmTransition, parsed.BodyCase); + Assert.Equal(AlarmTransitionKind.Acknowledge, parsed.OnAlarmTransition.TransitionKind); + Assert.Equal(raise, parsed.OnAlarmTransition.OriginalRaiseTimestamp); + Assert.Equal("operator1", parsed.OnAlarmTransition.OperatorUser); + } + + /// Verifies that an OnAlarmTransition event round-trips with only the required fields populated. + [Fact] + public void Event_RoundTripsOnAlarmTransitionWithOptionalFieldsEmpty() + { + var raise = Timestamp.FromDateTime(new DateTime(2026, 5, 1, 12, 0, 0, DateTimeKind.Utc)); + var original = new MxEvent + { + Family = MxEventFamily.OnAlarmTransition, + SessionId = "session-1", + WorkerSequence = 100, + OnAlarmTransition = new OnAlarmTransitionEvent + { + AlarmFullReference = "Tank01.Level.HiHi", + AlarmTypeName = "AnalogLimitAlarm.HiHi", + TransitionKind = AlarmTransitionKind.Raise, + Severity = 750, + TransitionTimestamp = raise, + }, + }; + + var parsed = MxEvent.Parser.ParseFrom(original.ToByteArray()); + + Assert.Equal(original, parsed); + Assert.Equal(string.Empty, parsed.OnAlarmTransition.OperatorUser); + Assert.Equal(string.Empty, parsed.OnAlarmTransition.OperatorComment); + Assert.Null(parsed.OnAlarmTransition.OriginalRaiseTimestamp); + Assert.Null(parsed.OnAlarmTransition.CurrentValue); + } + + /// Verifies that an MxEvent body oneof rejects multiple bodies — last write wins per proto3 semantics. + [Fact] + public void Event_OneofGuard_LastBodyWins() + { + var ev = new MxEvent + { + Family = MxEventFamily.OnAlarmTransition, + OnDataChange = new OnDataChangeEvent(), + OnAlarmTransition = new OnAlarmTransitionEvent + { + AlarmFullReference = "X", + TransitionKind = AlarmTransitionKind.Raise, + }, + }; + + Assert.Equal(MxEvent.BodyOneofCase.OnAlarmTransition, ev.BodyCase); + Assert.Null(ev.OnDataChange); + } + + /// Verifies that AcknowledgeAlarmRequest round-trips through serialization. + [Fact] + public void AcknowledgeAlarmRequest_RoundTripsAllFields() + { + var original = new AcknowledgeAlarmRequest + { + ClientCorrelationId = "client-correlation-7", + AlarmFullReference = "Tank01.Level.HiHi", + Comment = "shift handover", + OperatorUser = "operator2", + }; + + var parsed = AcknowledgeAlarmRequest.Parser.ParseFrom(original.ToByteArray()); + + Assert.Equal(original, parsed); + } + + /// Verifies that AcknowledgeAlarmReply round-trips with status, hresult, and diagnostics. + [Fact] + public void AcknowledgeAlarmReply_RoundTripsStatusAndHresult() + { + var original = new AcknowledgeAlarmReply + { + CorrelationId = "gateway-correlation-7", + ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok }, + Hresult = 0, + Status = new MxStatusProxy + { + Success = 1, + Category = MxStatusCategory.Ok, + DetectedBy = MxStatusSource.RespondingLmx, + }, + DiagnosticMessage = "ack accepted", + }; + + var parsed = AcknowledgeAlarmReply.Parser.ParseFrom(original.ToByteArray()); + + Assert.Equal(original, parsed); + Assert.True(parsed.HasHresult); + } + + /// + /// Pins the documented command/reply payload-reuse contract: an + /// ACKNOWLEDGE_ALARM_BY_NAME command's reply intentionally has no + /// by-name-specific payload case and instead reuses the + /// acknowledge_alarm () + /// case. A future change that adds a separate by-name reply case — or + /// drops the reuse — breaks this test. See Contracts-002 and + /// docs/AlarmClientDiscovery.md section 4. + /// + [Fact] + public void MxCommandReply_AcknowledgeAlarmByName_ReusesAcknowledgeAlarmPayloadCase() + { + // The reply oneof must NOT have a by-name-specific case. If a future + // edit adds one, this assertion fails and forces the doc/test contract + // to be revisited deliberately. + foreach (MxCommandReply.PayloadOneofCase value in + System.Enum.GetValues()) + { + Assert.NotEqual("AcknowledgeAlarmByName", value.ToString()); + } + + var original = new MxCommandReply + { + SessionId = "session-1", + CorrelationId = "gateway-correlation-7", + Kind = MxCommandKind.AcknowledgeAlarmByName, + ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok }, + Hresult = 0, + // By-name ack reuses the acknowledge_alarm payload case; see the + // worker's MxAccessCommandExecutor.ExecuteAcknowledgeAlarmByName. + AcknowledgeAlarm = new AcknowledgeAlarmReplyPayload + { + NativeStatus = 0, + }, + }; + + var parsed = MxCommandReply.Parser.ParseFrom(original.ToByteArray()); + + Assert.Equal(original, parsed); + // Kind distinguishes the by-name ack; the payload case is shared. + Assert.Equal(MxCommandKind.AcknowledgeAlarmByName, parsed.Kind); + Assert.Equal(MxCommandReply.PayloadOneofCase.AcknowledgeAlarm, parsed.PayloadCase); + Assert.Equal(0, parsed.AcknowledgeAlarm.NativeStatus); + // The by-name command has its own command payload case — the asymmetry + // with the reply oneof is the documented contract under test. + Assert.Contains( + MxCommand.PayloadOneofCase.AcknowledgeAlarmByNameCommand, + System.Enum.GetValues()); + } + + /// Verifies that ActiveAlarmSnapshot round-trips with current state and operator metadata. + [Fact] + public void ActiveAlarmSnapshot_RoundTripsAllFields() + { + var raise = Timestamp.FromDateTime(new DateTime(2026, 5, 1, 12, 0, 0, DateTimeKind.Utc)); + var ack = Timestamp.FromDateTime(new DateTime(2026, 5, 1, 12, 0, 30, DateTimeKind.Utc)); + var original = new ActiveAlarmSnapshot + { + AlarmFullReference = "Tank01.Level.HiHi", + SourceObjectReference = "Tank01", + AlarmTypeName = "AnalogLimitAlarm.HiHi", + Severity = 750, + OriginalRaiseTimestamp = raise, + CurrentState = AlarmConditionState.ActiveAcked, + Category = "Process", + Description = "Tank 01 high-high level", + LastTransitionTimestamp = ack, + OperatorUser = "operator2", + OperatorComment = "investigating", + }; + + var parsed = ActiveAlarmSnapshot.Parser.ParseFrom(original.ToByteArray()); + + Assert.Equal(original, parsed); + Assert.Equal(AlarmConditionState.ActiveAcked, parsed.CurrentState); + } + + /// Verifies that StreamAlarmsRequest round-trips with and without a filter prefix. + [Fact] + public void StreamAlarmsRequest_RoundTripsWithAndWithoutFilter() + { + var withoutFilter = new StreamAlarmsRequest + { + ClientCorrelationId = "client-correlation-8", + }; + + var withFilter = new StreamAlarmsRequest + { + ClientCorrelationId = "client-correlation-9", + AlarmFilterPrefix = "Tank01.", + }; + + Assert.Equal(withoutFilter, StreamAlarmsRequest.Parser.ParseFrom(withoutFilter.ToByteArray())); + Assert.Equal(withFilter, StreamAlarmsRequest.Parser.ParseFrom(withFilter.ToByteArray())); + } + + /// Verifies that an MxValue carrying a raw_value bytes payload round-trips. + [Fact] + public void MxValue_RoundTripsRawValueBytesPayload() + { + var original = new MxValue + { + DataType = MxDataType.Unknown, + VariantType = "VT_UNKNOWN", + RawDataType = 99, + RawDiagnostic = "uninterpreted COM variant", + RawValue = ByteString.CopyFrom(0x01, 0x02, 0xFE, 0xFF), + }; + + var parsed = MxValue.Parser.ParseFrom(original.ToByteArray()); + + Assert.Equal(original, parsed); + Assert.Equal(MxValue.KindOneofCase.RawValue, parsed.KindCase); + Assert.Equal(new byte[] { 0x01, 0x02, 0xFE, 0xFF }, parsed.RawValue.ToByteArray()); + } + + /// Verifies that an MxArray carrying a RawArray of byte blobs round-trips. + [Fact] + public void MxArray_RoundTripsRawArrayPayload() + { + var original = new MxArray + { + ElementDataType = MxDataType.Unknown, + VariantType = "VT_ARRAY|VT_UNKNOWN", + RawElementDataType = 99, + RawDiagnostic = "uninterpreted SAFEARRAY", + Dimensions = { 2 }, + RawValues = new RawArray + { + Values = + { + ByteString.CopyFrom(0xAA, 0xBB), + ByteString.CopyFrom(0xCC, 0xDD), + }, + }, + }; + + var parsed = MxArray.Parser.ParseFrom(original.ToByteArray()); + + Assert.Equal(original, parsed); + Assert.Equal(MxArray.ValuesOneofCase.RawValues, parsed.ValuesCase); + Assert.Equal(2, parsed.RawValues.Values.Count); + } + + /// Verifies that a BulkSubscribeReply with per-item SubscribeResults round-trips. + [Fact] + public void BulkSubscribeReply_RoundTripsSubscribeResults() + { + var original = new BulkSubscribeReply + { + Results = + { + new SubscribeResult + { + ServerHandle = 10, + TagAddress = "Provider!Tank01.Level", + ItemHandle = 21, + WasSuccessful = true, + }, + new SubscribeResult + { + ServerHandle = 10, + TagAddress = "Provider!Bad.Tag", + WasSuccessful = false, + ErrorMessage = "item not found", + }, + }, + }; + + var parsed = BulkSubscribeReply.Parser.ParseFrom(original.ToByteArray()); + + Assert.Equal(original, parsed); + Assert.Equal(2, parsed.Results.Count); + Assert.True(parsed.Results[0].WasSuccessful); + Assert.False(parsed.Results[1].WasSuccessful); + } + + /// Verifies that a bulk-subscribe command and its BulkSubscribeReply payload round-trip. + [Fact] + public void MxCommandReply_RoundTripsBulkSubscribePayload() + { + var original = new MxCommandReply + { + SessionId = "session-1", + CorrelationId = "gateway-correlation-bulk", + Kind = MxCommandKind.SubscribeBulk, + ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok }, + Hresult = 0, + SubscribeBulk = new BulkSubscribeReply + { + Results = + { + new SubscribeResult + { + ServerHandle = 5, + TagAddress = "Provider!Tank01.Level", + ItemHandle = 7, + WasSuccessful = true, + }, + }, + }, + }; + + var parsed = MxCommandReply.Parser.ParseFrom(original.ToByteArray()); + + Assert.Equal(original, parsed); + Assert.Equal(MxCommandReply.PayloadOneofCase.SubscribeBulk, parsed.PayloadCase); + Assert.Single(parsed.SubscribeBulk.Results); + } + + /// Verifies that a WorkerEnvelope carrying a WorkerFault body round-trips. + [Fact] + public void WorkerEnvelope_RoundTripsWorkerFaultBody() + { + var original = new WorkerEnvelope + { + ProtocolVersion = GatewayContractInfo.WorkerProtocolVersion, + SessionId = "session-1", + Sequence = 11, + CorrelationId = "gateway-correlation-fault", + WorkerFault = new WorkerFault + { + Category = WorkerFaultCategory.MxaccessCommandFailed, + CommandMethod = "Register", + Hresult = unchecked((int)0x80004005), + ExceptionType = "System.Runtime.InteropServices.COMException", + DiagnosticMessage = "MXAccess COM call failed.", + ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.ProtocolViolation }, + }, + }; + + var parsed = WorkerEnvelope.Parser.ParseFrom(original.ToByteArray()); + + Assert.Equal(original, parsed); + Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerFault, parsed.BodyCase); + Assert.True(parsed.WorkerFault.HasHresult); + } + + /// Verifies that a WorkerEnvelope carrying a WorkerHeartbeat body round-trips. + [Fact] + public void WorkerEnvelope_RoundTripsWorkerHeartbeatBody() + { + var activity = Timestamp.FromDateTime(new DateTime(2026, 5, 18, 9, 0, 0, DateTimeKind.Utc)); + var original = new WorkerEnvelope + { + ProtocolVersion = GatewayContractInfo.WorkerProtocolVersion, + SessionId = "session-1", + Sequence = 12, + CorrelationId = "gateway-correlation-heartbeat", + WorkerHeartbeat = new WorkerHeartbeat + { + WorkerProcessId = 4242, + State = WorkerState.Ready, + LastStaActivityTimestamp = activity, + PendingCommandCount = 3, + OutboundEventQueueDepth = 7, + LastEventSequence = 1234, + CurrentCommandCorrelationId = "in-flight-1", + }, + }; + + var parsed = WorkerEnvelope.Parser.ParseFrom(original.ToByteArray()); + + Assert.Equal(original, parsed); + Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerHeartbeat, parsed.BodyCase); + Assert.Equal(WorkerState.Ready, parsed.WorkerHeartbeat.State); + } + + /// Verifies that the Galaxy Repository service descriptor exposes its browse RPCs. + [Fact] + public void GalaxyRepositoryDescriptor_ContainsBrowseServiceMethods() + { + var service = Assert.Single( + GalaxyRepositoryReflection.Descriptor.Services, + descriptor => descriptor.Name == "GalaxyRepository"); + + Assert.Contains(service.Methods, method => method.Name == "TestConnection"); + Assert.Contains(service.Methods, method => method.Name == "GetLastDeployTime"); + Assert.Contains(service.Methods, method => method.Name == "DiscoverHierarchy"); + Assert.Contains(service.Methods, method => method.Name == "WatchDeployEvents"); + } + + /// + /// Verifies that a DiscoverHierarchyRequest round-trips through every + /// root oneof arm and its proto wrapper-typed max_depth field. + /// + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + public void DiscoverHierarchyRequest_RoundTripsRootOneofAndWrapperFields(int rootArm) + { + var original = new DiscoverHierarchyRequest + { + PageSize = 100, + PageToken = "page-2", + MaxDepth = 5, + CategoryIds = { 3, 9 }, + TemplateChainContains = { "Analog", "Pump" }, + TagNameGlob = "Tank*", + IncludeAttributes = true, + AlarmBearingOnly = true, + HistorizedOnly = false, + }; + switch (rootArm) + { + case 0: + original.RootGobjectId = 4711; + break; + case 1: + original.RootTagName = "Tank01"; + break; + default: + original.RootContainedPath = "Area1.Tank01"; + break; + } + + var parsed = DiscoverHierarchyRequest.Parser.ParseFrom(original.ToByteArray()); + + Assert.Equal(original, parsed); + Assert.Equal(original.RootCase, parsed.RootCase); + Assert.NotEqual(DiscoverHierarchyRequest.RootOneofCase.None, parsed.RootCase); + Assert.NotNull(parsed.MaxDepth); + Assert.Equal(5, parsed.MaxDepth!.Value); + Assert.True(parsed.HasIncludeAttributes); + Assert.True(parsed.IncludeAttributes); + } + + /// + /// Verifies that a DiscoverHierarchyReply round-trips with nested + /// GalaxyObject and GalaxyAttribute graphs. + /// + [Fact] + public void DiscoverHierarchyReply_RoundTripsObjectAndAttributeGraph() + { + var original = new DiscoverHierarchyReply + { + NextPageToken = "page-3", + TotalObjectCount = 2, + Objects = + { + new GalaxyObject + { + GobjectId = 4711, + TagName = "Tank01", + ContainedName = "Tank01", + BrowseName = "Tank 01", + ParentGobjectId = 12, + IsArea = false, + CategoryId = 3, + HostedByGobjectId = 8, + TemplateChain = { "$AnalogDevice", "$Tank" }, + Attributes = + { + new GalaxyAttribute + { + AttributeName = "Level", + FullTagReference = "Galaxy!Tank01.Level", + MxDataType = 3, + DataTypeName = "Float", + IsArray = false, + ArrayDimension = 0, + ArrayDimensionPresent = false, + MxAttributeCategory = 1, + SecurityClassification = 0, + IsHistorized = true, + IsAlarm = true, + }, + }, + }, + new GalaxyObject + { + GobjectId = 12, + TagName = "Area1", + IsArea = true, + }, + }, + }; + + var parsed = DiscoverHierarchyReply.Parser.ParseFrom(original.ToByteArray()); + + Assert.Equal(original, parsed); + Assert.Equal(2, parsed.Objects.Count); + Assert.Single(parsed.Objects[0].Attributes); + Assert.True(parsed.Objects[0].Attributes[0].IsAlarm); + } + + /// Verifies that a DeployEvent round-trips with its timestamp and counters. + [Fact] + public void DeployEvent_RoundTripsTimestampAndCounters() + { + var observed = Timestamp.FromDateTime(new DateTime(2026, 5, 18, 8, 30, 0, DateTimeKind.Utc)); + var deploy = Timestamp.FromDateTime(new DateTime(2026, 5, 18, 8, 0, 0, DateTimeKind.Utc)); + var original = new DeployEvent + { + Sequence = 17, + ObservedAt = observed, + TimeOfLastDeploy = deploy, + TimeOfLastDeployPresent = true, + ObjectCount = 240, + AttributeCount = 3600, + }; + + var parsed = DeployEvent.Parser.ParseFrom(original.ToByteArray()); + + Assert.Equal(original, parsed); + Assert.True(parsed.TimeOfLastDeployPresent); + Assert.Equal(deploy, parsed.TimeOfLastDeploy); + } + + /// Verifies that GetLastDeployTimeReply and TestConnectionReply round-trip. + [Fact] + public void GalaxyConnectionReplies_RoundTrip() + { + var deploy = Timestamp.FromDateTime(new DateTime(2026, 5, 18, 8, 0, 0, DateTimeKind.Utc)); + var lastDeploy = new GetLastDeployTimeReply + { + Present = true, + TimeOfLastDeploy = deploy, + }; + var testConnection = new TestConnectionReply { Ok = true }; + + Assert.Equal(lastDeploy, GetLastDeployTimeReply.Parser.ParseFrom(lastDeploy.ToByteArray())); + Assert.Equal(testConnection, TestConnectionReply.Parser.ParseFrom(testConnection.ToByteArray())); + } + + /// + /// Verifies that a carrying multiple + /// items round-trips, including the + /// per-entry value and user_id fields. + /// + [Fact] + public void WriteBulkCommand_RoundTripsEntries() + { + var original = new MxCommand + { + Kind = MxCommandKind.WriteBulk, + WriteBulk = new WriteBulkCommand + { + ServerHandle = 10, + Entries = + { + new WriteBulkEntry + { + ItemHandle = 21, + UserId = 7, + Value = new MxValue + { + DataType = MxDataType.Float, + FloatValue = 1.25f, + VariantType = "VT_R4", + }, + }, + new WriteBulkEntry + { + ItemHandle = 22, + Value = new MxValue + { + DataType = MxDataType.Integer, + Int32Value = 42, + VariantType = "VT_I4", + }, + }, + }, + }, + }; + + var parsed = MxCommand.Parser.ParseFrom(original.ToByteArray()); + + Assert.Equal(original, parsed); + Assert.Equal(MxCommand.PayloadOneofCase.WriteBulk, parsed.PayloadCase); + Assert.Equal(2, parsed.WriteBulk.Entries.Count); + Assert.Equal(7, parsed.WriteBulk.Entries[0].UserId); + } + + /// + /// Verifies that a round-trips, including + /// the per-entry timestamp_value field that distinguishes Write2 + /// from Write. + /// + [Fact] + public void Write2BulkCommand_RoundTripsEntriesWithTimestampValue() + { + var timestamp = Timestamp.FromDateTime(new DateTime(2026, 5, 19, 11, 0, 0, DateTimeKind.Utc)); + var original = new MxCommand + { + Kind = MxCommandKind.Write2Bulk, + Write2Bulk = new Write2BulkCommand + { + ServerHandle = 10, + Entries = + { + new Write2BulkEntry + { + ItemHandle = 21, + UserId = 7, + Value = new MxValue + { + DataType = MxDataType.Float, + FloatValue = 99.9f, + VariantType = "VT_R4", + }, + TimestampValue = new MxValue + { + DataType = MxDataType.Time, + TimestampValue = timestamp, + VariantType = "VT_DATE", + }, + }, + }, + }, + }; + + var parsed = MxCommand.Parser.ParseFrom(original.ToByteArray()); + + Assert.Equal(original, parsed); + Assert.Equal(MxCommand.PayloadOneofCase.Write2Bulk, parsed.PayloadCase); + Assert.NotNull(parsed.Write2Bulk.Entries[0].TimestampValue); + } + + /// + /// Verifies that a round-trips, + /// pinning the credential-bearing entry shape + /// (current_user_id, verifier_user_id, value). + /// See Contracts-011 for the credential-sensitivity comment on + /// WriteSecuredBulkEntry.value. + /// + [Fact] + public void WriteSecuredBulkCommand_RoundTripsCredentialBearingEntries() + { + var original = new MxCommand + { + Kind = MxCommandKind.WriteSecuredBulk, + WriteSecuredBulk = new WriteSecuredBulkCommand + { + ServerHandle = 10, + Entries = + { + new WriteSecuredBulkEntry + { + ItemHandle = 21, + CurrentUserId = 100, + VerifierUserId = 200, + Value = new MxValue + { + DataType = MxDataType.Float, + FloatValue = 75.0f, + VariantType = "VT_R4", + }, + }, + }, + }, + }; + + var parsed = MxCommand.Parser.ParseFrom(original.ToByteArray()); + + Assert.Equal(original, parsed); + Assert.Equal(MxCommand.PayloadOneofCase.WriteSecuredBulk, parsed.PayloadCase); + Assert.Equal(100, parsed.WriteSecuredBulk.Entries[0].CurrentUserId); + Assert.Equal(200, parsed.WriteSecuredBulk.Entries[0].VerifierUserId); + } + + /// + /// Verifies that a round-trips, + /// including both the credential-sensitive value and the + /// timestamp_value per entry. + /// + [Fact] + public void WriteSecured2BulkCommand_RoundTripsCredentialBearingEntriesWithTimestamp() + { + var timestamp = Timestamp.FromDateTime(new DateTime(2026, 5, 19, 11, 30, 0, DateTimeKind.Utc)); + var original = new MxCommand + { + Kind = MxCommandKind.WriteSecured2Bulk, + WriteSecured2Bulk = new WriteSecured2BulkCommand + { + ServerHandle = 10, + Entries = + { + new WriteSecured2BulkEntry + { + ItemHandle = 21, + CurrentUserId = 100, + VerifierUserId = 200, + Value = new MxValue + { + DataType = MxDataType.Float, + FloatValue = 50.0f, + VariantType = "VT_R4", + }, + TimestampValue = new MxValue + { + DataType = MxDataType.Time, + TimestampValue = timestamp, + VariantType = "VT_DATE", + }, + }, + }, + }, + }; + + var parsed = MxCommand.Parser.ParseFrom(original.ToByteArray()); + + Assert.Equal(original, parsed); + Assert.Equal(MxCommand.PayloadOneofCase.WriteSecured2Bulk, parsed.PayloadCase); + Assert.NotNull(parsed.WriteSecured2Bulk.Entries[0].TimestampValue); + } + + /// + /// Verifies that a round-trips, including + /// the tag_addresses list and the timeout_ms field that + /// distinguishes the cached-vs-snapshot lifecycle. + /// + [Fact] + public void ReadBulkCommand_RoundTripsTagAddressesAndTimeout() + { + var original = new MxCommand + { + Kind = MxCommandKind.ReadBulk, + ReadBulk = new ReadBulkCommand + { + ServerHandle = 10, + TagAddresses = { "Provider!Tank01.Level", "Provider!Tank02.Level" }, + TimeoutMs = 2500, + }, + }; + + var parsed = MxCommand.Parser.ParseFrom(original.ToByteArray()); + + Assert.Equal(original, parsed); + Assert.Equal(MxCommand.PayloadOneofCase.ReadBulk, parsed.PayloadCase); + Assert.Equal(2, parsed.ReadBulk.TagAddresses.Count); + Assert.Equal(2500u, parsed.ReadBulk.TimeoutMs); + } + + /// + /// Verifies that a carrying mixed-outcome + /// entries round-trips and that the + /// proto3 optional int32 hresult presence flag survives both the + /// "hresult set" and "hresult unset" cases. + /// + [Fact] + public void BulkWriteReply_RoundTripsResultsWithOptionalHresultPresence() + { + var original = new BulkWriteReply + { + Results = + { + new BulkWriteResult + { + ServerHandle = 10, + ItemHandle = 21, + WasSuccessful = true, + Hresult = 0, + Statuses = + { + new MxStatusProxy + { + Success = 1, + Category = MxStatusCategory.Ok, + DetectedBy = MxStatusSource.RespondingLmx, + }, + }, + }, + new BulkWriteResult + { + ServerHandle = 10, + ItemHandle = 22, + WasSuccessful = false, + Hresult = unchecked((int)0x80004005), + ErrorMessage = "item not advised", + }, + new BulkWriteResult + { + ServerHandle = 10, + ItemHandle = 23, + WasSuccessful = false, + // Hresult deliberately UNSET — exercises the proto3 + // `optional int32` HasField() = false arm. + ErrorMessage = "tag rejected by allowlist", + }, + }, + }; + + var parsed = BulkWriteReply.Parser.ParseFrom(original.ToByteArray()); + + Assert.Equal(original, parsed); + Assert.Equal(3, parsed.Results.Count); + Assert.True(parsed.Results[0].HasHresult); + Assert.True(parsed.Results[1].HasHresult); + Assert.False(parsed.Results[2].HasHresult); + Assert.True(parsed.Results[0].WasSuccessful); + Assert.False(parsed.Results[2].WasSuccessful); + Assert.Single(parsed.Results[0].Statuses); + } + + /// + /// Verifies that a carrying both cached + /// (was_cached = true) and uncached (was_cached = false) + /// entries round-trips. Pins the + /// deliberate absence of hresult on + /// — failures are carried as was_successful = false plus + /// error_message only. + /// + [Fact] + public void BulkReadReply_RoundTripsCachedAndSnapshotResults() + { + var sourceTimestamp = Timestamp.FromDateTime(new DateTime(2026, 5, 19, 12, 0, 0, DateTimeKind.Utc)); + var original = new BulkReadReply + { + Results = + { + new BulkReadResult + { + ServerHandle = 10, + TagAddress = "Provider!Tank01.Level", + ItemHandle = 21, + WasSuccessful = true, + WasCached = true, + Value = new MxValue + { + DataType = MxDataType.Float, + FloatValue = 42.5f, + VariantType = "VT_R4", + }, + Quality = 192, + SourceTimestamp = sourceTimestamp, + Statuses = + { + new MxStatusProxy + { + Success = 1, + Category = MxStatusCategory.Ok, + DetectedBy = MxStatusSource.RespondingNmx, + }, + }, + }, + new BulkReadResult + { + ServerHandle = 10, + TagAddress = "Provider!Tank02.Level", + ItemHandle = 22, + WasSuccessful = true, + WasCached = false, + Value = new MxValue + { + DataType = MxDataType.Integer, + Int32Value = 0, + VariantType = "VT_I4", + }, + SourceTimestamp = sourceTimestamp, + }, + new BulkReadResult + { + ServerHandle = 10, + TagAddress = "Provider!Bad.Tag", + WasSuccessful = false, + WasCached = false, + ErrorMessage = "snapshot timed out before first OnDataChange", + }, + }, + }; + + var parsed = BulkReadReply.Parser.ParseFrom(original.ToByteArray()); + + Assert.Equal(original, parsed); + Assert.Equal(3, parsed.Results.Count); + Assert.True(parsed.Results[0].WasCached); + Assert.False(parsed.Results[1].WasCached); + Assert.False(parsed.Results[2].WasSuccessful); + Assert.Equal("snapshot timed out before first OnDataChange", parsed.Results[2].ErrorMessage); + // BulkReadResult has no `hresult` field — pin that contract. + Assert.DoesNotContain( + BulkReadResult.Descriptor.Fields.InDeclarationOrder(), + field => field.Name == "hresult"); + } + + /// + /// Verifies that an with each of the new + /// bulk write/read payload oneof cases round-trips and that + /// resolves to the + /// expected value. Pins every new oneof case added by the bulk + /// write/read extension. + /// + [Theory] + [InlineData(MxCommandKind.WriteBulk, MxCommandReply.PayloadOneofCase.WriteBulk)] + [InlineData(MxCommandKind.Write2Bulk, MxCommandReply.PayloadOneofCase.Write2Bulk)] + [InlineData(MxCommandKind.WriteSecuredBulk, MxCommandReply.PayloadOneofCase.WriteSecuredBulk)] + [InlineData(MxCommandKind.WriteSecured2Bulk, MxCommandReply.PayloadOneofCase.WriteSecured2Bulk)] + public void MxCommandReply_RoundTripsBulkWritePayloadCases( + MxCommandKind kind, + MxCommandReply.PayloadOneofCase expectedPayloadCase) + { + var reply = new BulkWriteReply + { + Results = + { + new BulkWriteResult + { + ServerHandle = 5, + ItemHandle = 7, + WasSuccessful = true, + Hresult = 0, + }, + }, + }; + var original = new MxCommandReply + { + SessionId = "session-1", + CorrelationId = "gateway-correlation-bulk-write", + Kind = kind, + ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok }, + Hresult = 0, + }; + switch (expectedPayloadCase) + { + case MxCommandReply.PayloadOneofCase.WriteBulk: + original.WriteBulk = reply; + break; + case MxCommandReply.PayloadOneofCase.Write2Bulk: + original.Write2Bulk = reply; + break; + case MxCommandReply.PayloadOneofCase.WriteSecuredBulk: + original.WriteSecuredBulk = reply; + break; + case MxCommandReply.PayloadOneofCase.WriteSecured2Bulk: + original.WriteSecured2Bulk = reply; + break; + default: + throw new System.ArgumentOutOfRangeException(nameof(expectedPayloadCase)); + } + + var parsed = MxCommandReply.Parser.ParseFrom(original.ToByteArray()); + + Assert.Equal(original, parsed); + Assert.Equal(expectedPayloadCase, parsed.PayloadCase); + Assert.Equal(kind, parsed.Kind); + } + + /// + /// Verifies that an with kind + /// and a populated + /// payload round-trips and resolves to + /// . + /// + [Fact] + public void MxCommandReply_RoundTripsReadBulkPayload() + { + var original = new MxCommandReply + { + SessionId = "session-1", + CorrelationId = "gateway-correlation-read-bulk", + Kind = MxCommandKind.ReadBulk, + ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok }, + Hresult = 0, + ReadBulk = new BulkReadReply + { + Results = + { + new BulkReadResult + { + ServerHandle = 5, + TagAddress = "Provider!Tank01.Level", + ItemHandle = 7, + WasSuccessful = true, + WasCached = true, + Value = new MxValue + { + DataType = MxDataType.Float, + FloatValue = 12.5f, + VariantType = "VT_R4", + }, + }, + }, + }, + }; + + var parsed = MxCommandReply.Parser.ParseFrom(original.ToByteArray()); + + Assert.Equal(original, parsed); + Assert.Equal(MxCommandReply.PayloadOneofCase.ReadBulk, parsed.PayloadCase); + Assert.Single(parsed.ReadBulk.Results); + Assert.True(parsed.ReadBulk.Results[0].WasCached); + } +} diff --git a/src/MxGateway.Tests/Diagnostics/GatewayLogRedactorTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Diagnostics/GatewayLogRedactorTests.cs similarity index 97% rename from src/MxGateway.Tests/Diagnostics/GatewayLogRedactorTests.cs rename to src/ZB.MOM.WW.MxGateway.Tests/Diagnostics/GatewayLogRedactorTests.cs index 145c296..eedd129 100644 --- a/src/MxGateway.Tests/Diagnostics/GatewayLogRedactorTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Diagnostics/GatewayLogRedactorTests.cs @@ -1,6 +1,6 @@ -using MxGateway.Server.Diagnostics; +using ZB.MOM.WW.MxGateway.Server.Diagnostics; -namespace MxGateway.Tests.Diagnostics; +namespace ZB.MOM.WW.MxGateway.Tests.Diagnostics; public sealed class GatewayLogRedactorTests { diff --git a/src/MxGateway.Tests/Galaxy/GalaxyDeployNotifierTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyDeployNotifierTests.cs similarity index 97% rename from src/MxGateway.Tests/Galaxy/GalaxyDeployNotifierTests.cs rename to src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyDeployNotifierTests.cs index 9af1d79..3324efa 100644 --- a/src/MxGateway.Tests/Galaxy/GalaxyDeployNotifierTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyDeployNotifierTests.cs @@ -1,6 +1,6 @@ -using MxGateway.Server.Galaxy; +using ZB.MOM.WW.MxGateway.Server.Galaxy; -namespace MxGateway.Tests.Galaxy; +namespace ZB.MOM.WW.MxGateway.Tests.Galaxy; public sealed class GalaxyDeployNotifierTests { diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyFilterInputSafetyTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyFilterInputSafetyTests.cs new file mode 100644 index 0000000..6ee8cca --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyFilterInputSafetyTests.cs @@ -0,0 +1,329 @@ +using System.Diagnostics; +using Grpc.Core; +using Microsoft.Extensions.Logging.Abstractions; +using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; +using ZB.MOM.WW.MxGateway.Server.Dashboard; +using ZB.MOM.WW.MxGateway.Server.Galaxy; +using ZB.MOM.WW.MxGateway.Server.Grpc; +using ZB.MOM.WW.MxGateway.Server.Security.Authorization; +using ZB.MOM.WW.MxGateway.Tests.TestSupport; + +namespace ZB.MOM.WW.MxGateway.Tests.Galaxy; + +/// +/// Adversarial-input coverage for the Galaxy Repository browse filter layer. +/// +/// Re-triage note (finding Tests-002): the Galaxy Repository's SQL surface +/// (HierarchySql, AttributesSql, SELECT 1, +/// SELECT time_of_last_deploy FROM galaxy) is entirely constant — no +/// field is ever concatenated into a SQL +/// string. All filters (TagNameGlob, RootTagName, category ids, +/// template-chain filters, contained-path roots) are applied in memory by +/// against the cached snapshot, so there is +/// no SQL-injection surface and no LIKE-escaping helper to test. +/// +/// +/// The genuine, testable concern is that adversarial filter strings — SQL +/// metacharacters (', ;) and LIKE-wildcards (%, +/// _) — are treated as opaque literals by the in-memory filter layer: +/// they must never act as wildcards, never throw, and never trigger catastrophic +/// regex backtracking in . +/// +/// +public sealed class GalaxyFilterInputSafetyTests +{ + private static readonly string[] AdversarialInputs = + [ + "'", + "' OR '1'='1", + "'; DROP TABLE gobject;--", + "%", + "_", + "100%_off", + "[abc]", + "Pump'001", + ]; + + public static TheoryData AdversarialInputCases() + { + TheoryData data = []; + foreach (string input in AdversarialInputs) + { + data.Add(input); + } + + return data; + } + + /// + /// Verifies treats SQL metacharacters and + /// LIKE-wildcards as literals — a glob equal to the literal value matches, + /// and the same glob does not spuriously match an unrelated value. + /// + [Theory] + [MemberData(nameof(AdversarialInputCases))] + public void GlobMatcher_TreatsSqlMetacharactersAsLiterals(string input) + { + Assert.True( + GalaxyGlobMatcher.IsMatch(input, input), + $"A glob equal to the literal value should match: {input}"); + Assert.False( + GalaxyGlobMatcher.IsMatch("UnrelatedTagName", input), + $"Adversarial glob must not behave as a wildcard against unrelated text: {input}"); + } + + /// + /// Verifies the SQL LIKE wildcards % and _ are NOT treated as + /// wildcards by the glob matcher; only * and ? are glob wildcards. + /// + [Fact] + public void GlobMatcher_DoesNotTreatLikeWildcardsAsWildcards() + { + // '%' would match anything if interpreted as a SQL LIKE wildcard. + Assert.False(GalaxyGlobMatcher.IsMatch("Pump_001", "%")); + // '_' would match a single character if interpreted as a SQL LIKE wildcard. + Assert.False(GalaxyGlobMatcher.IsMatch("A", "_")); + Assert.True(GalaxyGlobMatcher.IsMatch("_", "_")); + // '*' and '?' remain glob wildcards. + Assert.True(GalaxyGlobMatcher.IsMatch("Pump_001", "Pump*")); + Assert.True(GalaxyGlobMatcher.IsMatch("Pump_001", "Pump_00?")); + } + + /// + /// Regression guard for finding Server-008: caches + /// the compiled regex per glob pattern. Repeated calls with the same pattern, and + /// interleaved calls with different patterns, must keep returning the correct + /// literal-vs-wildcard result rather than a stale cached match. + /// + [Fact] + public void GlobMatcher_RepeatedAndInterleavedPatterns_StayCorrect() + { + for (int i = 0; i < 5; i++) + { + Assert.True(GalaxyGlobMatcher.IsMatch("Pump_001", "Pump_*")); + Assert.False(GalaxyGlobMatcher.IsMatch("Valve_001", "Pump_*")); + Assert.True(GalaxyGlobMatcher.IsMatch("Valve_001", "Valve_00?")); + Assert.False(GalaxyGlobMatcher.IsMatch("Pump_001", "Valve_00?")); + // A glob equal to a SQL metacharacter still matches only its literal. + Assert.True(GalaxyGlobMatcher.IsMatch("%", "%")); + Assert.False(GalaxyGlobMatcher.IsMatch("anything", "%")); + } + } + + /// + /// Regression guard for finding Server-018: 's + /// internal compiled-regex cache must stay bounded so a client cannot grow it + /// without limit by submitting unique TagNameGlob values over the + /// process lifetime. Feeding the matcher far more distinct globs than the cap + /// must leave CurrentCacheSize at or below RegexCacheCapacity. + /// + [Fact] + public void GlobMatcher_WithManyDistinctPatterns_CacheStaysBounded() + { + // Submit well past the cap from a single thread to exercise the eviction path + // deterministically. The cap is internal; assert on it directly so the test + // tracks the source of truth. + int submissions = GalaxyGlobMatcher.RegexCacheCapacity * 4; + for (int i = 0; i < submissions; i++) + { + string uniqueGlob = $"client_supplied_{i}_*"; + GalaxyGlobMatcher.IsMatch($"client_supplied_{i}_thing", uniqueGlob); + } + + Assert.InRange(GalaxyGlobMatcher.CurrentCacheSize, 0, GalaxyGlobMatcher.RegexCacheCapacity); + } + + /// + /// Verifies a pathological glob does not cause catastrophic regex backtracking — + /// escapes every literal character and applies a + /// 100 ms regex timeout, so a long adversarial input completes promptly. + /// + [Fact] + public void GlobMatcher_WithPathologicalInput_DoesNotHang() + { + string pathologicalGlob = new string('a', 5000) + "!"; + string pathologicalValue = new string('a', 5000); + + Stopwatch stopwatch = Stopwatch.StartNew(); + bool matched = GalaxyGlobMatcher.IsMatch(pathologicalValue, pathologicalGlob); + stopwatch.Stop(); + + Assert.False(matched); + Assert.True( + stopwatch.Elapsed < TimeSpan.FromSeconds(2), + $"Glob matching took {stopwatch.ElapsedMilliseconds} ms — expected sub-second."); + } + + /// + /// Verifies the TagNameGlob filter + /// treats an adversarial glob as a literal: it never wildcard-matches the whole + /// hierarchy and never throws. + /// + [Theory] + [MemberData(nameof(AdversarialInputCases))] + public void Projector_TagNameGlob_WithAdversarialInput_DoesNotMatchEverything(string glob) + { + GalaxyHierarchyCacheEntry entry = CreateEntry(CreateObjects()); + + GalaxyHierarchyQueryResult result = GalaxyHierarchyProjector.Project( + entry, + new DiscoverHierarchyRequest { TagNameGlob = glob }); + + // None of the seeded tag names equal an adversarial string, so a correctly + // literal filter returns zero matches rather than the whole hierarchy. + Assert.Equal(0, result.TotalObjectCount); + Assert.Empty(result.Objects); + } + + /// + /// Verifies an adversarial RootTagName resolves through the projector as a + /// literal — an exact-match lookup that finds nothing and surfaces NotFound, + /// never matching unrelated objects or throwing an unexpected exception. + /// + [Theory] + [MemberData(nameof(AdversarialInputCases))] + public void Projector_RootTagName_WithAdversarialInput_ThrowsNotFound(string rootTagName) + { + GalaxyHierarchyCacheEntry entry = CreateEntry(CreateObjects()); + + RpcException exception = Assert.Throws( + () => GalaxyHierarchyProjector.Project( + entry, + new DiscoverHierarchyRequest { RootTagName = rootTagName })); + + Assert.Equal(StatusCode.NotFound, exception.StatusCode); + } + + /// + /// Verifies an adversarial TemplateChainContains filter is a literal + /// substring test — it never matches unrelated template chains and never throws. + /// + [Theory] + [MemberData(nameof(AdversarialInputCases))] + public void Projector_TemplateChainContains_WithAdversarialInput_MatchesNothing(string filter) + { + GalaxyHierarchyCacheEntry entry = CreateEntry(CreateObjects()); + DiscoverHierarchyRequest request = new(); + request.TemplateChainContains.Add(filter); + + GalaxyHierarchyQueryResult result = GalaxyHierarchyProjector.Project(entry, request); + + Assert.Equal(0, result.TotalObjectCount); + } + + /// + /// Verifies the RPC + /// handles an adversarial TagNameGlob end-to-end: the request succeeds with + /// zero matches rather than returning the whole hierarchy or faulting. + /// + [Theory] + [MemberData(nameof(AdversarialInputCases))] + public async Task DiscoverHierarchy_WithAdversarialTagNameGlob_ReturnsZeroMatches(string glob) + { + GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateObjects())); + + DiscoverHierarchyReply reply = await service.DiscoverHierarchy( + new DiscoverHierarchyRequest { TagNameGlob = glob, PageSize = 100 }, + new TestServerCallContext()); + + Assert.Equal(0, reply.TotalObjectCount); + Assert.Empty(reply.Objects); + } + + /// + /// Verifies the RPC + /// maps an adversarial RootTagName to NotFound rather than executing it as + /// a query fragment or matching unrelated objects. + /// + [Theory] + [MemberData(nameof(AdversarialInputCases))] + public async Task DiscoverHierarchy_WithAdversarialRootTagName_ReturnsNotFound(string rootTagName) + { + GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateObjects())); + + RpcException exception = await Assert.ThrowsAsync( + async () => await service.DiscoverHierarchy( + new DiscoverHierarchyRequest { RootTagName = rootTagName, PageSize = 100 }, + new TestServerCallContext())); + + Assert.Equal(StatusCode.NotFound, exception.StatusCode); + } + + private static GalaxyRepositoryGrpcService CreateService(GalaxyHierarchyCacheEntry entry) + { + GalaxyRepositoryOptions options = new() + { + ConnectionString = "Server=localhost;Database=ZB;Integrated Security=True;Encrypt=False;", + }; + return new GalaxyRepositoryGrpcService( + new ZB.MOM.WW.MxGateway.Server.Galaxy.GalaxyRepository(options), + new StubGalaxyHierarchyCache(entry), + new GalaxyDeployNotifier(), + new GatewayRequestIdentityAccessor(), + NullLogger.Instance); + } + + private static GalaxyHierarchyCacheEntry CreateEntry(IReadOnlyList objects) + { + return GalaxyHierarchyCacheEntry.Empty with + { + Status = GalaxyCacheStatus.Healthy, + Sequence = 1, + LastSuccessAt = DateTimeOffset.UtcNow, + Objects = objects, + Index = GalaxyHierarchyIndex.Build(objects), + DashboardSummary = DashboardGalaxySummary.Unknown with + { + Status = DashboardGalaxyStatus.Healthy, + ObjectCount = objects.Count, + }, + ObjectCount = objects.Count, + }; + } + + private static IReadOnlyList CreateObjects() + { + return + [ + new GalaxyObject + { + GobjectId = 1, + TagName = "Area1", + ContainedName = "Area1", + BrowseName = "Area1", + IsArea = true, + CategoryId = 13, + }, + new GalaxyObject + { + GobjectId = 2, + TagName = "Pump_001", + ContainedName = "Pump", + BrowseName = "Pump_001", + ParentGobjectId = 1, + CategoryId = 10, + TemplateChain = { "$Pump", "$Base" }, + }, + new GalaxyObject + { + GobjectId = 3, + TagName = "Valve_001", + ContainedName = "Valve", + BrowseName = "Valve_001", + ParentGobjectId = 1, + CategoryId = 11, + TemplateChain = { "$Valve" }, + }, + ]; + } + + private sealed class StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry current) : IGalaxyHierarchyCache + { + public GalaxyHierarchyCacheEntry Current { get; } = current; + + public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask; + } + +} diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchyCacheTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchyCacheTests.cs new file mode 100644 index 0000000..e4f54c2 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchyCacheTests.cs @@ -0,0 +1,503 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using ZB.MOM.WW.MxGateway.Server.Galaxy; +using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; +using ZB.MOM.WW.MxGateway.Tests.TestSupport; + +namespace ZB.MOM.WW.MxGateway.Tests.Galaxy; + +public sealed class GalaxyHierarchyCacheTests : IDisposable +{ + private readonly List _tempPaths = []; + + /// + /// Verifies cache returns empty entry before any refresh occurs. + /// + [Fact] + public void Current_BeforeAnyRefresh_ReturnsEmpty() + { + GalaxyDeployNotifier notifier = new(); + ThrowingGalaxyRepository repository = new(new InvalidOperationException("not invoked")); + GalaxyHierarchyCache cache = new(repository, notifier, new ManualTimeProvider()); + + GalaxyHierarchyCacheEntry entry = cache.Current; + + Assert.Equal(GalaxyCacheStatus.Unknown, entry.Status); + Assert.False(entry.HasData); + Assert.Equal(0, entry.ObjectCount); + Assert.Empty(entry.Objects); + } + + /// + /// Verifies cache marks unavailable and does not publish when the repository + /// surface throws — the production trigger for this code path is a SQL + /// connection failure, but it is fully covered by the cache's exception + /// branch and does not require a real TCP probe from a unit test. + /// + [Fact] + public async Task RefreshAsync_WhenRepositoryThrows_MarksUnavailableAndDoesNotPublish() + { + GalaxyDeployNotifier notifier = new(); + ManualTimeProvider clock = new(DateTimeOffset.Parse("2026-04-28T12:00:00Z", System.Globalization.CultureInfo.InvariantCulture)); + ThrowingGalaxyRepository repository = new(new InvalidOperationException("Galaxy repository unreachable")); + GalaxyHierarchyCache cache = new(repository, notifier, clock); + + await cache.RefreshAsync(CancellationToken.None); + + Assert.Equal(GalaxyCacheStatus.Unavailable, cache.Current.Status); + Assert.Equal("Galaxy repository unreachable", cache.Current.LastError); + Assert.Null(notifier.Latest); + Assert.True(cache.WaitForFirstLoadAsync(CancellationToken.None).IsCompletedSuccessfully); + Assert.Equal(1, repository.GetLastDeployTimeCount); + Assert.Equal(0, repository.GetHierarchyCount); + Assert.Equal(0, repository.GetAttributesCount); + } + + /// + /// Verifies HasData returns true for healthy cache entries. + /// + [Fact] + public void HasData_OnHealthyEntry_IsTrue() + { + GalaxyHierarchyCacheEntry entry = GalaxyHierarchyCacheEntry.Empty with + { + Status = GalaxyCacheStatus.Healthy, + LastSuccessAt = DateTimeOffset.UtcNow, + ObjectCount = 1, + }; + + Assert.True(entry.HasData); + } + + /// + /// Verifies HasData returns false for unknown cache entries. + /// + [Fact] + public void HasData_OnUnknownEntry_IsFalse() + { + Assert.False(GalaxyHierarchyCacheEntry.Empty.HasData); + } + + [Fact] + public void GalaxyHierarchyIndex_BuildsPathsAndTagLookupsWithoutThrowingOnBadMetadata() + { + GalaxyObject root = new() + { + GobjectId = 1, + TagName = "Area1", + ContainedName = "Area1", + }; + GalaxyObject duplicate = new() + { + GobjectId = 1, + TagName = "DuplicateArea", + ContainedName = "DuplicateArea", + }; + GalaxyObject child = new() + { + GobjectId = 2, + ParentGobjectId = 1, + TagName = "Pump_001", + ContainedName = "Pump", + Attributes = + { + new GalaxyAttribute + { + FullTagReference = "Pump_001.PV", + IsHistorized = true, + }, + }, + }; + GalaxyObject orphan = new() + { + GobjectId = 3, + ParentGobjectId = 99, + TagName = "Orphan_001", + ContainedName = "Orphan", + }; + + GalaxyHierarchyIndex index = GalaxyHierarchyIndex.Build([root, duplicate, child, orphan]); + + Assert.Equal("Area1/Pump", index.ObjectViewsById[2].ContainedPath); + Assert.Equal("Orphan", index.ObjectViewsById[3].ContainedPath); + Assert.Same(child, index.TagsByAddress["Pump_001.PV"].Object); + Assert.NotNull(index.TagsByAddress["Pump_001.PV"].Attribute); + Assert.Same(root, index.ObjectViewsById[1].Object); + } + + /// + /// Verifies a successful refresh writes the browse dataset to the on-disk + /// snapshot store so a later cold start can restore it. + /// + [Fact] + public async Task RefreshAsync_WhenSuccessful_PersistsSnapshotToDisk() + { + GalaxyDeployNotifier notifier = new(); + StubGalaxyRepository repository = new( + deployTime: new DateTime(2026, 5, 20, 9, 0, 0, DateTimeKind.Utc), + hierarchy: [SampleHierarchyRow()], + attributes: [SampleAttributeRow()]); + GalaxyHierarchySnapshotStore store = CreateStore(); + GalaxyHierarchyCache cache = new(repository, notifier, new ManualTimeProvider(), snapshotStore: store); + + await cache.RefreshAsync(CancellationToken.None); + + Assert.Equal(GalaxyCacheStatus.Healthy, cache.Current.Status); + GalaxyHierarchySnapshot? persisted = await store.TryLoadAsync(CancellationToken.None); + Assert.NotNull(persisted); + Assert.Equal(99, Assert.Single(persisted.Hierarchy).GobjectId); + Assert.Equal("PV", Assert.Single(persisted.Attributes).AttributeName); + } + + /// + /// Verifies that when the Galaxy database is unreachable on first refresh but a + /// snapshot exists on disk, the cache serves that data with Stale status + /// rather than coming up empty. + /// + [Fact] + public async Task RefreshAsync_WhenDatabaseUnreachableButSnapshotOnDisk_RestoresStaleData() + { + GalaxyHierarchySnapshotStore store = CreateStore(); + await store.SaveAsync( + new GalaxyHierarchySnapshot( + LastDeployTime: new DateTimeOffset(2026, 5, 20, 9, 0, 0, TimeSpan.Zero), + SavedAt: new DateTimeOffset(2026, 5, 20, 9, 1, 0, TimeSpan.Zero), + Hierarchy: [SampleHierarchyRow()], + Attributes: [SampleAttributeRow()]), + CancellationToken.None); + + GalaxyDeployNotifier notifier = new(); + ThrowingGalaxyRepository repository = new(new InvalidOperationException("Galaxy repository unreachable")); + GalaxyHierarchyCache cache = new(repository, notifier, new ManualTimeProvider(), snapshotStore: store); + + await cache.RefreshAsync(CancellationToken.None); + + Assert.Equal(GalaxyCacheStatus.Stale, cache.Current.Status); + Assert.True(cache.Current.HasData); + Assert.Equal(1, cache.Current.ObjectCount); + Assert.Equal(1, cache.Current.AttributeCount); + Assert.NotNull(notifier.Latest); + } + + /// + /// Verifies that when the disk snapshot's deploy time still matches the live + /// Galaxy database, the cache promotes the restored data to Healthy + /// without re-running the heavy hierarchy and attribute queries. + /// + [Fact] + public async Task RefreshAsync_WhenSnapshotDeployMatchesLive_PromotesToHealthyWithoutHeavyQuery() + { + DateTime deployTime = new(2026, 5, 20, 9, 0, 0, DateTimeKind.Utc); + GalaxyHierarchySnapshotStore store = CreateStore(); + await store.SaveAsync( + new GalaxyHierarchySnapshot( + LastDeployTime: new DateTimeOffset(deployTime, TimeSpan.Zero), + SavedAt: new DateTimeOffset(2026, 5, 20, 9, 1, 0, TimeSpan.Zero), + Hierarchy: [SampleHierarchyRow()], + Attributes: [SampleAttributeRow()]), + CancellationToken.None); + + GalaxyDeployNotifier notifier = new(); + StubGalaxyRepository repository = new(deployTime); + GalaxyHierarchyCache cache = new(repository, notifier, new ManualTimeProvider(), snapshotStore: store); + + await cache.RefreshAsync(CancellationToken.None); + + Assert.Equal(GalaxyCacheStatus.Healthy, cache.Current.Status); + Assert.Equal(1, cache.Current.ObjectCount); + Assert.Equal(0, repository.GetHierarchyCount); + Assert.Equal(0, repository.GetAttributesCount); + } + + /// + /// Verifies that a restored on-disk snapshot completes the first-load gate + /// immediately, so a browse call racing the first refresh is not blocked for + /// the full bootstrap budget while the live Galaxy query is still running. + /// Regression test for Server-033. + /// + [Fact] + public async Task RefreshAsync_RestoredSnapshotCompletesFirstLoadBeforeLiveQueryReturns() + { + GalaxyHierarchySnapshotStore store = CreateStore(); + await store.SaveAsync( + new GalaxyHierarchySnapshot( + LastDeployTime: new DateTimeOffset(2026, 5, 20, 9, 0, 0, TimeSpan.Zero), + SavedAt: new DateTimeOffset(2026, 5, 20, 9, 1, 0, TimeSpan.Zero), + Hierarchy: [SampleHierarchyRow()], + Attributes: [SampleAttributeRow()]), + CancellationToken.None); + + GalaxyDeployNotifier notifier = new(); + BlockingGalaxyRepository repository = new(); + GalaxyHierarchyCache cache = new(repository, notifier, new ManualTimeProvider(), snapshotStore: store); + + Task refresh = cache.RefreshAsync(CancellationToken.None); + + // The live query is blocked inside the repository; first-load must still + // complete — from the restored snapshot — well within the wait budget. + await cache.WaitForFirstLoadAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(5)); + Assert.True(cache.Current.HasData); + Assert.Equal(GalaxyCacheStatus.Stale, cache.Current.Status); + + repository.Release(); + await refresh.WaitAsync(TimeSpan.FromSeconds(5)); + } + + /// + /// Verifies a corrupt on-disk snapshot does not crash startup: the cache + /// ignores the unreadable file and comes up Unavailable when the database is + /// also unreachable. Regression test for Server-037. + /// + [Fact] + public async Task RefreshAsync_WhenSnapshotFileCorrupt_ComesUpUnavailableWithoutThrowing() + { + string path = CreateTempPath(); + await File.WriteAllTextAsync(path, "{ this is not valid json"); + GalaxyHierarchySnapshotStore store = CreateStore(path); + + GalaxyDeployNotifier notifier = new(); + ThrowingGalaxyRepository repository = new(new InvalidOperationException("Galaxy repository unreachable")); + GalaxyHierarchyCache cache = new(repository, notifier, new ManualTimeProvider(), snapshotStore: store); + + await cache.RefreshAsync(CancellationToken.None); + + Assert.Equal(GalaxyCacheStatus.Unavailable, cache.Current.Status); + Assert.False(cache.Current.HasData); + } + + /// + /// Verifies that with snapshot persistence disabled the cache does not + /// restore from disk — an unreachable database leaves it Unavailable. + /// Regression test for Server-037. + /// + [Fact] + public async Task RefreshAsync_WhenPersistDisabled_DoesNotRestoreFromDisk() + { + GalaxyHierarchySnapshotStore store = CreateStore(CreateTempPath(), persist: false); + + GalaxyDeployNotifier notifier = new(); + ThrowingGalaxyRepository repository = new(new InvalidOperationException("Galaxy repository unreachable")); + GalaxyHierarchyCache cache = new(repository, notifier, new ManualTimeProvider(), snapshotStore: store); + + await cache.RefreshAsync(CancellationToken.None); + + Assert.Equal(GalaxyCacheStatus.Unavailable, cache.Current.Status); + Assert.False(cache.Current.HasData); + } + + /// + /// Verifies that a snapshot save aborted because the gateway is shutting down + /// (the refresh token is cancelled) is not logged as a persistence failure. + /// Regression test for Server-036. + /// + [Fact] + public async Task RefreshAsync_WhenSnapshotSaveCancelledAtShutdown_DoesNotLogPersistFailure() + { + using CancellationTokenSource cts = new(); + GalaxyDeployNotifier notifier = new(); + StubGalaxyRepository repository = new( + deployTime: new DateTime(2026, 5, 20, 9, 0, 0, DateTimeKind.Utc), + hierarchy: [SampleHierarchyRow()], + attributes: [SampleAttributeRow()]); + CancellingSaveStore store = new(cts); + RecordingLogger logger = new(); + GalaxyHierarchyCache cache = new(repository, notifier, new ManualTimeProvider(), logger, store); + + await cache.RefreshAsync(cts.Token); + + Assert.DoesNotContain( + logger.Entries, + entry => entry.Level == LogLevel.Warning + && entry.Message.Contains("persist", StringComparison.OrdinalIgnoreCase)); + } + + private static GalaxyHierarchyRow SampleHierarchyRow() => new() + { + GobjectId = 99, + TagName = "Pump_001", + ContainedName = "Pump", + BrowseName = "Pump", + CategoryId = 10, + TemplateChain = ["AppPump"], + }; + + private static GalaxyAttributeRow SampleAttributeRow() => new() + { + GobjectId = 99, + TagName = "Pump_001", + AttributeName = "PV", + FullTagReference = "Pump_001.PV", + MxDataType = 5, + DataTypeName = "Float", + }; + + private string CreateTempPath() + { + string path = Path.Combine( + Path.GetTempPath(), + $"mxgw-galaxy-cache-test-{Guid.NewGuid():N}.json"); + _tempPaths.Add(path); + return path; + } + + private GalaxyHierarchySnapshotStore CreateStore() => CreateStore(CreateTempPath()); + + private static GalaxyHierarchySnapshotStore CreateStore(string path, bool persist = true) + { + GalaxyRepositoryOptions options = new() + { + PersistSnapshot = persist, + SnapshotCachePath = path, + }; + return new GalaxyHierarchySnapshotStore(Options.Create(options)); + } + + /// whose deploy-time query blocks until released. + private sealed class BlockingGalaxyRepository : IGalaxyRepository + { + private readonly TaskCompletionSource _release = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public void Release() => _release.TrySetResult(); + + public Task TestConnectionAsync(CancellationToken ct = default) => Task.FromResult(false); + + public async Task GetLastDeployTimeAsync(CancellationToken ct = default) + { + await _release.Task.WaitAsync(ct).ConfigureAwait(false); + throw new InvalidOperationException("Galaxy repository unreachable"); + } + + public Task> GetHierarchyAsync(CancellationToken ct = default) + => throw new InvalidOperationException("GetHierarchyAsync should not be reached"); + + public Task> GetAttributesAsync(CancellationToken ct = default) + => throw new InvalidOperationException("GetAttributesAsync should not be reached"); + } + + /// Snapshot store whose cancels the token mid-save. + private sealed class CancellingSaveStore(CancellationTokenSource cts) : IGalaxyHierarchySnapshotStore + { + public Task TryLoadAsync(CancellationToken cancellationToken) + => Task.FromResult(null); + + public Task SaveAsync(GalaxyHierarchySnapshot snapshot, CancellationToken cancellationToken) + { + cts.Cancel(); + cancellationToken.ThrowIfCancellationRequested(); + return Task.CompletedTask; + } + } + + /// Minimal that records every emitted log entry. + private sealed class RecordingLogger : ILogger + { + public List<(LogLevel Level, string Message)> Entries { get; } = []; + + public IDisposable BeginScope(TState state) + where TState : notnull => NullScope.Instance; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + Entries.Add((logLevel, formatter(state, exception))); + } + + private sealed class NullScope : IDisposable + { + public static readonly NullScope Instance = new(); + + public void Dispose() + { + } + } + } + + /// In-memory that returns fixed rowsets. + private sealed class StubGalaxyRepository( + DateTime? deployTime, + List? hierarchy = null, + List? attributes = null) : IGalaxyRepository + { + private readonly List _hierarchy = hierarchy ?? []; + private readonly List _attributes = attributes ?? []; + + public int GetHierarchyCount { get; private set; } + + public int GetAttributesCount { get; private set; } + + public Task TestConnectionAsync(CancellationToken ct = default) => Task.FromResult(true); + + public Task GetLastDeployTimeAsync(CancellationToken ct = default) => Task.FromResult(deployTime); + + public Task> GetHierarchyAsync(CancellationToken ct = default) + { + GetHierarchyCount++; + return Task.FromResult(_hierarchy); + } + + public Task> GetAttributesAsync(CancellationToken ct = default) + { + GetAttributesCount++; + return Task.FromResult(_attributes); + } + } + + public void Dispose() + { + foreach (string path in _tempPaths) + { + try + { + File.Delete(path); + File.Delete(path + ".tmp"); + } + catch (IOException) + { + // Best-effort cleanup of test scratch files. + } + } + } + + private sealed class ThrowingGalaxyRepository(Exception toThrow) : IGalaxyRepository + { + /// Gets the number of times was called. + public int GetLastDeployTimeCount { get; private set; } + + /// Gets the number of times was called. + public int GetHierarchyCount { get; private set; } + + /// Gets the number of times was called. + public int GetAttributesCount { get; private set; } + + /// + public Task TestConnectionAsync(CancellationToken ct = default) => Task.FromResult(false); + + /// + public Task GetLastDeployTimeAsync(CancellationToken ct = default) + { + GetLastDeployTimeCount++; + throw toThrow; + } + + /// + public Task> GetHierarchyAsync(CancellationToken ct = default) + { + GetHierarchyCount++; + throw toThrow; + } + + /// + public Task> GetAttributesAsync(CancellationToken ct = default) + { + GetAttributesCount++; + throw toThrow; + } + } + +} diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchyProjectorTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchyProjectorTests.cs new file mode 100644 index 0000000..f596c7d --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchyProjectorTests.cs @@ -0,0 +1,136 @@ +using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; +using ZB.MOM.WW.MxGateway.Server.Dashboard; +using ZB.MOM.WW.MxGateway.Server.Galaxy; + +namespace ZB.MOM.WW.MxGateway.Tests.Galaxy; + +/// +/// Direct coverage for paging. +/// +/// Regression guard for finding Server-007: the projector memoizes the filtered, +/// ordered view list per (cache entry, filter signature) so paging is an +/// O(pageSize) slice rather than an O(total) re-scan per page. These tests confirm +/// the memo does not change paging results, does not bleed between distinct filter +/// signatures, and is scoped to a single cache-entry instance. +/// +/// +public sealed class GalaxyHierarchyProjectorTests +{ + [Fact] + public void Project_PagedAcrossEntireHierarchy_ReturnsEveryObjectExactlyOnce() + { + GalaxyHierarchyCacheEntry entry = CreateEntry(CreateObjects(25)); + + List collected = []; + int totalReported = -1; + for (int offset = 0; offset < 25; offset += 4) + { + GalaxyHierarchyQueryResult result = GalaxyHierarchyProjector.Project( + entry, + new DiscoverHierarchyRequest(), + browseSubtreeGlobs: null, + offset, + pageSize: 4); + + totalReported = result.TotalObjectCount; + collected.AddRange(result.Objects.Select(obj => obj.TagName)); + } + + Assert.Equal(25, totalReported); + Assert.Equal(25, collected.Count); + Assert.Equal(collected.Count, collected.Distinct(StringComparer.Ordinal).Count()); + Assert.Equal("Object_001", collected[0]); + Assert.Equal("Object_025", collected[^1]); + } + + [Fact] + public void Project_DistinctFiltersOnSameEntry_DoNotShareMemoizedViewList() + { + GalaxyHierarchyCacheEntry entry = CreateEntry(CreateObjects(10)); + + GalaxyHierarchyQueryResult globbed = GalaxyHierarchyProjector.Project( + entry, + new DiscoverHierarchyRequest { TagNameGlob = "Object_00?" }); + GalaxyHierarchyQueryResult unfiltered = GalaxyHierarchyProjector.Project( + entry, + new DiscoverHierarchyRequest()); + + // Distinct filter signatures must each get their own filtered list. + Assert.Equal(9, globbed.TotalObjectCount); + Assert.Equal(10, unfiltered.TotalObjectCount); + } + + [Fact] + public void Project_SameFilterRepeated_ReturnsIdenticalTotals() + { + GalaxyHierarchyCacheEntry entry = CreateEntry(CreateObjects(12)); + + GalaxyHierarchyQueryResult first = GalaxyHierarchyProjector.Project( + entry, + new DiscoverHierarchyRequest(), + browseSubtreeGlobs: null, + offset: 0, + pageSize: 5); + GalaxyHierarchyQueryResult second = GalaxyHierarchyProjector.Project( + entry, + new DiscoverHierarchyRequest(), + browseSubtreeGlobs: null, + offset: 5, + pageSize: 5); + + Assert.Equal(first.TotalObjectCount, second.TotalObjectCount); + Assert.Equal(first.FilterSignature, second.FilterSignature); + Assert.Equal(5, first.Objects.Count); + Assert.Equal(5, second.Objects.Count); + Assert.NotEqual(first.Objects[0].TagName, second.Objects[0].TagName); + } + + [Fact] + public void Project_DistinctCacheEntries_ProjectAgainstTheirOwnData() + { + GalaxyHierarchyCacheEntry small = CreateEntry(CreateObjects(3)); + GalaxyHierarchyCacheEntry large = CreateEntry(CreateObjects(40)); + + GalaxyHierarchyQueryResult smallResult = GalaxyHierarchyProjector.Project( + small, + new DiscoverHierarchyRequest()); + GalaxyHierarchyQueryResult largeResult = GalaxyHierarchyProjector.Project( + large, + new DiscoverHierarchyRequest()); + + // Each entry instance keys its own memo; the second projection must not reuse the + // first entry's filtered view list. + Assert.Equal(3, smallResult.TotalObjectCount); + Assert.Equal(40, largeResult.TotalObjectCount); + } + + private static GalaxyHierarchyCacheEntry CreateEntry(IReadOnlyList objects) + { + return GalaxyHierarchyCacheEntry.Empty with + { + Status = GalaxyCacheStatus.Healthy, + Sequence = 1, + LastSuccessAt = DateTimeOffset.UtcNow, + Objects = objects, + Index = GalaxyHierarchyIndex.Build(objects), + DashboardSummary = DashboardGalaxySummary.Unknown with + { + Status = DashboardGalaxyStatus.Healthy, + ObjectCount = objects.Count, + }, + ObjectCount = objects.Count, + }; + } + + private static IReadOnlyList CreateObjects(int count) + { + return Enumerable.Range(1, count) + .Select(index => new GalaxyObject + { + GobjectId = index, + TagName = $"Object_{index:000}", + BrowseName = $"Object_{index:000}", + }) + .ToArray(); + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchyRefreshServiceTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchyRefreshServiceTests.cs new file mode 100644 index 0000000..27261d6 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchyRefreshServiceTests.cs @@ -0,0 +1,81 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using ZB.MOM.WW.MxGateway.Server.Galaxy; + +namespace ZB.MOM.WW.MxGateway.Tests.Galaxy; + +/// +/// Server-005 regression: the initial RefreshAsync call in +/// must not let a transient, +/// non-cancellation first-load failure (e.g. a +/// or from connection +/// establishment) escape and fault the host BackgroundService. +/// +public sealed class GalaxyHierarchyRefreshServiceTests +{ + [Fact] + public async Task ExecuteAsync_WhenFirstRefreshThrowsNonCancellationException_DoesNotFaultBackgroundService() + { + ThrowingCache cache = new(new TimeoutException("connection establishment timed out")); + GalaxyHierarchyRefreshService service = CreateService(cache); + + using CancellationTokenSource cts = new(); + + await service.StartAsync(cts.Token); + + // Wait until the first RefreshAsync has actually been attempted (and + // thrown) before cancelling, so cancellation cannot race ahead of the + // first-load path under test — this is what made the test flaky under + // parallel load. + await cache.FirstRefreshAttempted.WaitAsync(TimeSpan.FromSeconds(10)); + + await cts.CancelAsync(); + + // The background loop must have stopped cleanly: ExecuteTask reaches a + // terminal state that is not Faulted (RanToCompletion or Canceled) + // rather than faulting on the first refresh. WhenAny is used so a + // Canceled task does not rethrow before the IsFaulted assertion. + Task? executeTask = service.ExecuteTask; + Assert.NotNull(executeTask); + Task completed = await Task.WhenAny(executeTask, Task.Delay(TimeSpan.FromSeconds(10))); + Assert.Same(executeTask, completed); + Assert.False(executeTask.IsFaulted); + Assert.Equal(1, cache.RefreshCallCount); + + await service.StopAsync(CancellationToken.None); + } + + private static GalaxyHierarchyRefreshService CreateService(IGalaxyHierarchyCache cache) + { + GalaxyRepositoryOptions options = new() + { + DashboardRefreshIntervalSeconds = 3600, + }; + return new GalaxyHierarchyRefreshService( + cache, + Options.Create(options), + NullLogger.Instance); + } + + private sealed class ThrowingCache(Exception toThrow) : IGalaxyHierarchyCache + { + private readonly TaskCompletionSource firstRefreshAttempted = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public int RefreshCallCount { get; private set; } + + /// Completes once has been invoked at least once. + public Task FirstRefreshAttempted => firstRefreshAttempted.Task; + + public GalaxyHierarchyCacheEntry Current => GalaxyHierarchyCacheEntry.Empty; + + public Task RefreshAsync(CancellationToken cancellationToken) + { + RefreshCallCount++; + firstRefreshAttempted.TrySetResult(); + throw toThrow; + } + + public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask; + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchySnapshotStoreTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchySnapshotStoreTests.cs new file mode 100644 index 0000000..911ba28 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchySnapshotStoreTests.cs @@ -0,0 +1,177 @@ +using Microsoft.Extensions.Options; +using ZB.MOM.WW.MxGateway.Server.Galaxy; + +namespace ZB.MOM.WW.MxGateway.Tests.Galaxy; + +/// +/// Covers : the on-disk persistence +/// that lets the Galaxy browse cache survive a cold start while the Galaxy +/// database is unreachable. +/// +public sealed class GalaxyHierarchySnapshotStoreTests : IDisposable +{ + private readonly List _tempPaths = []; + + [Fact] + public async Task SaveAsync_ThenTryLoadAsync_RoundTripsRows() + { + string path = CreateTempPath(); + GalaxyHierarchySnapshotStore store = CreateStore(path); + GalaxyHierarchySnapshot snapshot = SampleSnapshot(); + + await store.SaveAsync(snapshot, CancellationToken.None); + GalaxyHierarchySnapshot? loaded = await store.TryLoadAsync(CancellationToken.None); + + Assert.NotNull(loaded); + Assert.Equal(snapshot.LastDeployTime, loaded.LastDeployTime); + Assert.Equal(snapshot.SavedAt, loaded.SavedAt); + + GalaxyHierarchyRow row = Assert.Single(loaded.Hierarchy); + Assert.Equal(7, row.GobjectId); + Assert.Equal("Pump_001", row.TagName); + Assert.Equal(["AppPump", "Pump"], row.TemplateChain); + + Assert.Equal(2, loaded.Attributes.Count); + GalaxyAttributeRow withDimension = loaded.Attributes[0]; + Assert.Equal("PV", withDimension.AttributeName); + Assert.Equal(8, withDimension.ArrayDimension); + Assert.True(withDimension.IsAlarm); + Assert.Null(loaded.Attributes[1].ArrayDimension); + } + + [Fact] + public async Task TryLoadAsync_WhenNoFileExists_ReturnsNull() + { + GalaxyHierarchySnapshotStore store = CreateStore(CreateTempPath()); + + Assert.Null(await store.TryLoadAsync(CancellationToken.None)); + } + + [Fact] + public async Task SaveAsync_WhenPersistenceDisabled_WritesNothing() + { + string path = CreateTempPath(); + GalaxyHierarchySnapshotStore store = CreateStore(path, persist: false); + + await store.SaveAsync(SampleSnapshot(), CancellationToken.None); + + Assert.False(File.Exists(path)); + Assert.Null(await store.TryLoadAsync(CancellationToken.None)); + } + + [Fact] + public async Task TryLoadAsync_WhenFileIsCorruptJson_ReturnsNull() + { + string path = CreateTempPath(); + await File.WriteAllTextAsync(path, "{ this is not valid json"); + GalaxyHierarchySnapshotStore store = CreateStore(path); + + Assert.Null(await store.TryLoadAsync(CancellationToken.None)); + } + + [Fact] + public async Task TryLoadAsync_WhenSchemaVersionUnrecognized_ReturnsNull() + { + string path = CreateTempPath(); + await File.WriteAllTextAsync(path, """{"SchemaVersion":999,"Snapshot":null}"""); + GalaxyHierarchySnapshotStore store = CreateStore(path); + + Assert.Null(await store.TryLoadAsync(CancellationToken.None)); + } + + [Fact] + public async Task SaveAsync_OverwritesAnEarlierSnapshot() + { + string path = CreateTempPath(); + GalaxyHierarchySnapshotStore store = CreateStore(path); + + await store.SaveAsync(SampleSnapshot(), CancellationToken.None); + GalaxyHierarchySnapshot second = SampleSnapshot() with + { + Hierarchy = [], + Attributes = [], + }; + await store.SaveAsync(second, CancellationToken.None); + + GalaxyHierarchySnapshot? loaded = await store.TryLoadAsync(CancellationToken.None); + Assert.NotNull(loaded); + Assert.Empty(loaded.Hierarchy); + Assert.Empty(loaded.Attributes); + } + + private static GalaxyHierarchySnapshot SampleSnapshot() => new( + LastDeployTime: new DateTimeOffset(2026, 5, 20, 9, 30, 0, TimeSpan.Zero), + SavedAt: new DateTimeOffset(2026, 5, 20, 9, 31, 0, TimeSpan.Zero), + Hierarchy: + [ + new GalaxyHierarchyRow + { + GobjectId = 7, + TagName = "Pump_001", + ContainedName = "Pump", + BrowseName = "Pump", + CategoryId = 10, + TemplateChain = ["AppPump", "Pump"], + }, + ], + Attributes: + [ + new GalaxyAttributeRow + { + GobjectId = 7, + TagName = "Pump_001", + AttributeName = "PV", + FullTagReference = "Pump_001.PV[]", + MxDataType = 5, + DataTypeName = "Float", + IsArray = true, + ArrayDimension = 8, + IsAlarm = true, + }, + new GalaxyAttributeRow + { + GobjectId = 7, + TagName = "Pump_001", + AttributeName = "Mode", + FullTagReference = "Pump_001.Mode", + MxDataType = 3, + DataTypeName = "Integer", + ArrayDimension = null, + }, + ]); + + private static GalaxyHierarchySnapshotStore CreateStore(string path, bool persist = true) + { + GalaxyRepositoryOptions options = new() + { + PersistSnapshot = persist, + SnapshotCachePath = path, + }; + return new GalaxyHierarchySnapshotStore(Options.Create(options)); + } + + private string CreateTempPath() + { + string path = Path.Combine( + Path.GetTempPath(), + $"mxgw-galaxy-snapshot-{Guid.NewGuid():N}.json"); + _tempPaths.Add(path); + return path; + } + + public void Dispose() + { + foreach (string path in _tempPaths) + { + try + { + File.Delete(path); + File.Delete(path + ".tmp"); + } + catch (IOException) + { + // Best-effort cleanup of test scratch files. + } + } + } +} diff --git a/src/MxGateway.Tests/Galaxy/GalaxyProtoMapperTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyProtoMapperTests.cs similarity index 96% rename from src/MxGateway.Tests/Galaxy/GalaxyProtoMapperTests.cs rename to src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyProtoMapperTests.cs index d1de74a..28d90f0 100644 --- a/src/MxGateway.Tests/Galaxy/GalaxyProtoMapperTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyProtoMapperTests.cs @@ -1,8 +1,8 @@ -using MxGateway.Contracts.Proto.Galaxy; -using MxGateway.Server.Galaxy; -using MxGateway.Server.Grpc; +using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; +using ZB.MOM.WW.MxGateway.Server.Galaxy; +using ZB.MOM.WW.MxGateway.Server.Grpc; -namespace MxGateway.Tests.Galaxy; +namespace ZB.MOM.WW.MxGateway.Tests.Galaxy; public sealed class GalaxyProtoMapperTests { diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardApiKeyAuthorizationTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardApiKeyAuthorizationTests.cs new file mode 100644 index 0000000..d7e8239 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardApiKeyAuthorizationTests.cs @@ -0,0 +1,65 @@ +using System.Security.Claims; +using Microsoft.Extensions.Options; +using ZB.MOM.WW.MxGateway.Server.Configuration; +using ZB.MOM.WW.MxGateway.Server.Dashboard; + +namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard; + +public sealed class DashboardApiKeyAuthorizationTests +{ + [Fact] + public void CanManage_AuthenticatedUserWithShortRequiredGroupClaim_ReturnsTrue() + { + DashboardApiKeyAuthorization authorization = CreateAuthorization(); + ClaimsPrincipal user = CreatePrincipal("GwAdmin"); + + Assert.True(authorization.CanManage(user)); + } + + [Fact] + public void CanManage_AuthenticatedUserWithRequiredGroupDnClaim_ReturnsTrue() + { + DashboardApiKeyAuthorization authorization = CreateAuthorization(); + ClaimsPrincipal user = CreatePrincipal("ou=GwAdmin,ou=groups,dc=lmxopcua,dc=local"); + + Assert.True(authorization.CanManage(user)); + } + + [Fact] + public void CanManage_AnonymousUser_ReturnsFalse() + { + DashboardApiKeyAuthorization authorization = CreateAuthorization(); + ClaimsPrincipal user = new(new ClaimsIdentity()); + + Assert.False(authorization.CanManage(user)); + } + + [Fact] + public void CanManage_AuthenticatedUserWithoutRequiredGroup_ReturnsFalse() + { + DashboardApiKeyAuthorization authorization = CreateAuthorization(); + ClaimsPrincipal user = CreatePrincipal("ReadOnly"); + + Assert.False(authorization.CanManage(user)); + } + + private static DashboardApiKeyAuthorization CreateAuthorization() + { + return new DashboardApiKeyAuthorization(Options.Create(new GatewayOptions + { + Ldap = new LdapOptions + { + RequiredGroup = "GwAdmin", + }, + })); + } + + private static ClaimsPrincipal CreatePrincipal(string group) + { + ClaimsIdentity identity = new( + [new Claim(DashboardAuthenticationDefaults.LdapGroupClaimType, group)], + DashboardAuthenticationDefaults.AuthenticationScheme); + + return new ClaimsPrincipal(identity); + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardApiKeyManagementServiceTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardApiKeyManagementServiceTests.cs new file mode 100644 index 0000000..cc3b3d3 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardApiKeyManagementServiceTests.cs @@ -0,0 +1,264 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; +using ZB.MOM.WW.MxGateway.Server.Configuration; +using ZB.MOM.WW.MxGateway.Server.Dashboard; +using ZB.MOM.WW.MxGateway.Server.Security.Authentication; +using ZB.MOM.WW.MxGateway.Server.Security.Authorization; + +namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard; + +public sealed class DashboardApiKeyManagementServiceTests +{ + [Fact] + public async Task CreateAsync_UnauthorizedUser_DoesNotCallStore() + { + FakeApiKeyAdminStore adminStore = new(); + DashboardApiKeyManagementService service = CreateService(adminStore); + + DashboardApiKeyManagementResult result = await service.CreateAsync( + new ClaimsPrincipal(new ClaimsIdentity()), + CreateRequest(), + CancellationToken.None); + + Assert.False(result.Succeeded); + Assert.Equal(0, adminStore.CreateCount); + } + + [Fact] + public async Task CreateAsync_AuthorizedUser_StoresHashOfSecretAndAudits() + { + FakeApiKeyAdminStore adminStore = new(); + FakeApiKeyAuditStore auditStore = new(); + FakeApiKeySecretHasher hasher = new(); + DashboardApiKeyManagementService service = CreateService(adminStore, auditStore, hasher); + + DashboardApiKeyManagementResult result = await service.CreateAsync( + CreateAuthorizedUser(), + CreateRequest(), + CancellationToken.None); + + Assert.True(result.Succeeded); + Assert.NotNull(result.ApiKey); + Assert.StartsWith("mxgw_operator01_", result.ApiKey, StringComparison.Ordinal); + string secret = result.ApiKey["mxgw_operator01_".Length..]; + Assert.Equal(secret, hasher.LastSecret); + Assert.DoesNotContain("mxgw_operator01_", hasher.LastSecret, StringComparison.Ordinal); + ApiKeyCreateRequest stored = Assert.Single(adminStore.CreatedRequests); + Assert.Equal("operator01", stored.KeyId); + Assert.Equal("Operator", stored.DisplayName); + Assert.Contains(GatewayScopes.SessionOpen, stored.Scopes); + Assert.Equal(["Area1/*"], stored.Constraints.BrowseSubtrees); + Assert.Contains(auditStore.Entries, entry => + entry.EventType == "dashboard-create-key" + && entry.KeyId == "operator01"); + } + + [Fact] + public async Task RevokeAsync_UnauthorizedUser_DoesNotCallStore() + { + FakeApiKeyAdminStore adminStore = new(); + DashboardApiKeyManagementService service = CreateService(adminStore); + + DashboardApiKeyManagementResult result = await service.RevokeAsync( + new ClaimsPrincipal(new ClaimsIdentity()), + "operator01", + CancellationToken.None); + + Assert.False(result.Succeeded); + Assert.Equal(0, adminStore.RevokeCount); + } + + [Fact] + public async Task RevokeAsync_AuthorizedUser_RevokesAndAudits() + { + FakeApiKeyAdminStore adminStore = new() { RevokeResult = true }; + FakeApiKeyAuditStore auditStore = new(); + DashboardApiKeyManagementService service = CreateService(adminStore, auditStore); + + DashboardApiKeyManagementResult result = await service.RevokeAsync( + CreateAuthorizedUser(), + "operator01", + CancellationToken.None); + + Assert.True(result.Succeeded); + Assert.Equal("operator01", adminStore.LastRevokedKeyId); + Assert.Contains(auditStore.Entries, entry => + entry.EventType == "dashboard-revoke-key" + && entry.KeyId == "operator01" + && entry.Details == "revoked"); + } + + [Fact] + public async Task RotateAsync_AuthorizedUser_RotatesHashAndAudits() + { + FakeApiKeyAdminStore adminStore = new() { RotateResult = true }; + FakeApiKeyAuditStore auditStore = new(); + FakeApiKeySecretHasher hasher = new(); + DashboardApiKeyManagementService service = CreateService(adminStore, auditStore, hasher); + + DashboardApiKeyManagementResult result = await service.RotateAsync( + CreateAuthorizedUser(), + "operator01", + CancellationToken.None); + + Assert.True(result.Succeeded); + Assert.NotNull(result.ApiKey); + Assert.StartsWith("mxgw_operator01_", result.ApiKey, StringComparison.Ordinal); + Assert.Equal(hasher.HashSecret(hasher.LastSecret!), adminStore.LastRotatedSecretHash); + Assert.Contains(auditStore.Entries, entry => + entry.EventType == "dashboard-rotate-key" + && entry.KeyId == "operator01" + && entry.Details == "rotated"); + } + + /// + /// Server-004 regression: the dashboard create path must reject a request + /// carrying a non-canonical scope string rather than persisting a key whose + /// scope the authorization resolver never matches. + /// + [Fact] + public async Task CreateAsync_UnknownScope_DoesNotCallStore() + { + FakeApiKeyAdminStore adminStore = new(); + DashboardApiKeyManagementService service = CreateService(adminStore); + + DashboardApiKeyManagementRequest request = CreateRequest() with + { + Scopes = new HashSet( + [GatewayScopes.SessionOpen, "invoke", "metadata"], + StringComparer.Ordinal), + }; + + DashboardApiKeyManagementResult result = await service.CreateAsync( + CreateAuthorizedUser(), + request, + CancellationToken.None); + + Assert.False(result.Succeeded); + Assert.Equal(0, adminStore.CreateCount); + } + + private static DashboardApiKeyManagementService CreateService( + FakeApiKeyAdminStore? adminStore = null, + FakeApiKeyAuditStore? auditStore = null, + FakeApiKeySecretHasher? hasher = null) + { + GatewayOptions options = new() + { + Ldap = new LdapOptions + { + RequiredGroup = "GwAdmin", + }, + }; + + DefaultHttpContext httpContext = new(); + httpContext.Connection.RemoteIpAddress = System.Net.IPAddress.Loopback; + + return new DashboardApiKeyManagementService( + new DashboardApiKeyAuthorization(Options.Create(options)), + adminStore ?? new FakeApiKeyAdminStore(), + auditStore ?? new FakeApiKeyAuditStore(), + hasher ?? new FakeApiKeySecretHasher(), + new HttpContextAccessor { HttpContext = httpContext }); + } + + private static DashboardApiKeyManagementRequest CreateRequest() + { + return new DashboardApiKeyManagementRequest( + KeyId: "operator01", + DisplayName: "Operator", + Scopes: new HashSet([GatewayScopes.SessionOpen], StringComparer.Ordinal), + Constraints: ApiKeyConstraints.Empty with + { + BrowseSubtrees = ["Area1/*"], + }); + } + + private static ClaimsPrincipal CreateAuthorizedUser() + { + ClaimsIdentity identity = new( + [new Claim(DashboardAuthenticationDefaults.LdapGroupClaimType, "GwAdmin")], + DashboardAuthenticationDefaults.AuthenticationScheme); + + return new ClaimsPrincipal(identity); + } + + private sealed class FakeApiKeyAdminStore : IApiKeyAdminStore + { + public int CreateCount { get; private set; } + + public int RevokeCount { get; private set; } + + public bool RevokeResult { get; init; } + + public bool RotateResult { get; init; } + + public string? LastRevokedKeyId { get; private set; } + + public byte[]? LastRotatedSecretHash { get; private set; } + + public List CreatedRequests { get; } = []; + + public Task CreateAsync(ApiKeyCreateRequest request, CancellationToken cancellationToken) + { + CreateCount++; + CreatedRequests.Add(request); + return Task.CompletedTask; + } + + public Task> ListAsync(CancellationToken cancellationToken) + { + return Task.FromResult>([]); + } + + public Task RevokeAsync( + string keyId, + DateTimeOffset revokedUtc, + CancellationToken cancellationToken) + { + RevokeCount++; + LastRevokedKeyId = keyId; + return Task.FromResult(RevokeResult); + } + + public Task RotateAsync( + string keyId, + byte[] secretHash, + DateTimeOffset rotatedUtc, + CancellationToken cancellationToken) + { + LastRotatedSecretHash = secretHash; + return Task.FromResult(RotateResult); + } + } + + private sealed class FakeApiKeyAuditStore : IApiKeyAuditStore + { + public List Entries { get; } = []; + + public Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken cancellationToken) + { + Entries.Add(entry); + return Task.CompletedTask; + } + + public Task> ListRecentAsync( + int count, + CancellationToken cancellationToken) + { + return Task.FromResult>([]); + } + } + + private sealed class FakeApiKeySecretHasher : IApiKeySecretHasher + { + public string? LastSecret { get; private set; } + + public byte[] HashSecret(string secret) + { + LastSecret = secret; + return System.Text.Encoding.UTF8.GetBytes($"hash:{secret}"); + } + } +} diff --git a/src/MxGateway.Tests/Gateway/Dashboard/DashboardAuthenticatorTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardAuthenticatorTests.cs similarity index 93% rename from src/MxGateway.Tests/Gateway/Dashboard/DashboardAuthenticatorTests.cs rename to src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardAuthenticatorTests.cs index f666e1d..32729dd 100644 --- a/src/MxGateway.Tests/Gateway/Dashboard/DashboardAuthenticatorTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardAuthenticatorTests.cs @@ -1,9 +1,9 @@ using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; -using MxGateway.Server.Configuration; -using MxGateway.Server.Dashboard; +using ZB.MOM.WW.MxGateway.Server.Configuration; +using ZB.MOM.WW.MxGateway.Server.Dashboard; -namespace MxGateway.Tests.Gateway.Dashboard; +namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard; public sealed class DashboardAuthenticatorTests { diff --git a/src/MxGateway.Tests/Gateway/Dashboard/DashboardAuthorizationHandlerTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardAuthorizationHandlerTests.cs similarity index 69% rename from src/MxGateway.Tests/Gateway/Dashboard/DashboardAuthorizationHandlerTests.cs rename to src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardAuthorizationHandlerTests.cs index cde3b0b..6b58e74 100644 --- a/src/MxGateway.Tests/Gateway/Dashboard/DashboardAuthorizationHandlerTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardAuthorizationHandlerTests.cs @@ -3,11 +3,11 @@ using System.Security.Claims; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Options; -using MxGateway.Server.Configuration; -using MxGateway.Server.Dashboard; -using MxGateway.Server.Security.Authorization; +using ZB.MOM.WW.MxGateway.Server.Configuration; +using ZB.MOM.WW.MxGateway.Server.Dashboard; +using ZB.MOM.WW.MxGateway.Server.Security.Authorization; -namespace MxGateway.Tests.Gateway.Dashboard; +namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard; public sealed class DashboardAuthorizationHandlerTests { @@ -35,6 +35,36 @@ public sealed class DashboardAuthorizationHandlerTests Assert.True(context.HasSucceeded); } + /// + /// Verifies that the anonymous-localhost bypass is denied when AllowAnonymousLocalhost + /// is off, even on a loopback connection — the misconfiguration must not expose the dashboard. + /// + [Fact] + public async Task HandleAsync_AnonymousLocalhostDisallowed_DoesNotSucceed() + { + AuthorizationHandlerContext context = await AuthorizeAsync( + new ClaimsPrincipal(new ClaimsIdentity()), + IPAddress.Loopback, + allowAnonymousLocalhost: false); + + Assert.False(context.HasSucceeded); + } + + /// + /// Verifies that the anonymous-localhost bypass stays scoped to loopback: an anonymous + /// request from a non-loopback address is denied even when AllowAnonymousLocalhost is on. + /// + [Fact] + public async Task HandleAsync_AnonymousLocalhostAllowedFromRemoteAddress_DoesNotSucceed() + { + AuthorizationHandlerContext context = await AuthorizeAsync( + new ClaimsPrincipal(new ClaimsIdentity()), + IPAddress.Parse("10.0.0.5"), + allowAnonymousLocalhost: true); + + Assert.False(context.HasSucceeded); + } + /// Verifies that authenticated users without admin scope fail authorization. [Fact] public async Task HandleAsync_AuthenticatedWithoutAdminScope_DoesNotSucceed() diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardBrowseAndAlarmModelTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardBrowseAndAlarmModelTests.cs new file mode 100644 index 0000000..8786f1f --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardBrowseAndAlarmModelTests.cs @@ -0,0 +1,142 @@ +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; +using ZB.MOM.WW.MxGateway.Server.Dashboard; + +namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard; + +/// +/// Unit tests for the pure projection/formatting helpers behind the +/// dashboard Browse and Alarms tabs. +/// +public sealed class DashboardBrowseAndAlarmModelTests +{ + [Fact] + public void BuildTree_LinksChildrenToParents_AndPromotesOrphansToRoots() + { + GalaxyObject area = new() { GobjectId = 1, BrowseName = "AreaA", IsArea = true, ParentGobjectId = 0 }; + GalaxyObject child = new() { GobjectId = 2, BrowseName = "Pump01", ParentGobjectId = 1 }; + GalaxyObject orphan = new() { GobjectId = 3, BrowseName = "Lost", ParentGobjectId = 99 }; + + IReadOnlyList roots = DashboardBrowseTreeBuilder.Build([area, child, orphan]); + + // The area and the orphan (its parent id is absent) are both roots. + Assert.Equal(2, roots.Count); + DashboardBrowseNode areaNode = Assert.Single(roots, node => node.Object.GobjectId == 1); + Assert.Single(areaNode.Children); + Assert.Equal(2, areaNode.Children[0].Object.GobjectId); + Assert.Contains(roots, node => node.Object.GobjectId == 3); + } + + [Fact] + public void BuildTree_SortsAreasBeforeObjects() + { + GalaxyObject instance = new() { GobjectId = 1, BrowseName = "Zeta", IsArea = false }; + GalaxyObject areaB = new() { GobjectId = 2, BrowseName = "Beta", IsArea = true }; + + IReadOnlyList roots = DashboardBrowseTreeBuilder.Build([instance, areaB]); + + Assert.Equal(2, roots.Count); + Assert.True(roots[0].IsArea); + Assert.Equal("Beta", roots[0].DisplayName); + Assert.False(roots[1].IsArea); + } + + [Theory] + [InlineData(true, "true")] + [InlineData(false, "false")] + public void FormatValue_FormatsBooleans(bool input, string expected) + { + MxValue value = new() { DataType = MxDataType.Boolean, BoolValue = input }; + Assert.Equal(expected, DashboardMxValueFormatter.FormatValue(value)); + } + + [Fact] + public void FormatValue_FormatsNumbersAndStrings() + { + Assert.Equal("42", DashboardMxValueFormatter.FormatValue(new MxValue { Int32Value = 42 })); + Assert.Equal("hello", DashboardMxValueFormatter.FormatValue(new MxValue { StringValue = "hello" })); + } + + [Fact] + public void FormatValue_HandlesNullPayloadAndNullReference() + { + Assert.Equal("-", DashboardMxValueFormatter.FormatValue(null)); + Assert.Equal("(null)", DashboardMxValueFormatter.FormatValue(new MxValue { IsNull = true })); + } + + [Fact] + public void TagValue_FromSuccessfulReadResult_MarksGoodQuality() + { + BulkReadResult result = new() + { + TagAddress = "Galaxy!Area.Tag", + WasSuccessful = true, + Quality = 192, + Value = new MxValue { DataType = MxDataType.Double, DoubleValue = 1.5 }, + }; + + DashboardTagValue value = DashboardTagValue.FromBulkReadResult(result); + + Assert.True(value.Ok); + Assert.True(value.QualityGood); + Assert.Equal("1.5", value.ValueText); + Assert.Null(value.Error); + } + + [Fact] + public void TagValue_FromFailedReadResult_CarriesError() + { + BulkReadResult result = new() + { + TagAddress = "Galaxy!Area.Bad", + WasSuccessful = false, + Quality = 0, + ErrorMessage = "invalid handle", + }; + + DashboardTagValue value = DashboardTagValue.FromBulkReadResult(result); + + Assert.False(value.Ok); + Assert.False(value.QualityGood); + Assert.Equal("invalid handle", value.Error); + } + + [Fact] + public void ActiveAlarm_FromSnapshot_ParsesProviderAndAcknowledgementState() + { + ActiveAlarmSnapshot unacked = new() + { + AlarmFullReference = "Galaxy!TestArea.TestMachine_001.TestAlarm001", + Category = "TestArea", + CurrentState = AlarmConditionState.Active, + Severity = 500, + }; + ActiveAlarmSnapshot acked = new() + { + AlarmFullReference = "Galaxy!TestArea.TestMachine_002.TestAlarm001", + CurrentState = AlarmConditionState.ActiveAcked, + }; + + DashboardActiveAlarm unackedRow = DashboardActiveAlarm.FromSnapshot(unacked); + DashboardActiveAlarm ackedRow = DashboardActiveAlarm.FromSnapshot(acked); + + Assert.Equal("Galaxy", unackedRow.Provider); + Assert.Equal("TestArea", unackedRow.Area); + Assert.Equal(500, unackedRow.Severity); + Assert.True(unackedRow.IsUnacknowledged); + Assert.False(ackedRow.IsUnacknowledged); + } + + [Fact] + public void FormatValue_AndDataType_RenderArrayElementsAndElementType() + { + MxArray array = new() { ElementDataType = MxDataType.Double }; + array.Dimensions.Add(3u); + array.DoubleValues = new DoubleArray(); + array.DoubleValues.Values.Add(new[] { 1.5, 2.25, 3.0 }); + MxValue value = new() { ArrayValue = array }; + + Assert.Equal("[1.5, 2.25, 3]", DashboardMxValueFormatter.FormatValue(value)); + Assert.Equal("Double[3]", DashboardMxValueFormatter.FormatDataType(value)); + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardConnectionStringDisplayTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardConnectionStringDisplayTests.cs new file mode 100644 index 0000000..bc29ca9 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardConnectionStringDisplayTests.cs @@ -0,0 +1,21 @@ +using ZB.MOM.WW.MxGateway.Server.Dashboard; + +namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard; + +public sealed class DashboardConnectionStringDisplayTests +{ + [Fact] + public void GalaxyRepositoryConnectionString_WithSqlCredentials_OnlyKeepsNonSecretFields() + { + string display = DashboardConnectionStringDisplay.GalaxyRepositoryConnectionString( + "Server=localhost;Database=ZB;User ID=mxuser;Password=secret;Encrypt=True;Trust Server Certificate=False;"); + + Assert.Contains("Data Source=localhost", display, StringComparison.Ordinal); + Assert.Contains("Initial Catalog=ZB", display, StringComparison.Ordinal); + Assert.Contains("Encrypt=True", display, StringComparison.Ordinal); + Assert.DoesNotContain("User", display, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("Password", display, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("secret", display, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("mxuser", display, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/MxGateway.Tests/Gateway/Dashboard/DashboardCookieOptionsTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardCookieOptionsTests.cs similarity index 81% rename from src/MxGateway.Tests/Gateway/Dashboard/DashboardCookieOptionsTests.cs rename to src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardCookieOptionsTests.cs index df78898..6f3c441 100644 --- a/src/MxGateway.Tests/Gateway/Dashboard/DashboardCookieOptionsTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardCookieOptionsTests.cs @@ -3,18 +3,18 @@ using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; -using MxGateway.Server; -using MxGateway.Server.Dashboard; +using ZB.MOM.WW.MxGateway.Server; +using ZB.MOM.WW.MxGateway.Server.Dashboard; -namespace MxGateway.Tests.Gateway.Dashboard; +namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard; public sealed class DashboardCookieOptionsTests { /// Verifies that the application configures secure dashboard authentication cookies. [Fact] - public void Build_ConfiguresSecureDashboardCookie() + public async Task Build_ConfiguresSecureDashboardCookie() { - WebApplication app = GatewayApplication.Build([]); + await using WebApplication app = GatewayApplication.Build([]); IOptionsMonitor optionsMonitor = app.Services .GetRequiredService>(); diff --git a/src/MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotServiceTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotServiceTests.cs similarity index 93% rename from src/MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotServiceTests.cs rename to src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotServiceTests.cs index c5b1617..6875e0a 100644 --- a/src/MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotServiceTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotServiceTests.cs @@ -1,15 +1,16 @@ +using System.Globalization; using Microsoft.Extensions.Options; -using MxGateway.Contracts.Proto; -using MxGateway.Server.Configuration; -using MxGateway.Server.Dashboard; -using MxGateway.Server.Galaxy; -using MxGateway.Server.Metrics; -using MxGateway.Server.Security.Authentication; -using MxGateway.Server.Security.Authorization; -using MxGateway.Server.Sessions; -using MxGateway.Server.Workers; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Server.Configuration; +using ZB.MOM.WW.MxGateway.Server.Dashboard; +using ZB.MOM.WW.MxGateway.Server.Galaxy; +using ZB.MOM.WW.MxGateway.Server.Metrics; +using ZB.MOM.WW.MxGateway.Server.Security.Authentication; +using ZB.MOM.WW.MxGateway.Server.Security.Authorization; +using ZB.MOM.WW.MxGateway.Server.Sessions; +using ZB.MOM.WW.MxGateway.Server.Workers; -namespace MxGateway.Tests.Gateway.Dashboard; +namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard; public sealed class DashboardSnapshotServiceTests { @@ -42,19 +43,19 @@ public sealed class DashboardSnapshotServiceTests GatewaySession activeSession = CreateSession( "session-active", "client-one", - DateTimeOffset.Parse("2026-04-26T10:00:00Z")); + DateTimeOffset.Parse("2026-04-26T10:00:00Z", CultureInfo.InvariantCulture)); activeSession.AttachWorkerClient(new FakeWorkerClient("session-active", 1201, WorkerClientState.Ready)); activeSession.MarkReady(); GatewaySession faultedSession = CreateSession( "session-faulted", "client-two", - DateTimeOffset.Parse("2026-04-26T10:01:00Z")); + DateTimeOffset.Parse("2026-04-26T10:01:00Z", CultureInfo.InvariantCulture)); faultedSession.AttachWorkerClient(new FakeWorkerClient("session-faulted", 1202, WorkerClientState.Faulted)); faultedSession.MarkFaulted("worker pipe disconnected"); GatewaySession closedSession = CreateSession( "session-closed", "client-three", - DateTimeOffset.Parse("2026-04-26T09:59:00Z")); + DateTimeOffset.Parse("2026-04-26T09:59:00Z", CultureInfo.InvariantCulture)); closedSession.AttachWorkerClient(new FakeWorkerClient("session-closed", 1203, WorkerClientState.Closed)); closedSession.TransitionTo(SessionState.Closed); registry.TryAdd(activeSession); @@ -102,7 +103,7 @@ public sealed class DashboardSnapshotServiceTests GatewaySession session = CreateSession( "session-redacted", "Bearer mxgw_admin_super-secret", - DateTimeOffset.Parse("2026-04-26T10:00:00Z"), + DateTimeOffset.Parse("2026-04-26T10:00:00Z", CultureInfo.InvariantCulture), clientSessionName: "password=hunter2", clientCorrelationId: "token=abc123"); session.MarkFaulted("secret=credential-value"); @@ -131,7 +132,7 @@ public sealed class DashboardSnapshotServiceTests GatewaySession session = CreateSession( "session-active", "client-one", - DateTimeOffset.Parse("2026-04-26T10:00:00Z")); + DateTimeOffset.Parse("2026-04-26T10:00:00Z", CultureInfo.InvariantCulture)); FakeWorkerClient workerClient = new("session-active", 1201, WorkerClientState.Ready); session.AttachWorkerClient(workerClient); session.MarkReady(); @@ -160,11 +161,11 @@ public sealed class DashboardSnapshotServiceTests GatewaySession olderSession = CreateSession( "session-older", "client-one", - DateTimeOffset.Parse("2026-04-26T10:00:00Z")); + DateTimeOffset.Parse("2026-04-26T10:00:00Z", CultureInfo.InvariantCulture)); GatewaySession newerSession = CreateSession( "session-newer", "client-two", - DateTimeOffset.Parse("2026-04-26T10:01:00Z")); + DateTimeOffset.Parse("2026-04-26T10:01:00Z", CultureInfo.InvariantCulture)); olderSession.MarkFaulted("older fault"); newerSession.MarkFaulted("newer fault"); registry.TryAdd(olderSession); @@ -199,14 +200,14 @@ public sealed class DashboardSnapshotServiceTests { Status = GalaxyCacheStatus.Healthy, Sequence = 7, - LastQueriedAt = DateTimeOffset.Parse("2026-04-28T11:30:00Z"), - LastSuccessAt = DateTimeOffset.Parse("2026-04-28T11:30:00Z"), - LastDeployTime = DateTimeOffset.Parse("2026-04-28T09:00:00Z"), + LastQueriedAt = DateTimeOffset.Parse("2026-04-28T11:30:00Z", CultureInfo.InvariantCulture), + LastSuccessAt = DateTimeOffset.Parse("2026-04-28T11:30:00Z", CultureInfo.InvariantCulture), + LastDeployTime = DateTimeOffset.Parse("2026-04-28T09:00:00Z", CultureInfo.InvariantCulture), DashboardSummary = new DashboardGalaxySummary( DashboardGalaxyStatus.Healthy, - LastQueriedAt: DateTimeOffset.Parse("2026-04-28T11:30:00Z"), - LastSuccessAt: DateTimeOffset.Parse("2026-04-28T11:30:00Z"), - LastDeployTime: DateTimeOffset.Parse("2026-04-28T09:00:00Z"), + LastQueriedAt: DateTimeOffset.Parse("2026-04-28T11:30:00Z", CultureInfo.InvariantCulture), + LastSuccessAt: DateTimeOffset.Parse("2026-04-28T11:30:00Z", CultureInfo.InvariantCulture), + LastDeployTime: DateTimeOffset.Parse("2026-04-28T09:00:00Z", CultureInfo.InvariantCulture), LastError: null, ObjectCount: 3, AreaCount: 1, @@ -281,7 +282,7 @@ public sealed class DashboardSnapshotServiceTests { BrowseSubtrees = ["Area1/*"], }, - CreatedUtc: DateTimeOffset.Parse("2026-04-28T12:00:00Z"), + CreatedUtc: DateTimeOffset.Parse("2026-04-28T12:00:00Z", CultureInfo.InvariantCulture), LastUsedUtc: null, RevokedUtc: null)); DashboardSnapshotService service = CreateService( @@ -314,7 +315,7 @@ public sealed class DashboardSnapshotServiceTests DisplayName: "Operator", Scopes: new HashSet([GatewayScopes.MetadataRead], StringComparer.Ordinal), Constraints: ApiKeyConstraints.Empty, - CreatedUtc: DateTimeOffset.Parse("2026-04-28T12:00:00Z"), + CreatedUtc: DateTimeOffset.Parse("2026-04-28T12:00:00Z", CultureInfo.InvariantCulture), LastUsedUtc: null, RevokedUtc: null)); DashboardSnapshotService service = CreateService( @@ -520,7 +521,7 @@ public sealed class DashboardSnapshotServiceTests /// /// Gets the timestamp of the last heartbeat. /// - public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.Parse("2026-04-26T10:02:00Z"); + public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.Parse("2026-04-26T10:02:00Z", CultureInfo.InvariantCulture); /// /// Gets the count of start invocations. diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/GatewayApplicationTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/GatewayApplicationTests.cs new file mode 100644 index 0000000..bea41ab --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/GatewayApplicationTests.cs @@ -0,0 +1,213 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using ZB.MOM.WW.MxGateway.Server; +using ZB.MOM.WW.MxGateway.Server.Dashboard; +using ZB.MOM.WW.MxGateway.Server.Metrics; + +namespace ZB.MOM.WW.MxGateway.Tests.Gateway; + +public sealed class GatewayApplicationTests +{ + /// Verifies that Build maps the live health check endpoint. + [Fact] + public async Task Build_MapsLiveHealthEndpoint() + { + await using WebApplication app = GatewayApplication.Build([]); + + RouteEndpoint endpoint = Assert.Single( + ((IEndpointRouteBuilder)app).DataSources + .SelectMany(dataSource => dataSource.Endpoints) + .OfType(), + candidate => candidate.RoutePattern.RawText == "/health/live"); + + Assert.Equal("LiveHealth", endpoint.Metadata.GetMetadata()?.EndpointName); + } + + /// Verifies that Build registers the gateway metrics service. + [Fact] + public async Task Build_RegistersGatewayMetrics() + { + await using WebApplication app = GatewayApplication.Build([]); + + GatewayMetrics metrics = app.Services.GetRequiredService(); + + Assert.NotNull(metrics); + } + + /// Verifies that Build maps dashboard and authentication endpoints when the dashboard is enabled. + [Fact] + public async Task Build_WhenDashboardEnabled_MapsBlazorDashboardAndAuthEndpoints() + { + await using WebApplication app = GatewayApplication.Build([]); + IReadOnlyList endpoints = GetRouteEndpoints(app); + + Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/dashboard/"); + Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/dashboard/sessions"); + Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/dashboard/workers"); + Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/dashboard/events"); + Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/dashboard/settings"); + Assert.Contains(endpoints, endpoint => + endpoint.Metadata.GetMetadata()?.EndpointName == "DashboardLogin"); + Assert.Contains(endpoints, endpoint => + endpoint.Metadata.GetMetadata()?.EndpointName == "DashboardLogout"); + } + + /// Verifies that the dashboard login, logout, and denied endpoints allow anonymous access. + [Fact] + public async Task Build_WhenDashboardEnabled_AuthEndpointsAllowAnonymousAccess() + { + await using WebApplication app = GatewayApplication.Build([]); + IReadOnlyList endpoints = GetRouteEndpoints(app); + + string[] anonymousEndpointNames = + ["DashboardLogin", "DashboardLoginPost", "DashboardLogout", "DashboardAccessDenied"]; + foreach (string endpointName in anonymousEndpointNames) + { + RouteEndpoint endpoint = Assert.Single( + endpoints, + candidate => candidate.Metadata.GetMetadata()?.EndpointName == endpointName); + + Assert.NotNull(endpoint.Metadata.GetMetadata()); + } + } + + /// Verifies that dashboard Razor component routes require the dashboard authorization policy. + [Fact] + public async Task Build_WhenDashboardEnabled_ComponentRoutesRequireAuthorization() + { + await using WebApplication app = GatewayApplication.Build([]); + IReadOnlyList endpoints = GetRouteEndpoints(app); + + string[] componentRoutes = + ["/dashboard/", "/dashboard/sessions", "/dashboard/workers", "/dashboard/events", "/dashboard/settings"]; + foreach (string route in componentRoutes) + { + RouteEndpoint[] matches = endpoints + .Where(endpoint => endpoint.RoutePattern.RawText == route) + .ToArray(); + + Assert.NotEmpty(matches); + Assert.All(matches, endpoint => + { + IAuthorizeData? authorize = endpoint.Metadata.GetMetadata(); + Assert.NotNull(authorize); + Assert.Equal(DashboardAuthenticationDefaults.AuthorizationPolicy, authorize.Policy); + }); + } + } + + /// + /// Server-020 reversal regression guard. The original Server-020 finding + /// incorrectly concluded that the duplicate @page "/dashboard/X" + /// directives were redundant because MapGroup("/dashboard") + /// would prepend the prefix to all dashboard Razor pages. In practice + /// Blazor SSR's RouteTableFactory matches against the raw + /// @page template values (not against the endpoint-route + /// prefix), so removing @page "/dashboard/X" left the dashboard + /// unreachable at runtime (every page returned HTTP 500 with "Unable + /// to find the provided template '/dashboard/'"). The duplicate + /// @page directives are restored, and as a side effect the + /// endpoint route table DOES carry the doubled /dashboard/dashboard/X + /// shape (because MapGroup("/dashboard") prefixes the already-prefixed + /// @page "/dashboard/X"). Those doubled endpoints are harmless — + /// no client requests /dashboard/dashboard/X — and removing them + /// requires either dropping MapGroup or the @page + /// prefix. This test asserts only the positive contract: every + /// dashboard page IS reachable under the canonical /dashboard/X + /// route, which is what the Blazor router actually serves. + /// + [Fact] + public async Task Build_WhenDashboardEnabled_RegistersCanonicalDashboardRoutes() + { + await using WebApplication app = GatewayApplication.Build([]); + IReadOnlyList endpoints = GetRouteEndpoints(app); + + string[] canonicalRoutes = + [ + "/dashboard/", + "/dashboard/sessions", + "/dashboard/workers", + "/dashboard/events", + "/dashboard/settings", + "/dashboard/galaxy", + "/dashboard/apikeys", + "/dashboard/sessions/{SessionId}", + ]; + foreach (string canonical in canonicalRoutes) + { + Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == canonical); + } + } + + [Fact] + public async Task Build_WhenDashboardDisabled_DoesNotMapDashboardRoutes() + { + await using WebApplication app = GatewayApplication.Build(["--MxGateway:Dashboard:Enabled=false"]); + IReadOnlyList endpoints = GetRouteEndpoints(app); + + Assert.DoesNotContain(endpoints, endpoint => + endpoint.RoutePattern.RawText?.StartsWith("/dashboard", StringComparison.Ordinal) == true); + Assert.DoesNotContain(endpoints, endpoint => + endpoint.Metadata.GetMetadata()?.EndpointName?.StartsWith( + "Dashboard", + StringComparison.Ordinal) == true); + } + + /// Verifies that StartAsync fails when gateway configuration is invalid. + /// Configuration key to override. + /// Invalid configuration value. + /// Expected validation error message. + [Theory] + [InlineData( + "MxGateway:Worker:ExecutablePath", + "worker.dll", + "MxGateway:Worker:ExecutablePath must point to a .exe file.")] + [InlineData( + "MxGateway:Events:QueueCapacity", + "0", + "MxGateway:Events:QueueCapacity must be greater than zero.")] + [InlineData( + "MxGateway:Authentication:PepperSecretName", + "", + "MxGateway:Authentication:PepperSecretName is required")] + [InlineData( + "MxGateway:Dashboard:PathBase", + "dashboard", + "MxGateway:Dashboard:PathBase must start with '/'.")] + [InlineData( + "MxGateway:Ldap:RequiredGroup", + "", + "MxGateway:Ldap:RequiredGroup is required when LDAP login is enabled.")] + [InlineData( + "MxGateway:Ldap:AllowInsecureLdap", + "false", + "MxGateway:Ldap:AllowInsecureLdap must be true when UseTls is false.")] + public async Task StartAsync_InvalidGatewayConfiguration_FailsStartup( + string key, + string value, + string expectedFailure) + { + // Bind an ephemeral port (:0) — xUnit runs test collections in parallel, so any + // WebApplication-building test must avoid a fixed port to prevent a bind collision. + await using WebApplication app = GatewayApplication.Build( + [$"--{key}={value}", "--urls=http://127.0.0.1:0"]); + + OptionsValidationException exception = await Assert.ThrowsAsync( + () => app.StartAsync()); + + Assert.Contains( + exception.Failures, + failure => failure.Contains(expectedFailure, StringComparison.Ordinal)); + } + + private static IReadOnlyList GetRouteEndpoints(WebApplication app) + { + return ((IEndpointRouteBuilder)app).DataSources + .SelectMany(dataSource => dataSource.Endpoints) + .OfType() + .ToArray(); + } +} diff --git a/src/MxGateway.Tests/Gateway/GatewayEndToEndFakeWorkerSmokeTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/GatewayEndToEndFakeWorkerSmokeTests.cs similarity index 70% rename from src/MxGateway.Tests/Gateway/GatewayEndToEndFakeWorkerSmokeTests.cs rename to src/ZB.MOM.WW.MxGateway.Tests/Gateway/GatewayEndToEndFakeWorkerSmokeTests.cs index bb0c627..a1de85d 100644 --- a/src/MxGateway.Tests/Gateway/GatewayEndToEndFakeWorkerSmokeTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/GatewayEndToEndFakeWorkerSmokeTests.cs @@ -1,20 +1,20 @@ using System.Collections.Concurrent; using Google.Protobuf.WellKnownTypes; -using Grpc.Core; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; -using MxGateway.Contracts; -using MxGateway.Contracts.Proto; -using MxGateway.Server.Configuration; -using MxGateway.Server.Grpc; -using MxGateway.Server.Metrics; -using MxGateway.Server.Security.Authentication; -using MxGateway.Server.Security.Authorization; -using MxGateway.Server.Sessions; -using MxGateway.Server.Workers; -using MxGateway.Tests.Gateway.Workers.Fakes; +using ZB.MOM.WW.MxGateway.Contracts; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Server.Configuration; +using ZB.MOM.WW.MxGateway.Server.Grpc; +using ZB.MOM.WW.MxGateway.Server.Metrics; +using ZB.MOM.WW.MxGateway.Server.Security.Authentication; +using ZB.MOM.WW.MxGateway.Server.Security.Authorization; +using ZB.MOM.WW.MxGateway.Server.Sessions; +using ZB.MOM.WW.MxGateway.Server.Workers; +using ZB.MOM.WW.MxGateway.Tests.Gateway.Workers.Fakes; +using ZB.MOM.WW.MxGateway.Tests.TestSupport; -namespace MxGateway.Tests.Gateway; +namespace ZB.MOM.WW.MxGateway.Tests.Gateway; public sealed class GatewayEndToEndFakeWorkerSmokeTests { @@ -85,6 +85,10 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests Assert.Equal(ProtocolStatusCode.Ok, closeReply.ProtocolStatus.Code); Assert.Equal(SessionState.Closed, closeReply.FinalState); Assert.True(launcher.Process.HasExited); + // MarkExited(0) is reached only after the scripted worker observed a WorkerShutdown + // envelope and emitted its WorkerShutdownAck — anything else (a kill, a fault) would + // have produced a non-zero exit code, so this pins the shutdown-ack handshake. + Assert.Equal(0, launcher.Process.ExitCode); Assert.Equal( [MxCommandKind.Register, MxCommandKind.AddItem, MxCommandKind.Advise], launcher.CommandKinds); @@ -184,7 +188,8 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests mapper, eventStreamService, _metrics, - NullLogger.Instance); + NullLogger.Instance, + new FakeGatewayAlarmService()); } /// @@ -351,6 +356,8 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests private sealed class FakeWorkerProcess(int processId) : IWorkerProcess { + private readonly TaskCompletionSource _exited = new(TaskCreationOptions.RunContinuationsAsynchronously); + /// /// Gets the process identifier. /// @@ -367,15 +374,15 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests public int? ExitCode { get; private set; } /// - /// Waits for the process to exit asynchronously. + /// Waits for the process to exit asynchronously. Completes only when + /// or has been called, so callers that observe completion can + /// trust that exit actually happened (e.g., via the worker shutdown-ack path). /// /// Cancellation token. - /// Completed task. + /// A task that completes when the process has actually exited. public ValueTask WaitForExitAsync(CancellationToken cancellationToken) { - HasExited = true; - ExitCode ??= 0; - return ValueTask.CompletedTask; + return new ValueTask(_exited.Task.WaitAsync(cancellationToken)); } /// @@ -402,162 +409,8 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests { HasExited = true; ExitCode = exitCode; + _exited.TrySetResult(); } } - private sealed class RecordingServerStreamWriter : IServerStreamWriter - { - private readonly object _syncRoot = new(); - private readonly TaskCompletionSource _firstMessage = new(TaskCreationOptions.RunContinuationsAsynchronously); - private readonly List _messages = []; - - /// - /// Gets the recorded messages written to this stream. - /// - public IReadOnlyList Messages - { - get - { - lock (_syncRoot) - { - return _messages.ToArray(); - } - } - } - - /// - /// Gets or sets options for writing messages to the stream. - /// - public WriteOptions? WriteOptions { get; set; } - - /// - /// Writes a message to the stream asynchronously. - /// - /// The message to write. - /// Completed task. - public Task WriteAsync(T message) - { - lock (_syncRoot) - { - _messages.Add(message); - } - - _firstMessage.TrySetResult(message); - return Task.CompletedTask; - } - - /// - /// Waits for the first message to be written within the specified timeout. - /// - /// Maximum time to wait for the first message. - /// The first message written to this stream. - public async Task WaitForFirstMessageAsync(TimeSpan timeout) - { - return await _firstMessage.Task.WaitAsync(timeout).ConfigureAwait(false); - } - } - - private sealed class TestServerCallContext(CancellationToken cancellationToken = default) : ServerCallContext - { - private readonly Metadata _requestHeaders = []; - private readonly Metadata _responseTrailers = []; - private readonly Dictionary _userState = []; - private Status _status; - private WriteOptions? _writeOptions; - - /// - protected override string MethodCore => "/mxaccess_gateway.v1.MxAccessGateway/Test"; - - /// - protected override string HostCore => "localhost"; - - /// - protected override string PeerCore => "ipv4:127.0.0.1:5000"; - - /// - protected override DateTime DeadlineCore => DateTime.UtcNow.AddMinutes(1); - - /// - protected override Metadata RequestHeadersCore => _requestHeaders; - - /// - protected override CancellationToken CancellationTokenCore => cancellationToken; - - /// - protected override Metadata ResponseTrailersCore => _responseTrailers; - - /// - protected override Status StatusCore - { - get => _status; - set => _status = value; - } - - /// - protected override WriteOptions? WriteOptionsCore - { - get => _writeOptions; - set => _writeOptions = value; - } - - /// - protected override AuthContext AuthContextCore { get; } = new( - string.Empty, - new Dictionary>(StringComparer.Ordinal)); - - /// - protected override IDictionary UserStateCore => _userState; - - /// - /// Writes response headers asynchronously. - /// - /// Headers to write. - /// Completed task. - /// - protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders) - { - return Task.CompletedTask; - } - - /// - /// Creates a context propagation token with the specified options. - /// - /// Propagation options. - /// Propagation token. - /// - protected override ContextPropagationToken CreatePropagationTokenCore( - ContextPropagationOptions? options) - { - throw new NotSupportedException(); - } - } - - private sealed class AllowAllConstraintEnforcer : IConstraintEnforcer - { - public Task CheckReadTagAsync( - ApiKeyIdentity? identity, - string tagAddress, - CancellationToken cancellationToken) => Task.FromResult(null); - - public Task CheckReadHandleAsync( - ApiKeyIdentity? identity, - GatewaySession session, - int serverHandle, - int itemHandle, - CancellationToken cancellationToken) => Task.FromResult(null); - - public Task CheckWriteHandleAsync( - ApiKeyIdentity? identity, - GatewaySession session, - int serverHandle, - int itemHandle, - CancellationToken cancellationToken) => Task.FromResult(null); - - public Task RecordDenialAsync( - ApiKeyIdentity? identity, - string commandKind, - string target, - ConstraintFailure failure, - CancellationToken cancellationToken) => Task.CompletedTask; - } } diff --git a/src/MxGateway.Tests/Gateway/Grpc/EventStreamServiceTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/EventStreamServiceTests.cs similarity index 98% rename from src/MxGateway.Tests/Gateway/Grpc/EventStreamServiceTests.cs rename to src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/EventStreamServiceTests.cs index 3d2264b..ab3f7cb 100644 --- a/src/MxGateway.Tests/Gateway/Grpc/EventStreamServiceTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/EventStreamServiceTests.cs @@ -2,15 +2,15 @@ using System.Runtime.CompilerServices; using Grpc.Core; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; -using MxGateway.Contracts; -using MxGateway.Contracts.Proto; -using MxGateway.Server.Configuration; -using MxGateway.Server.Grpc; -using MxGateway.Server.Metrics; -using MxGateway.Server.Sessions; -using MxGateway.Server.Workers; +using ZB.MOM.WW.MxGateway.Contracts; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Server.Configuration; +using ZB.MOM.WW.MxGateway.Server.Grpc; +using ZB.MOM.WW.MxGateway.Server.Metrics; +using ZB.MOM.WW.MxGateway.Server.Sessions; +using ZB.MOM.WW.MxGateway.Server.Workers; -namespace MxGateway.Tests.Gateway.Grpc; +namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Grpc; public sealed class EventStreamServiceTests { diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/GalaxyRepositoryGrpcServiceTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/GalaxyRepositoryGrpcServiceTests.cs new file mode 100644 index 0000000..7acb6d2 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/GalaxyRepositoryGrpcServiceTests.cs @@ -0,0 +1,325 @@ +using Grpc.Core; +using Microsoft.Extensions.Logging.Abstractions; +using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; +using ZB.MOM.WW.MxGateway.Server.Dashboard; +using ZB.MOM.WW.MxGateway.Server.Galaxy; +using ZB.MOM.WW.MxGateway.Server.Grpc; +using ZB.MOM.WW.MxGateway.Server.Security.Authorization; +using ZB.MOM.WW.MxGateway.Tests.TestSupport; + +namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Grpc; + +public sealed class GalaxyRepositoryGrpcServiceTests +{ + [Fact] + public async Task DiscoverHierarchy_ReturnsRequestedPageAndTotals() + { + GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateObjects(3))); + + DiscoverHierarchyReply reply = await service.DiscoverHierarchy( + new DiscoverHierarchyRequest + { + PageSize = 2, + }, + new TestServerCallContext()); + + Assert.Equal(2, reply.Objects.Count); + Assert.Equal("Object_001", reply.Objects[0].TagName); + Assert.Equal("Object_002", reply.Objects[1].TagName); + Assert.StartsWith("7:", reply.NextPageToken, StringComparison.Ordinal); + Assert.EndsWith(":2", reply.NextPageToken, StringComparison.Ordinal); + Assert.Equal(3, reply.TotalObjectCount); + } + + [Fact] + public async Task DiscoverHierarchy_WithNextPageToken_ReturnsRemainingObjects() + { + GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateObjects(3))); + DiscoverHierarchyReply firstPage = await service.DiscoverHierarchy( + new DiscoverHierarchyRequest + { + PageSize = 2, + }, + new TestServerCallContext()); + + DiscoverHierarchyReply reply = await service.DiscoverHierarchy( + new DiscoverHierarchyRequest + { + PageSize = 2, + PageToken = firstPage.NextPageToken, + }, + new TestServerCallContext()); + + GalaxyObject item = Assert.Single(reply.Objects); + Assert.Equal("Object_003", item.TagName); + Assert.Equal("", reply.NextPageToken); + Assert.Equal(3, reply.TotalObjectCount); + } + + [Theory] + [InlineData("-1", 1)] + [InlineData("not-an-offset", 1)] + [InlineData("7:4", 1)] + [InlineData("6:2", 1)] + [InlineData("", -1)] + public async Task DiscoverHierarchy_WithInvalidPagingArguments_ReturnsInvalidArgument( + string pageToken, + int pageSize) + { + GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateObjects(3))); + + RpcException exception = await Assert.ThrowsAsync( + async () => await service.DiscoverHierarchy( + new DiscoverHierarchyRequest + { + PageSize = pageSize, + PageToken = pageToken, + }, + new TestServerCallContext())); + + Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode); + } + + [Fact] + public async Task DiscoverHierarchy_WithSubtreeRootAndDepth_FiltersDescendants() + { + GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateFilterObjects())); + + DiscoverHierarchyReply reply = await service.DiscoverHierarchy( + new DiscoverHierarchyRequest + { + RootContainedPath = "Area1/Line3", + MaxDepth = 1, + PageSize = 10, + }, + new TestServerCallContext()); + + Assert.Equal(["Line3", "Pump_001", "Valve_001"], reply.Objects.Select(obj => obj.TagName)); + Assert.Equal(3, reply.TotalObjectCount); + } + + [Fact] + public async Task DiscoverHierarchy_WithServerSideFilters_AppliesAllFiltersAndOmitsAttributes() + { + GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateFilterObjects())); + + DiscoverHierarchyReply reply = await service.DiscoverHierarchy( + new DiscoverHierarchyRequest + { + RootTagName = "Area1", + TagNameGlob = "Pump_*", + AlarmBearingOnly = true, + HistorizedOnly = true, + IncludeAttributes = false, + PageSize = 10, + CategoryIds = { 10 }, + TemplateChainContains = { "Pump" }, + }, + new TestServerCallContext()); + + GalaxyObject obj = Assert.Single(reply.Objects); + Assert.Equal("Pump_001", obj.TagName); + Assert.Empty(obj.Attributes); + Assert.Equal(1, reply.TotalObjectCount); + } + + [Fact] + public async Task DiscoverHierarchy_WithFilteredPaging_ReturnsPostFilterTotal() + { + GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateFilterObjects())); + + DiscoverHierarchyReply first = await service.DiscoverHierarchy( + new DiscoverHierarchyRequest + { + RootGobjectId = 1, + PageSize = 1, + CategoryIds = { 10 }, + }, + new TestServerCallContext()); + + DiscoverHierarchyReply second = await service.DiscoverHierarchy( + new DiscoverHierarchyRequest + { + RootGobjectId = 1, + PageSize = 1, + PageToken = first.NextPageToken, + CategoryIds = { 10 }, + }, + new TestServerCallContext()); + + GalaxyObject firstObject = Assert.Single(first.Objects); + GalaxyObject secondObject = Assert.Single(second.Objects); + Assert.Equal(2, first.TotalObjectCount); + Assert.Equal(2, second.TotalObjectCount); + Assert.NotEqual(firstObject.TagName, secondObject.TagName); + } + + [Fact] + public async Task DiscoverHierarchy_WithMismatchedFilterToken_ReturnsInvalidArgument() + { + GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateFilterObjects())); + DiscoverHierarchyReply first = await service.DiscoverHierarchy( + new DiscoverHierarchyRequest + { + PageSize = 1, + CategoryIds = { 10 }, + }, + new TestServerCallContext()); + + RpcException exception = await Assert.ThrowsAsync( + async () => await service.DiscoverHierarchy( + new DiscoverHierarchyRequest + { + PageSize = 1, + PageToken = first.NextPageToken, + CategoryIds = { 11 }, + }, + new TestServerCallContext())); + + Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode); + Assert.Contains("filters", exception.Status.Detail, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task DiscoverHierarchy_WithMissingRoot_ReturnsNotFound() + { + GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateFilterObjects())); + + RpcException exception = await Assert.ThrowsAsync( + async () => await service.DiscoverHierarchy( + new DiscoverHierarchyRequest + { + RootTagName = "Missing", + }, + new TestServerCallContext())); + + Assert.Equal(StatusCode.NotFound, exception.StatusCode); + } + + private static GalaxyRepositoryGrpcService CreateService(GalaxyHierarchyCacheEntry entry) + { + GalaxyRepositoryOptions options = new() + { + ConnectionString = "Server=localhost;Database=ZB;Integrated Security=True;Encrypt=False;", + }; + return new GalaxyRepositoryGrpcService( + new global::ZB.MOM.WW.MxGateway.Server.Galaxy.GalaxyRepository(options), + new StubGalaxyHierarchyCache(entry), + new GalaxyDeployNotifier(), + new GatewayRequestIdentityAccessor(), + NullLogger.Instance); + } + + private static GalaxyHierarchyCacheEntry CreateEntry(IReadOnlyList objects) + { + return GalaxyHierarchyCacheEntry.Empty with + { + Status = GalaxyCacheStatus.Healthy, + Sequence = 7, + LastSuccessAt = DateTimeOffset.UtcNow, + Objects = objects, + Index = GalaxyHierarchyIndex.Build(objects), + DashboardSummary = DashboardGalaxySummary.Unknown with + { + Status = DashboardGalaxyStatus.Healthy, + ObjectCount = objects.Count, + }, + ObjectCount = objects.Count, + }; + } + + private static IReadOnlyList CreateObjects(int count) + { + return Enumerable.Range(1, count) + .Select(index => new GalaxyObject + { + GobjectId = index, + TagName = $"Object_{index:000}", + BrowseName = $"Object_{index:000}", + }) + .ToArray(); + } + + private static IReadOnlyList CreateFilterObjects() + { + return + [ + new GalaxyObject + { + GobjectId = 1, + TagName = "Area1", + ContainedName = "Area1", + BrowseName = "Area1", + IsArea = true, + CategoryId = 13, + }, + new GalaxyObject + { + GobjectId = 2, + TagName = "Line3", + ContainedName = "Line3", + BrowseName = "Line3", + ParentGobjectId = 1, + CategoryId = 10, + TemplateChain = { "$Line", "$Base" }, + }, + new GalaxyObject + { + GobjectId = 3, + TagName = "Pump_001", + ContainedName = "Pump", + BrowseName = "Pump_001", + ParentGobjectId = 2, + CategoryId = 10, + TemplateChain = { "$Pump", "$Base" }, + Attributes = + { + new GalaxyAttribute + { + AttributeName = "PV", + FullTagReference = "Pump_001.PV", + IsAlarm = true, + IsHistorized = true, + SecurityClassification = 2, + }, + }, + }, + new GalaxyObject + { + GobjectId = 4, + TagName = "Valve_001", + ContainedName = "Valve", + BrowseName = "Valve_001", + ParentGobjectId = 2, + CategoryId = 11, + TemplateChain = { "$Valve" }, + Attributes = + { + new GalaxyAttribute + { + AttributeName = "PV", + FullTagReference = "Valve_001.PV", + }, + }, + }, + new GalaxyObject + { + GobjectId = 5, + TagName = "Other_001", + ContainedName = "Other", + BrowseName = "Other_001", + CategoryId = 10, + }, + ]; + } + + private sealed class StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry current) : IGalaxyHierarchyCache + { + public GalaxyHierarchyCacheEntry Current { get; } = current; + + public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask; + } + +} diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceConstraintTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceConstraintTests.cs new file mode 100644 index 0000000..6b84b4d --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceConstraintTests.cs @@ -0,0 +1,974 @@ +using Grpc.Core; +using Microsoft.Extensions.Logging.Abstractions; +using ZB.MOM.WW.MxGateway.Contracts; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Server.Grpc; +using ZB.MOM.WW.MxGateway.Server.Metrics; +using ZB.MOM.WW.MxGateway.Server.Security.Authentication; +using ZB.MOM.WW.MxGateway.Server.Security.Authorization; +using ZB.MOM.WW.MxGateway.Server.Sessions; +using ZB.MOM.WW.MxGateway.Server.Workers; +using ZB.MOM.WW.MxGateway.Tests.TestSupport; + +namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Grpc; + +/// +/// Tests for Server-021. MxAccessGatewayService.ApplyConstraintsAsync and +/// the BulkConstraintPlan / ReadBulkConstraintPlan / +/// WriteBulkConstraintPlan / SubscribeBulkConstraintPlan reply-merge +/// logic was previously exercised only with an allow-all enforcer, so denial +/// filtering, the no-allowed-items short-circuit, and the index-ordered +/// denied/allowed interleave were dead code at test time. The fixtures below +/// inject a that denies a subset of +/// tags or handles, and assert the post-merge reply contents and that the +/// session manager is (or is not) invoked. +/// +public sealed class MxAccessGatewayServiceConstraintTests +{ + private const string SessionId = "session-constraint"; + + // === SubscribeBulk family: AddItemBulk / SubscribeBulk / AdviseItemBulk === + + /// + /// AddItemBulk with a mix of allowed and denied tags must invoke the + /// worker once with only the allowed tags, then splice the denied entries + /// back into the reply at their original indices. + /// + [Fact] + public async Task Invoke_AddItemBulk_WithMixedDenials_InterleavesDeniedAndAllowedInOriginalIndexOrder() + { + PredicateConstraintEnforcer enforcer = new() + { + DenyTag = tag => tag == "Tank01.Locked" || tag == "Tank03.Secret", + }; + FakeSessionManager sessionManager = CreateSessionManagerWithSeed(); + sessionManager.InvokeReply = new WorkerCommandReply + { + Reply = new MxCommandReply + { + SessionId = SessionId, + Kind = MxCommandKind.AddItemBulk, + ProtocolStatus = MxAccessGrpcMapper.Ok(), + AddItemBulk = new BulkSubscribeReply + { + Results = + { + // Worker only sees the two allowed tags — Tank02.Open at original + // index 1 and Tank04.Public at original index 3. + new SubscribeResult { ServerHandle = 7, TagAddress = "Tank02.Open", ItemHandle = 102, WasSuccessful = true }, + new SubscribeResult { ServerHandle = 7, TagAddress = "Tank04.Public", ItemHandle = 104, WasSuccessful = true }, + }, + }, + }, + }; + MxAccessGatewayService service = CreateService(sessionManager, enforcer); + + MxCommandReply reply = await service.Invoke( + CreateAddItemBulkRequest(7, ["Tank01.Locked", "Tank02.Open", "Tank03.Secret", "Tank04.Public"]), + new TestServerCallContext()); + + Assert.Equal(1, sessionManager.InvokeCount); + // Worker saw only the allowed subset, in original order, with denied entries dropped. + AddItemBulkCommand forwardedCommand = sessionManager.LastWorkerCommand!.Command.AddItemBulk; + Assert.Equal(["Tank02.Open", "Tank04.Public"], forwardedCommand.TagAddresses); + // Final reply preserves the original 4-entry index order, with denied entries + // at index 0 and 2 and worker-allowed entries at index 1 and 3. + BulkSubscribeReply merged = reply.AddItemBulk; + Assert.Equal(4, merged.Results.Count); + Assert.False(merged.Results[0].WasSuccessful); + Assert.Equal("Tank01.Locked", merged.Results[0].TagAddress); + Assert.Contains("Tank01.Locked", merged.Results[0].ErrorMessage, StringComparison.Ordinal); + Assert.True(merged.Results[1].WasSuccessful); + Assert.Equal("Tank02.Open", merged.Results[1].TagAddress); + Assert.Equal(102, merged.Results[1].ItemHandle); + Assert.False(merged.Results[2].WasSuccessful); + Assert.Equal("Tank03.Secret", merged.Results[2].TagAddress); + Assert.True(merged.Results[3].WasSuccessful); + Assert.Equal("Tank04.Public", merged.Results[3].TagAddress); + Assert.Equal(104, merged.Results[3].ItemHandle); + // Both denied tags recorded. + Assert.Equal(2, enforcer.RecordedDenials.Count); + } + + /// + /// SubscribeBulk when every tag is denied must short-circuit + /// false, return the + /// denied-only reply, and never call the session manager. + /// + [Fact] + public async Task Invoke_SubscribeBulk_WhenAllTagsDenied_DoesNotCallWorkerAndReturnsDeniedReply() + { + PredicateConstraintEnforcer enforcer = new() { DenyTag = _ => true }; + FakeSessionManager sessionManager = CreateSessionManagerWithSeed(); + MxAccessGatewayService service = CreateService(sessionManager, enforcer); + + MxCommandReply reply = await service.Invoke( + CreateSubscribeBulkRequest(7, ["A", "B", "C"]), + new TestServerCallContext()); + + Assert.Equal(0, sessionManager.InvokeCount); + Assert.Equal(3, reply.SubscribeBulk.Results.Count); + Assert.All(reply.SubscribeBulk.Results, r => Assert.False(r.WasSuccessful)); + Assert.Equal(["A", "B", "C"], reply.SubscribeBulk.Results.Select(r => r.TagAddress)); + Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); + } + + /// + /// AdviseItemBulk takes handle inputs (not tags) and routes through + /// FilterHandleBulkAsync against CheckReadHandleAsync. Partial + /// denial must still produce a merged-by-index BulkSubscribeReply. + /// + [Fact] + public async Task Invoke_AdviseItemBulk_WithMixedHandleDenials_MergesDeniedIntoReply() + { + PredicateConstraintEnforcer enforcer = new() + { + DenyReadHandle = (_, itemHandle) => itemHandle == 502, + }; + FakeSessionManager sessionManager = CreateSessionManagerWithSeed(); + sessionManager.InvokeReply = new WorkerCommandReply + { + Reply = new MxCommandReply + { + SessionId = SessionId, + Kind = MxCommandKind.AdviseItemBulk, + ProtocolStatus = MxAccessGrpcMapper.Ok(), + AdviseItemBulk = new BulkSubscribeReply + { + Results = + { + new SubscribeResult { ServerHandle = 7, ItemHandle = 501, WasSuccessful = true }, + new SubscribeResult { ServerHandle = 7, ItemHandle = 503, WasSuccessful = true }, + }, + }, + }, + }; + MxAccessGatewayService service = CreateService(sessionManager, enforcer); + + MxCommandReply reply = await service.Invoke( + CreateAdviseItemBulkRequest(7, [501, 502, 503]), + new TestServerCallContext()); + + Assert.Equal(1, sessionManager.InvokeCount); + Assert.Equal([501, 503], sessionManager.LastWorkerCommand!.Command.AdviseItemBulk.ItemHandles); + BulkSubscribeReply merged = reply.AdviseItemBulk; + Assert.Equal(3, merged.Results.Count); + Assert.True(merged.Results[0].WasSuccessful); + Assert.Equal(501, merged.Results[0].ItemHandle); + Assert.False(merged.Results[1].WasSuccessful); + Assert.Equal(502, merged.Results[1].ItemHandle); + Assert.True(merged.Results[2].WasSuccessful); + Assert.Equal(503, merged.Results[2].ItemHandle); + } + + /// + /// SubscribeBulk with an allow-all enforcer must leave the worker reply + /// unchanged — the constraint plan is null and no merge occurs. Regression + /// guard against accidentally engaging the merge path for the common case. + /// + [Fact] + public async Task Invoke_SubscribeBulk_WithAllowAllEnforcer_PassesThroughUnchanged() + { + FakeSessionManager sessionManager = CreateSessionManagerWithSeed(); + sessionManager.InvokeReply = new WorkerCommandReply + { + Reply = new MxCommandReply + { + SessionId = SessionId, + Kind = MxCommandKind.SubscribeBulk, + ProtocolStatus = MxAccessGrpcMapper.Ok(), + SubscribeBulk = new BulkSubscribeReply + { + Results = + { + new SubscribeResult { ServerHandle = 7, TagAddress = "A", ItemHandle = 1, WasSuccessful = true }, + new SubscribeResult { ServerHandle = 7, TagAddress = "B", ItemHandle = 2, WasSuccessful = true }, + }, + }, + }, + }; + MxAccessGatewayService service = CreateService(sessionManager); + + MxCommandReply reply = await service.Invoke( + CreateSubscribeBulkRequest(7, ["A", "B"]), + new TestServerCallContext()); + + Assert.Equal(1, sessionManager.InvokeCount); + Assert.Equal(["A", "B"], sessionManager.LastWorkerCommand!.Command.SubscribeBulk.TagAddresses); + // Reply identical to worker reply — no synthetic denial rows added. + Assert.Equal(2, reply.SubscribeBulk.Results.Count); + Assert.All(reply.SubscribeBulk.Results, r => Assert.True(r.WasSuccessful)); + } + + // === ReadBulk family === + + /// + /// ReadBulk with a mix of allowed and denied tags merges denied entries + /// into the BulkReadReply in original-index order, distinguishable from + /// the SubscribeBulk family because the reply slot is + /// BulkReadReply, not BulkSubscribeReply. + /// + [Fact] + public async Task Invoke_ReadBulk_WithMixedDenials_MergesDeniedBulkReadResults() + { + PredicateConstraintEnforcer enforcer = new() + { + DenyTag = tag => tag == "Secret.Tag", + }; + FakeSessionManager sessionManager = CreateSessionManagerWithSeed(); + sessionManager.InvokeReply = new WorkerCommandReply + { + Reply = new MxCommandReply + { + SessionId = SessionId, + Kind = MxCommandKind.ReadBulk, + ProtocolStatus = MxAccessGrpcMapper.Ok(), + ReadBulk = new BulkReadReply + { + Results = + { + new BulkReadResult { ServerHandle = 7, TagAddress = "Public.A", WasSuccessful = true }, + new BulkReadResult { ServerHandle = 7, TagAddress = "Public.B", WasSuccessful = true }, + }, + }, + }, + }; + MxAccessGatewayService service = CreateService(sessionManager, enforcer); + + MxCommandReply reply = await service.Invoke( + CreateReadBulkRequest(7, ["Public.A", "Secret.Tag", "Public.B"]), + new TestServerCallContext()); + + Assert.Equal(1, sessionManager.InvokeCount); + Assert.Equal(["Public.A", "Public.B"], sessionManager.LastWorkerCommand!.Command.ReadBulk.TagAddresses); + BulkReadReply merged = reply.ReadBulk; + Assert.Equal(3, merged.Results.Count); + Assert.True(merged.Results[0].WasSuccessful); + Assert.False(merged.Results[1].WasSuccessful); + Assert.Equal("Secret.Tag", merged.Results[1].TagAddress); + Assert.True(merged.Results[2].WasSuccessful); + } + + /// + /// ReadBulk with all tags denied must short-circuit and produce a + /// denied-only BulkReadReply — verifying + /// 's ReadBulkConstraintPlan + /// CreateDeniedReply path. + /// + [Fact] + public async Task Invoke_ReadBulk_WhenAllTagsDenied_ShortCircuitsWithDeniedOnlyReply() + { + PredicateConstraintEnforcer enforcer = new() { DenyTag = _ => true }; + FakeSessionManager sessionManager = CreateSessionManagerWithSeed(); + MxAccessGatewayService service = CreateService(sessionManager, enforcer); + + MxCommandReply reply = await service.Invoke( + CreateReadBulkRequest(7, ["X", "Y"]), + new TestServerCallContext()); + + Assert.Equal(0, sessionManager.InvokeCount); + Assert.Equal(2, reply.ReadBulk.Results.Count); + Assert.All(reply.ReadBulk.Results, r => Assert.False(r.WasSuccessful)); + Assert.Equal(MxCommandKind.ReadBulk, reply.Kind); + } + + // === WriteBulk family: WriteBulk / Write2Bulk / WriteSecuredBulk / WriteSecured2Bulk === + + /// + /// WriteBulk with one denied handle must drop that entry from the + /// forwarded command and splice a denied BulkWriteResult back in at + /// the original index. + /// + [Fact] + public async Task Invoke_WriteBulk_WithDeniedHandle_DropsEntryFromWorkerCallAndMergesDenialIntoReply() + { + PredicateConstraintEnforcer enforcer = new() + { + DenyWriteHandle = (_, itemHandle) => itemHandle == 902, + }; + FakeSessionManager sessionManager = CreateSessionManagerWithSeed(); + sessionManager.InvokeReply = new WorkerCommandReply + { + Reply = new MxCommandReply + { + SessionId = SessionId, + Kind = MxCommandKind.WriteBulk, + ProtocolStatus = MxAccessGrpcMapper.Ok(), + WriteBulk = new BulkWriteReply + { + Results = + { + new BulkWriteResult { ServerHandle = 7, ItemHandle = 901, WasSuccessful = true }, + new BulkWriteResult { ServerHandle = 7, ItemHandle = 903, WasSuccessful = true }, + }, + }, + }, + }; + MxAccessGatewayService service = CreateService(sessionManager, enforcer); + + MxCommandReply reply = await service.Invoke( + CreateWriteBulkRequest(7, [901, 902, 903]), + new TestServerCallContext()); + + Assert.Equal(1, sessionManager.InvokeCount); + // 902 dropped from forwarded entries; only 901 and 903 reach the worker. + WriteBulkCommand forwarded = sessionManager.LastWorkerCommand!.Command.WriteBulk; + Assert.Equal([901, 903], forwarded.Entries.Select(e => e.ItemHandle)); + BulkWriteReply merged = reply.WriteBulk; + Assert.Equal(3, merged.Results.Count); + Assert.True(merged.Results[0].WasSuccessful); + Assert.Equal(901, merged.Results[0].ItemHandle); + Assert.False(merged.Results[1].WasSuccessful); + Assert.Equal(902, merged.Results[1].ItemHandle); + Assert.True(merged.Results[2].WasSuccessful); + Assert.Equal(903, merged.Results[2].ItemHandle); + } + + /// + /// WriteSecuredBulk exercises a different ReplaceWriteBulkEntries + /// switch arm than plain WriteBulk. The merge logic is shared, so a + /// full denial here is enough to prove the secured-bulk routing. + /// + [Fact] + public async Task Invoke_WriteSecuredBulk_WhenAllHandlesDenied_ShortCircuitsWithDeniedOnlyReply() + { + PredicateConstraintEnforcer enforcer = new() { DenyWriteHandle = (_, _) => true }; + FakeSessionManager sessionManager = CreateSessionManagerWithSeed(); + MxAccessGatewayService service = CreateService(sessionManager, enforcer); + + MxCommandReply reply = await service.Invoke( + CreateWriteSecuredBulkRequest(7, [10, 11]), + new TestServerCallContext()); + + Assert.Equal(0, sessionManager.InvokeCount); + Assert.Equal(MxCommandKind.WriteSecuredBulk, reply.Kind); + Assert.Equal(2, reply.WriteSecuredBulk.Results.Count); + Assert.All(reply.WriteSecuredBulk.Results, r => Assert.False(r.WasSuccessful)); + } + + /// + /// Tests-020: Write2Bulk takes the third GetPayload/SetPayload + /// switch arm in WriteBulkConstraintPlan. The merge logic is shared with + /// WriteBulk, but a full denial through the CreateDeniedReply path + /// proves the Write2Bulk arm of the per-kind SetPayload switch fires + /// (and not, say, WriteBulk by mistake) — guarding against a refactor that + /// drops or misroutes the Write2Bulk case. + /// + [Fact] + public async Task Invoke_Write2Bulk_WhenAllHandlesDenied_ShortCircuitsWithDeniedOnlyReply() + { + PredicateConstraintEnforcer enforcer = new() { DenyWriteHandle = (_, _) => true }; + FakeSessionManager sessionManager = CreateSessionManagerWithSeed(); + MxAccessGatewayService service = CreateService(sessionManager, enforcer); + + MxCommandReply reply = await service.Invoke( + CreateWrite2BulkRequest(7, [10, 11]), + new TestServerCallContext()); + + Assert.Equal(0, sessionManager.InvokeCount); + Assert.Equal(MxCommandKind.Write2Bulk, reply.Kind); + Assert.Equal(2, reply.Write2Bulk.Results.Count); + Assert.All(reply.Write2Bulk.Results, r => Assert.False(r.WasSuccessful)); + // Sibling reply slots must remain empty — pin the SetPayload arm fired + // for Write2Bulk and not for one of the other three Write*Bulk kinds. + Assert.Empty(reply.WriteBulk?.Results ?? new Google.Protobuf.Collections.RepeatedField()); + Assert.Empty(reply.WriteSecuredBulk?.Results ?? new Google.Protobuf.Collections.RepeatedField()); + Assert.Empty(reply.WriteSecured2Bulk?.Results ?? new Google.Protobuf.Collections.RepeatedField()); + } + + /// + /// Tests-020: WriteSecured2Bulk takes the fourth GetPayload/SetPayload + /// switch arm in WriteBulkConstraintPlan. Same reasoning as + /// Write2Bulk — assert the WriteSecured2Bulk reply slot is populated + /// to prove that arm of the switch fires. + /// + [Fact] + public async Task Invoke_WriteSecured2Bulk_WhenAllHandlesDenied_ShortCircuitsWithDeniedOnlyReply() + { + PredicateConstraintEnforcer enforcer = new() { DenyWriteHandle = (_, _) => true }; + FakeSessionManager sessionManager = CreateSessionManagerWithSeed(); + MxAccessGatewayService service = CreateService(sessionManager, enforcer); + + MxCommandReply reply = await service.Invoke( + CreateWriteSecured2BulkRequest(7, [10, 11]), + new TestServerCallContext()); + + Assert.Equal(0, sessionManager.InvokeCount); + Assert.Equal(MxCommandKind.WriteSecured2Bulk, reply.Kind); + Assert.Equal(2, reply.WriteSecured2Bulk.Results.Count); + Assert.All(reply.WriteSecured2Bulk.Results, r => Assert.False(r.WasSuccessful)); + // Sibling reply slots must remain empty — pin the SetPayload arm fired + // for WriteSecured2Bulk and not for one of the other three Write*Bulk kinds. + Assert.Empty(reply.WriteBulk?.Results ?? new Google.Protobuf.Collections.RepeatedField()); + Assert.Empty(reply.Write2Bulk?.Results ?? new Google.Protobuf.Collections.RepeatedField()); + Assert.Empty(reply.WriteSecuredBulk?.Results ?? new Google.Protobuf.Collections.RepeatedField()); + } + + // === Worker reply-count divergence (Tests-024) === + + /// + /// Tests-024: WriteBulkConstraintPlan.MergeDeniedInto dequeues from + /// allowedResults per non-denied slot via Queue.TryDequeue, + /// which silently returns false when the queue is empty. Pin the + /// observable behaviour when the worker returns FEWER allowed results than + /// the gateway forwarded: the merged reply is truncated — denied entries + /// keep their slots, but the trailing allowed slot for which no worker + /// result arrived is dropped (no synthetic failure result is fabricated). + /// This fixture makes that "silent truncate" behaviour explicit so a future + /// change either fills the gap with a synthetic failure or fails this test. + /// + [Fact] + public async Task Invoke_WriteBulk_WhenWorkerReturnsFewerResultsThanAllowed_MergedReplyIsTruncated() + { + PredicateConstraintEnforcer enforcer = new() + { + DenyWriteHandle = (_, itemHandle) => itemHandle == 902, + }; + FakeSessionManager sessionManager = CreateSessionManagerWithSeed(); + // Gateway forwards 2 allowed handles (901, 903) but the worker returns only + // 1 result. The merge logic should keep denied entry 902 at index 1, place + // the single worker result at index 0, and leave index 2 empty (truncate). + sessionManager.InvokeReply = new WorkerCommandReply + { + Reply = new MxCommandReply + { + SessionId = SessionId, + Kind = MxCommandKind.WriteBulk, + ProtocolStatus = MxAccessGrpcMapper.Ok(), + WriteBulk = new BulkWriteReply + { + Results = + { + new BulkWriteResult { ServerHandle = 7, ItemHandle = 901, WasSuccessful = true }, + }, + }, + }, + }; + MxAccessGatewayService service = CreateService(sessionManager, enforcer); + + MxCommandReply reply = await service.Invoke( + CreateWriteBulkRequest(7, [901, 902, 903]), + new TestServerCallContext()); + + Assert.Equal(1, sessionManager.InvokeCount); + BulkWriteReply merged = reply.WriteBulk; + // Current behaviour: the merged reply is shorter than OriginalCount when + // the worker under-supplies. Two slots survive — the worker result at + // index 0 and the denied entry at index 1 — and the trailing slot is + // silently dropped via Queue.TryDequeue returning false. + Assert.Equal(2, merged.Results.Count); + Assert.True(merged.Results[0].WasSuccessful); + Assert.Equal(901, merged.Results[0].ItemHandle); + Assert.False(merged.Results[1].WasSuccessful); + Assert.Equal(902, merged.Results[1].ItemHandle); + } + + /// + /// Tests-024: when the worker returns MORE allowed results than the + /// gateway forwarded, the extras must be silently ignored — the merged + /// reply length stays at OriginalCount. This pins the + /// for index < OriginalCount loop bound so a regression that + /// accidentally surfaces extras as trailing results is caught. + /// + [Fact] + public async Task Invoke_WriteBulk_WhenWorkerReturnsExtraResults_IgnoresExtras() + { + PredicateConstraintEnforcer enforcer = new() + { + DenyWriteHandle = (_, itemHandle) => itemHandle == 902, + }; + FakeSessionManager sessionManager = CreateSessionManagerWithSeed(); + // Gateway forwards 2 allowed handles (901, 903) but the worker returns 4. + sessionManager.InvokeReply = new WorkerCommandReply + { + Reply = new MxCommandReply + { + SessionId = SessionId, + Kind = MxCommandKind.WriteBulk, + ProtocolStatus = MxAccessGrpcMapper.Ok(), + WriteBulk = new BulkWriteReply + { + Results = + { + new BulkWriteResult { ServerHandle = 7, ItemHandle = 901, WasSuccessful = true }, + new BulkWriteResult { ServerHandle = 7, ItemHandle = 903, WasSuccessful = true }, + new BulkWriteResult { ServerHandle = 7, ItemHandle = 999, WasSuccessful = true }, + new BulkWriteResult { ServerHandle = 7, ItemHandle = 1000, WasSuccessful = true }, + }, + }, + }, + }; + MxAccessGatewayService service = CreateService(sessionManager, enforcer); + + MxCommandReply reply = await service.Invoke( + CreateWriteBulkRequest(7, [901, 902, 903]), + new TestServerCallContext()); + + Assert.Equal(1, sessionManager.InvokeCount); + BulkWriteReply merged = reply.WriteBulk; + // Merged reply length stays at OriginalCount (3); the two extra worker + // results (item handles 999, 1000) are silently discarded by the + // OriginalCount-bounded loop. + Assert.Equal(3, merged.Results.Count); + Assert.Equal(901, merged.Results[0].ItemHandle); + Assert.True(merged.Results[0].WasSuccessful); + Assert.Equal(902, merged.Results[1].ItemHandle); + Assert.False(merged.Results[1].WasSuccessful); + Assert.Equal(903, merged.Results[2].ItemHandle); + Assert.True(merged.Results[2].WasSuccessful); + Assert.DoesNotContain(merged.Results, r => r.ItemHandle == 999); + Assert.DoesNotContain(merged.Results, r => r.ItemHandle == 1000); + } + + // === Unary write-handle enforcement (EnforceWriteHandleAsync) === + + /// + /// Unary Write against a denied (server, item) handle must surface + /// via EnforceWriteHandleAsync + /// and never reach the session manager. + /// + [Fact] + public async Task Invoke_Write_WithDeniedHandle_ThrowsPermissionDeniedAndDoesNotCallWorker() + { + PredicateConstraintEnforcer enforcer = new() + { + DenyWriteHandle = (serverHandle, itemHandle) => serverHandle == 7 && itemHandle == 42, + }; + FakeSessionManager sessionManager = CreateSessionManagerWithSeed(); + MxAccessGatewayService service = CreateService(sessionManager, enforcer); + + RpcException exception = await Assert.ThrowsAsync( + async () => await service.Invoke( + CreateWriteRequest(serverHandle: 7, itemHandle: 42), + new TestServerCallContext())); + + Assert.Equal(StatusCode.PermissionDenied, exception.StatusCode); + Assert.Equal(0, sessionManager.InvokeCount); + Assert.Single(enforcer.RecordedDenials); + Assert.Equal("42", enforcer.RecordedDenials[0].Target); + } + + /// + /// Unary WriteSecured against a denied handle takes the same enforce path + /// and rejects identically — proving the four-arm switch in + /// ApplyConstraintsAsync (Write/Write2/WriteSecured/WriteSecured2) is + /// reachable for at least one of the secured kinds. + /// + [Fact] + public async Task Invoke_WriteSecured_WithDeniedHandle_ThrowsPermissionDenied() + { + PredicateConstraintEnforcer enforcer = new() { DenyWriteHandle = (_, _) => true }; + FakeSessionManager sessionManager = CreateSessionManagerWithSeed(); + MxAccessGatewayService service = CreateService(sessionManager, enforcer); + + RpcException exception = await Assert.ThrowsAsync( + async () => await service.Invoke( + CreateWriteSecuredRequest(serverHandle: 7, itemHandle: 42), + new TestServerCallContext())); + + Assert.Equal(StatusCode.PermissionDenied, exception.StatusCode); + Assert.Equal(0, sessionManager.InvokeCount); + } + + // === Unary read-tag enforcement (EnforceReadTagAsync via AddItem) === + + /// + /// Unary AddItem against a denied tag must surface + /// via EnforceReadTagAsync + /// and never reach the session manager. + /// + [Fact] + public async Task Invoke_AddItem_WithDeniedTag_ThrowsPermissionDeniedAndDoesNotCallWorker() + { + PredicateConstraintEnforcer enforcer = new() + { + DenyTag = tag => tag == "Secret.Tag", + }; + FakeSessionManager sessionManager = CreateSessionManagerWithSeed(); + MxAccessGatewayService service = CreateService(sessionManager, enforcer); + + RpcException exception = await Assert.ThrowsAsync( + async () => await service.Invoke( + CreateAddItemRequest(serverHandle: 7, tagAddress: "Secret.Tag"), + new TestServerCallContext())); + + Assert.Equal(StatusCode.PermissionDenied, exception.StatusCode); + Assert.Equal(0, sessionManager.InvokeCount); + Assert.Single(enforcer.RecordedDenials); + Assert.Equal("Secret.Tag", enforcer.RecordedDenials[0].Target); + } + + // === Helpers === + + private static MxAccessGatewayService CreateService( + FakeSessionManager sessionManager, + IConstraintEnforcer? constraintEnforcer = null) + { + return new MxAccessGatewayService( + sessionManager, + new GatewayRequestIdentityAccessor(), + constraintEnforcer ?? new AllowAllConstraintEnforcer(), + new MxAccessGrpcRequestValidator(), + new MxAccessGrpcMapper(), + new FakeEventStreamService(sessionManager), + new GatewayMetrics(), + NullLogger.Instance, + new FakeGatewayAlarmService()); + } + + private static FakeSessionManager CreateSessionManagerWithSeed() + { + FakeSessionManager sessionManager = new() { ResolveOnlySeededSessions = true }; + sessionManager.SeedSession(CreateSession(SessionId)); + return sessionManager; + } + + private static GatewaySession CreateSession(string sessionId) + { + GatewaySession session = new( + sessionId, + GatewayContractInfo.DefaultBackendName, + "pipe", + "nonce", + "Operator Key", + "operator-session", + "client-correlation", + TimeSpan.FromSeconds(7), + TimeSpan.FromSeconds(30), + TimeSpan.FromSeconds(10), + DateTimeOffset.UtcNow); + session.AttachWorkerClient(new FakeWorkerClient()); + session.MarkReady(); + return session; + } + + private static MxCommandRequest CreateAddItemBulkRequest(int serverHandle, IReadOnlyList tags) + { + AddItemBulkCommand cmd = new() { ServerHandle = serverHandle }; + cmd.TagAddresses.Add(tags); + return new MxCommandRequest + { + SessionId = SessionId, + Command = new MxCommand { Kind = MxCommandKind.AddItemBulk, AddItemBulk = cmd }, + }; + } + + private static MxCommandRequest CreateSubscribeBulkRequest(int serverHandle, IReadOnlyList tags) + { + SubscribeBulkCommand cmd = new() { ServerHandle = serverHandle }; + cmd.TagAddresses.Add(tags); + return new MxCommandRequest + { + SessionId = SessionId, + Command = new MxCommand { Kind = MxCommandKind.SubscribeBulk, SubscribeBulk = cmd }, + }; + } + + private static MxCommandRequest CreateAdviseItemBulkRequest(int serverHandle, IReadOnlyList itemHandles) + { + AdviseItemBulkCommand cmd = new() { ServerHandle = serverHandle }; + cmd.ItemHandles.Add(itemHandles); + return new MxCommandRequest + { + SessionId = SessionId, + Command = new MxCommand { Kind = MxCommandKind.AdviseItemBulk, AdviseItemBulk = cmd }, + }; + } + + private static MxCommandRequest CreateReadBulkRequest(int serverHandle, IReadOnlyList tags) + { + ReadBulkCommand cmd = new() { ServerHandle = serverHandle, TimeoutMs = 1000 }; + cmd.TagAddresses.Add(tags); + return new MxCommandRequest + { + SessionId = SessionId, + Command = new MxCommand { Kind = MxCommandKind.ReadBulk, ReadBulk = cmd }, + }; + } + + private static MxCommandRequest CreateWriteBulkRequest(int serverHandle, IReadOnlyList itemHandles) + { + WriteBulkCommand cmd = new() { ServerHandle = serverHandle }; + foreach (int handle in itemHandles) + { + cmd.Entries.Add(new WriteBulkEntry { ItemHandle = handle, Value = new MxValue { StringValue = "v" } }); + } + + return new MxCommandRequest + { + SessionId = SessionId, + Command = new MxCommand { Kind = MxCommandKind.WriteBulk, WriteBulk = cmd }, + }; + } + + private static MxCommandRequest CreateWriteSecuredBulkRequest(int serverHandle, IReadOnlyList itemHandles) + { + WriteSecuredBulkCommand cmd = new() { ServerHandle = serverHandle }; + foreach (int handle in itemHandles) + { + cmd.Entries.Add(new WriteSecuredBulkEntry + { + ItemHandle = handle, + CurrentUserId = 1, + VerifierUserId = 2, + Value = new MxValue { StringValue = "v" }, + }); + } + + return new MxCommandRequest + { + SessionId = SessionId, + Command = new MxCommand { Kind = MxCommandKind.WriteSecuredBulk, WriteSecuredBulk = cmd }, + }; + } + + private static MxCommandRequest CreateWrite2BulkRequest(int serverHandle, IReadOnlyList itemHandles) + { + Write2BulkCommand cmd = new() { ServerHandle = serverHandle }; + foreach (int handle in itemHandles) + { + cmd.Entries.Add(new Write2BulkEntry + { + ItemHandle = handle, + Value = new MxValue { StringValue = "v" }, + TimestampValue = new MxValue { Int64Value = 1234567890L }, + }); + } + + return new MxCommandRequest + { + SessionId = SessionId, + Command = new MxCommand { Kind = MxCommandKind.Write2Bulk, Write2Bulk = cmd }, + }; + } + + private static MxCommandRequest CreateWriteSecured2BulkRequest(int serverHandle, IReadOnlyList itemHandles) + { + WriteSecured2BulkCommand cmd = new() { ServerHandle = serverHandle }; + foreach (int handle in itemHandles) + { + cmd.Entries.Add(new WriteSecured2BulkEntry + { + ItemHandle = handle, + CurrentUserId = 1, + VerifierUserId = 2, + Value = new MxValue { StringValue = "v" }, + TimestampValue = new MxValue { Int64Value = 1234567890L }, + }); + } + + return new MxCommandRequest + { + SessionId = SessionId, + Command = new MxCommand { Kind = MxCommandKind.WriteSecured2Bulk, WriteSecured2Bulk = cmd }, + }; + } + + private static MxCommandRequest CreateWriteRequest(int serverHandle, int itemHandle) + { + return new MxCommandRequest + { + SessionId = SessionId, + Command = new MxCommand + { + Kind = MxCommandKind.Write, + Write = new WriteCommand + { + ServerHandle = serverHandle, + ItemHandle = itemHandle, + Value = new MxValue { StringValue = "v" }, + }, + }, + }; + } + + private static MxCommandRequest CreateWriteSecuredRequest(int serverHandle, int itemHandle) + { + return new MxCommandRequest + { + SessionId = SessionId, + Command = new MxCommand + { + Kind = MxCommandKind.WriteSecured, + WriteSecured = new WriteSecuredCommand + { + ServerHandle = serverHandle, + ItemHandle = itemHandle, + CurrentUserId = 1, + VerifierUserId = 2, + Value = new MxValue { StringValue = "v" }, + }, + }, + }; + } + + private static MxCommandRequest CreateAddItemRequest(int serverHandle, string tagAddress) + { + return new MxCommandRequest + { + SessionId = SessionId, + Command = new MxCommand + { + Kind = MxCommandKind.AddItem, + AddItem = new AddItemCommand + { + ServerHandle = serverHandle, + ItemDefinition = tagAddress, + }, + }, + }; + } + + // FakeSessionManager / FakeEventStreamService / FakeWorkerClient mirror the + // implementations in MxAccessGatewayServiceTests; the duplication is intentional + // so the constraint tests are self-contained and changes to the existing fakes + // don't accidentally couple the two suites. + private sealed class FakeSessionManager : ISessionManager + { + private readonly Dictionary seededSessions = new(StringComparer.Ordinal); + + public bool ResolveOnlySeededSessions { get; init; } + + public WorkerCommand? LastWorkerCommand { get; private set; } + + public int InvokeCount { get; private set; } + + public WorkerCommandReply InvokeReply { get; set; } = new() + { + Reply = new MxCommandReply + { + SessionId = SessionId, + Kind = MxCommandKind.Ping, + ProtocolStatus = MxAccessGrpcMapper.Ok(), + }, + }; + + public List Events { get; } = []; + + public void SeedSession(GatewaySession session) => seededSessions[session.SessionId] = session; + + public Task OpenSessionAsync( + SessionOpenRequest request, + string? clientIdentity, + CancellationToken cancellationToken) => + Task.FromResult(seededSessions.Values.First()); + + public bool TryGetSession(string sessionId, out GatewaySession session) + { + if (seededSessions.TryGetValue(sessionId, out GatewaySession? seeded)) + { + session = seeded; + return true; + } + + if (ResolveOnlySeededSessions) + { + session = null!; + return false; + } + + session = CreateFallbackSession(sessionId); + return true; + } + + public Task InvokeAsync( + string sessionId, + WorkerCommand command, + CancellationToken cancellationToken) + { + InvokeCount++; + LastWorkerCommand = command; + return Task.FromResult(InvokeReply); + } + + public async IAsyncEnumerable ReadEventsAsync( + string sessionId, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + foreach (WorkerEvent ev in Events) + { + cancellationToken.ThrowIfCancellationRequested(); + await Task.Yield(); + yield return ev; + } + } + + public Task CloseSessionAsync( + string sessionId, + CancellationToken cancellationToken) => + Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false)); + + public Task CloseExpiredLeasesAsync( + DateTimeOffset now, + CancellationToken cancellationToken) => Task.FromResult(0); + + public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + private static GatewaySession CreateFallbackSession(string sessionId) + { + GatewaySession session = new( + sessionId, + GatewayContractInfo.DefaultBackendName, + "pipe", + "nonce", + "Operator Key", + "operator-session", + "client-correlation", + TimeSpan.FromSeconds(7), + TimeSpan.FromSeconds(30), + TimeSpan.FromSeconds(10), + DateTimeOffset.UtcNow); + session.AttachWorkerClient(new FakeWorkerClient()); + session.MarkReady(); + return session; + } + } + + private sealed class FakeEventStreamService(FakeSessionManager sessionManager) : IEventStreamService + { + public async IAsyncEnumerable StreamEventsAsync( + StreamEventsRequest request, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + foreach (WorkerEvent ev in sessionManager.Events) + { + cancellationToken.ThrowIfCancellationRequested(); + await Task.Yield(); + yield return ev.Event; + } + } + } + + private sealed class FakeWorkerClient : IWorkerClient + { + public string SessionId { get; } = MxAccessGatewayServiceConstraintTests.SessionId; + + public int? ProcessId { get; } = 1234; + + public WorkerClientState State { get; } = WorkerClientState.Ready; + + public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow; + + public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public Task InvokeAsync( + WorkerCommand command, + TimeSpan timeout, + CancellationToken cancellationToken) => Task.FromResult(new WorkerCommandReply()); + + public async IAsyncEnumerable ReadEventsAsync( + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + await Task.CompletedTask; + yield break; + } + + public Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken) => Task.CompletedTask; + + public void Kill(string reason) + { + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } +} diff --git a/src/MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceTests.cs similarity index 71% rename from src/MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceTests.cs rename to src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceTests.cs index b3654a1..3aec895 100644 --- a/src/MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceTests.cs @@ -3,16 +3,17 @@ using System.Runtime.CompilerServices; using Google.Protobuf.WellKnownTypes; using Grpc.Core; using Microsoft.Extensions.Logging.Abstractions; -using MxGateway.Contracts; -using MxGateway.Contracts.Proto; -using MxGateway.Server.Grpc; -using MxGateway.Server.Metrics; -using MxGateway.Server.Security.Authentication; -using MxGateway.Server.Security.Authorization; -using MxGateway.Server.Sessions; -using MxGateway.Server.Workers; +using ZB.MOM.WW.MxGateway.Contracts; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Server.Grpc; +using ZB.MOM.WW.MxGateway.Server.Metrics; +using ZB.MOM.WW.MxGateway.Server.Security.Authentication; +using ZB.MOM.WW.MxGateway.Server.Security.Authorization; +using ZB.MOM.WW.MxGateway.Server.Sessions; +using ZB.MOM.WW.MxGateway.Server.Workers; +using ZB.MOM.WW.MxGateway.Tests.TestSupport; -namespace MxGateway.Tests.Gateway.Grpc; +namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Grpc; public sealed class MxAccessGatewayServiceTests { @@ -47,16 +48,17 @@ public sealed class MxAccessGatewayServiceTests Assert.Equal("operator-session", sessionManager.LastOpenRequest?.ClientSessionName); } - /// Verifies that Invoke throws NotFound when the session does not exist. + /// + /// Verifies that Invoke maps a genuinely missing session to NotFound via the + /// service's own ResolveSession lookup. No InvokeException is + /// injected — makes + /// TryGetSession return false, so this test fails if the service drops + /// its missing-session check rather than passing for the wrong reason. + /// [Fact] public async Task Invoke_WhenSessionMissing_ThrowsNotFound() { - FakeSessionManager sessionManager = new() - { - InvokeException = new SessionManagerException( - SessionManagerErrorCode.SessionNotFound, - "Session session-missing was not found."), - }; + FakeSessionManager sessionManager = new() { ResolveOnlySeededSessions = true }; MxAccessGatewayService service = CreateService(sessionManager); RpcException exception = await Assert.ThrowsAsync( @@ -66,6 +68,28 @@ public sealed class MxAccessGatewayServiceTests Assert.Equal(StatusCode.NotFound, exception.StatusCode); Assert.Contains("session-missing", exception.Status.Detail, StringComparison.Ordinal); + // The service must reject before delegating to the session manager. + Assert.Equal(0, sessionManager.InvokeCount); + } + + /// + /// Verifies that Invoke resolves a session that was seeded into the session + /// manager when is on, + /// confirming the missing-session test above is gated on a real lookup. + /// + [Fact] + public async Task Invoke_WhenSessionSeeded_ResolvesAndInvokes() + { + FakeSessionManager sessionManager = new() { ResolveOnlySeededSessions = true }; + sessionManager.SeedSession(CreateSession("session-1", processId: 1234)); + MxAccessGatewayService service = CreateService(sessionManager); + + MxCommandReply reply = await service.Invoke( + CreatePingRequest("session-1"), + new TestServerCallContext()); + + Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); + Assert.Equal(1, sessionManager.InvokeCount); } /// Verifies that Invoke throws InvalidArgument and does not invoke the session manager when payload is mismatched. @@ -154,7 +178,7 @@ public sealed class MxAccessGatewayServiceTests sessionManager.Events.Add(CreateWorkerEvent("session-1", workerSequence: 1)); sessionManager.Events.Add(CreateWorkerEvent("session-1", workerSequence: 2)); MxAccessGatewayService service = CreateService(sessionManager); - TestServerStreamWriter writer = new(); + RecordingServerStreamWriter writer = new(); await service.StreamEvents( new StreamEventsRequest @@ -205,7 +229,7 @@ public sealed class MxAccessGatewayServiceTests FakeSessionManager sessionManager = new(); sessionManager.Events.Add(CreateWorkerEvent("session-1", workerSequence: 2)); MxAccessGatewayService service = CreateService(sessionManager, metrics: metrics); - TestServerStreamWriter writer = new(); + RecordingServerStreamWriter writer = new(); await service.StreamEvents( new StreamEventsRequest { SessionId = "session-1" }, @@ -229,32 +253,13 @@ public sealed class MxAccessGatewayServiceTests Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode); } - // ===== PR A.4 — AcknowledgeAlarm + QueryActiveAlarms handler contract ===== + // ===== AcknowledgeAlarm + StreamAlarms handler contract ===== // - // Worker-side dispatch (translating AcknowledgeAlarm to MxAccess Acknowledge, - // walking the active-alarm collection for QueryActiveAlarms) is gated on PR - // A.2's dev-rig validation. These tests pin the public surface so the worker - // wiring lands without changing observable behaviour for clients. + // AcknowledgeAlarm validates alarm_full_reference then delegates to the + // session-less IGatewayAlarmService; StreamAlarms forwards the central + // alarm feed. CreateService injects FakeGatewayAlarmService. - /// Verifies AcknowledgeAlarm rejects empty session_id. - [Fact] - public async Task AcknowledgeAlarm_WithMissingSessionId_ThrowsInvalidArgument() - { - MxAccessGatewayService service = CreateService(new FakeSessionManager()); - - RpcException exception = await Assert.ThrowsAsync( - async () => await service.AcknowledgeAlarm( - new AcknowledgeAlarmRequest - { - AlarmFullReference = "Tank01.Level.HiHi", - OperatorUser = "alice", - }, - new TestServerCallContext())); - - Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode); - } - - /// Verifies AcknowledgeAlarm rejects empty alarm_full_reference. + /// Verifies AcknowledgeAlarm rejects an empty alarm_full_reference. [Fact] public async Task AcknowledgeAlarm_WithMissingAlarmReference_ThrowsInvalidArgument() { @@ -262,71 +267,47 @@ public sealed class MxAccessGatewayServiceTests RpcException exception = await Assert.ThrowsAsync( async () => await service.AcknowledgeAlarm( - new AcknowledgeAlarmRequest - { - SessionId = "session-1", - OperatorUser = "alice", - }, + new AcknowledgeAlarmRequest { OperatorUser = "alice" }, new TestServerCallContext())); Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode); } - /// Verifies AcknowledgeAlarm returns OK with a worker-pending diagnostic for valid input. + /// Verifies AcknowledgeAlarm delegates a valid request to the alarm service. [Fact] - public async Task AcknowledgeAlarm_WithValidRequest_ReturnsOkWithWorkerPendingDiagnostic() + public async Task AcknowledgeAlarm_WithValidRequest_DelegatesToAlarmService() { MxAccessGatewayService service = CreateService(new FakeSessionManager()); AcknowledgeAlarmReply reply = await service.AcknowledgeAlarm( new AcknowledgeAlarmRequest { - SessionId = "session-1", ClientCorrelationId = "corr-1", - AlarmFullReference = "Tank01.Level.HiHi", + AlarmFullReference = "Galaxy!Area.Tank01.Level.HiHi", Comment = "investigating", OperatorUser = "alice", }, new TestServerCallContext()); Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); - Assert.Equal("session-1", reply.SessionId); Assert.Equal("corr-1", reply.CorrelationId); - Assert.Contains("worker", reply.DiagnosticMessage, StringComparison.OrdinalIgnoreCase); } - /// Verifies QueryActiveAlarms rejects empty session_id. + /// Verifies StreamAlarms forwards the central alarm feed, ending with snapshot_complete. [Fact] - public async Task QueryActiveAlarms_WithMissingSessionId_ThrowsInvalidArgument() + public async Task StreamAlarms_ForwardsTheCentralAlarmFeed() { MxAccessGatewayService service = CreateService(new FakeSessionManager()); + RecordingServerStreamWriter sink = new(); - RpcException exception = await Assert.ThrowsAsync( - async () => await service.QueryActiveAlarms( - new QueryActiveAlarmsRequest(), - new RecordingStreamWriter(), - new TestServerCallContext())); - - Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode); - } - - /// Verifies QueryActiveAlarms streams zero snapshots until PR A.2 wires the worker walk. - [Fact] - public async Task QueryActiveAlarms_WithValidRequest_StreamsZeroSnapshots() - { - MxAccessGatewayService service = CreateService(new FakeSessionManager()); - RecordingStreamWriter sink = new(); - - await service.QueryActiveAlarms( - new QueryActiveAlarmsRequest - { - SessionId = "session-1", - AlarmFilterPrefix = "Tank01.", - }, + await service.StreamAlarms( + new StreamAlarmsRequest(), sink, new TestServerCallContext()); - Assert.Empty(sink.Items); + Assert.Contains( + sink.Messages, + message => message.PayloadCase == AlarmFeedMessage.PayloadOneofCase.SnapshotComplete); } /// Verifies OpenSession advertises the alarm RPC capability strings. @@ -350,17 +331,19 @@ public sealed class MxAccessGatewayServiceTests private static MxAccessGatewayService CreateService( FakeSessionManager sessionManager, IGatewayRequestIdentityAccessor? identityAccessor = null, - GatewayMetrics? metrics = null) + GatewayMetrics? metrics = null, + IConstraintEnforcer? constraintEnforcer = null) { return new MxAccessGatewayService( sessionManager, identityAccessor ?? new GatewayRequestIdentityAccessor(), - new AllowAllConstraintEnforcer(), + constraintEnforcer ?? new AllowAllConstraintEnforcer(), new MxAccessGrpcRequestValidator(), new MxAccessGrpcMapper(), new FakeEventStreamService(sessionManager), metrics ?? new GatewayMetrics(), - NullLogger.Instance); + NullLogger.Instance, + new FakeGatewayAlarmService()); } private static ApiKeyIdentity CreateIdentity() @@ -425,9 +408,26 @@ public sealed class MxAccessGatewayServiceTests private sealed class FakeSessionManager : ISessionManager { + private readonly Dictionary seededSessions = new(StringComparer.Ordinal); + /// The session to return from OpenSessionAsync. public GatewaySession? OpenSessionResult { get; init; } + /// + /// When true, only resolves sessions that have been + /// explicitly seeded via (or ), + /// and returns false for any other id. This exercises the gateway service's own + /// missing-session handling instead of masking it with a synthesized session. + /// + public bool ResolveOnlySeededSessions { get; init; } + + /// Registers a session so resolves its id. + /// Session to register by its . + public void SeedSession(GatewaySession session) + { + seededSessions[session.SessionId] = session; + } + /// The last OpenSessionAsync request captured. public SessionOpenRequest? LastOpenRequest { get; private set; } @@ -484,6 +484,18 @@ public sealed class MxAccessGatewayServiceTests string sessionId, out GatewaySession session) { + if (seededSessions.TryGetValue(sessionId, out GatewaySession? seeded)) + { + session = seeded; + return true; + } + + if (ResolveOnlySeededSessions) + { + session = null!; + return false; + } + session = OpenSessionResult ?? CreateSession(sessionId, processId: 1234); return true; } @@ -564,35 +576,6 @@ public sealed class MxAccessGatewayServiceTests } } - private sealed class AllowAllConstraintEnforcer : IConstraintEnforcer - { - public Task CheckReadTagAsync( - ApiKeyIdentity? identity, - string tagAddress, - CancellationToken cancellationToken) => Task.FromResult(null); - - public Task CheckReadHandleAsync( - ApiKeyIdentity? identity, - GatewaySession session, - int serverHandle, - int itemHandle, - CancellationToken cancellationToken) => Task.FromResult(null); - - public Task CheckWriteHandleAsync( - ApiKeyIdentity? identity, - GatewaySession session, - int serverHandle, - int itemHandle, - CancellationToken cancellationToken) => Task.FromResult(null); - - public Task RecordDenialAsync( - ApiKeyIdentity? identity, - string commandKind, - string target, - ConstraintFailure failure, - CancellationToken cancellationToken) => Task.CompletedTask; - } - private sealed class FakeWorkerClient(int processId) : IWorkerClient { /// @@ -650,97 +633,4 @@ public sealed class MxAccessGatewayServiceTests } } - private sealed class TestServerStreamWriter : IServerStreamWriter - { - /// - public List Messages { get; } = []; - - /// - public WriteOptions? WriteOptions { get; set; } - - /// - public Task WriteAsync(T message) - { - Messages.Add(message); - - return Task.CompletedTask; - } - } - - private sealed class RecordingStreamWriter : IServerStreamWriter - { - public List Items { get; } = new(); - public WriteOptions? WriteOptions { get; set; } - - public Task WriteAsync(T message) - { - Items.Add(message); - return Task.CompletedTask; - } - } - - private sealed class TestServerCallContext(CancellationToken cancellationToken = default) : ServerCallContext - { - private readonly Metadata requestHeaders = []; - private readonly Metadata responseTrailers = []; - private readonly Dictionary userState = []; - private Status status; - private WriteOptions? writeOptions; - - /// - protected override string MethodCore => "/mxaccess_gateway.v1.MxAccessGateway/Test"; - - /// - protected override string HostCore => "localhost"; - - /// - protected override string PeerCore => "ipv4:127.0.0.1:5000"; - - /// - protected override DateTime DeadlineCore => DateTime.UtcNow.AddMinutes(1); - - /// - protected override Metadata RequestHeadersCore => requestHeaders; - - /// - protected override CancellationToken CancellationTokenCore => cancellationToken; - - /// - protected override Metadata ResponseTrailersCore => responseTrailers; - - /// - protected override Status StatusCore - { - get => status; - set => status = value; - } - - /// - protected override WriteOptions? WriteOptionsCore - { - get => writeOptions; - set => writeOptions = value; - } - - /// - protected override AuthContext AuthContextCore { get; } = new( - string.Empty, - new Dictionary>(StringComparer.Ordinal)); - - /// - protected override IDictionary UserStateCore => userState; - - /// - protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders) - { - return Task.CompletedTask; - } - - /// - protected override ContextPropagationToken CreatePropagationTokenCore( - ContextPropagationOptions? options) - { - throw new NotSupportedException(); - } - } } diff --git a/src/MxGateway.Tests/Gateway/Grpc/MxAccessGrpcMapperTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/MxAccessGrpcMapperTests.cs similarity index 95% rename from src/MxGateway.Tests/Gateway/Grpc/MxAccessGrpcMapperTests.cs rename to src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/MxAccessGrpcMapperTests.cs index a6476d3..92ae3e9 100644 --- a/src/MxGateway.Tests/Gateway/Grpc/MxAccessGrpcMapperTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/MxAccessGrpcMapperTests.cs @@ -1,7 +1,7 @@ -using MxGateway.Contracts.Proto; -using MxGateway.Server.Grpc; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Server.Grpc; -namespace MxGateway.Tests.Gateway.Grpc; +namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Grpc; public sealed class MxAccessGrpcMapperTests { diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/GatewaySessionTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/GatewaySessionTests.cs new file mode 100644 index 0000000..9471bec --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/GatewaySessionTests.cs @@ -0,0 +1,284 @@ +using System.Runtime.CompilerServices; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Server.Sessions; +using ZB.MOM.WW.MxGateway.Server.Workers; + +namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Sessions; + +/// +/// Concurrency and disposal regression tests for . +/// Server-015 and Server-016 audited the split lock discipline between +/// _syncRoot (state transitions) and _closeLock (close serialization) +/// and the un-gated DisposeAsync; these tests pin the post-fix behavior. +/// +public sealed class GatewaySessionTests +{ + /// + /// Server-015 regression. A TransitionTo(Ready) issued after + /// has set + /// must not flip the session back to . The + /// blocking worker shutdown keeps CloseAsync parked between the + /// Closing write and the Closed write, which is precisely the + /// window the audit identified. + /// + [Fact] + public async Task TransitionTo_AfterCloseStarted_DoesNotOverwriteClosing() + { + BlockingShutdownWorkerClient workerClient = new(); + GatewaySession session = CreateReadySession(workerClient); + + Task closeTask = session.CloseAsync("test-close", CancellationToken.None); + await workerClient.WaitForShutdownStartAsync(); + + // Close has set _state = Closing under _syncRoot and is parked inside + // worker.ShutdownAsync. A concurrent transition (e.g. a late + // SessionWorkerClientFactory lifecycle callback) must not revive the session. + Assert.Equal(SessionState.Closing, session.State); + session.TransitionTo(SessionState.Ready); + Assert.Equal(SessionState.Closing, session.State); + + workerClient.ReleaseShutdown(); + SessionCloseResult result = await closeTask; + + Assert.Equal(SessionState.Closed, result.FinalState); + Assert.Equal(SessionState.Closed, session.State); + + await session.DisposeAsync(); + } + + /// + /// Server-015 regression. Once finishes, + /// must not be able to move the + /// session out of either — the close path's + /// terminal write goes through the same _syncRoot the rest of the state + /// machine uses, so the existing "Closed is terminal" invariant holds. + /// + [Fact] + public async Task MarkFaulted_AfterCloseCompletes_DoesNotResurrectSession() + { + FakeWorkerClient workerClient = new(); + GatewaySession session = CreateReadySession(workerClient); + + await session.CloseAsync("test-close", CancellationToken.None); + Assert.Equal(SessionState.Closed, session.State); + + session.MarkFaulted("late-fault"); + Assert.Equal(SessionState.Closed, session.State); + + await session.DisposeAsync(); + } + + /// + /// Server-028 regression. A issued + /// while is parked between its + /// Closing and Closed writes must not break the close path's + /// terminal contract: the in-flight close runs to Closed, the fault + /// reason is preserved on , and the + /// session does not get stuck in . The + /// state machine documents "Closing only allows a transition to Closed or + /// Faulted" — this test pins the resolved end state so a future tightening + /// of MarkFaulted cannot silently regress it. + /// + [Fact] + public async Task MarkFaulted_DuringInFlightClose_PreservesFaultButYieldsToClose() + { + BlockingShutdownWorkerClient workerClient = new(); + GatewaySession session = CreateReadySession(workerClient); + + Task closeTask = session.CloseAsync("test-close", CancellationToken.None); + await workerClient.WaitForShutdownStartAsync(); + + // Close has set _state = Closing under _syncRoot and is parked inside + // worker.ShutdownAsync. Fault the session from another thread while parked. + Assert.Equal(SessionState.Closing, session.State); + session.MarkFaulted("concurrent-fault"); + + workerClient.ReleaseShutdown(); + SessionCloseResult result = await closeTask; + + // Close still wins — Closed is terminal — but the fault reason is preserved + // so observers see the original cause once the session settles. + Assert.Equal(SessionState.Closed, result.FinalState); + Assert.Equal(SessionState.Closed, session.State); + Assert.Equal("concurrent-fault", session.FinalFault); + + await session.DisposeAsync(); + } + + /// + /// Server-016 regression. must wait + /// for an in-flight before disposing + /// its semaphore. Without the fix, the close's _closeLock.Release() + /// would race the dispose and raise . + /// + [Fact] + public async Task DisposeAsync_WhileCloseInFlight_WaitsForCloseAndDoesNotThrow() + { + BlockingShutdownWorkerClient workerClient = new(); + GatewaySession session = CreateReadySession(workerClient); + + Task closeTask = session.CloseAsync("test-close", CancellationToken.None); + await workerClient.WaitForShutdownStartAsync(); + + // Start disposing while close is still parked inside worker.ShutdownAsync. + ValueTask disposeTask = session.DisposeAsync(); + + // Now release the worker shutdown so close can complete. + workerClient.ReleaseShutdown(); + + // Both must complete cleanly — the close's Release() must run before the + // dispose actually tears the semaphore down. + SessionCloseResult result = await closeTask; + await disposeTask; + + Assert.Equal(SessionState.Closed, result.FinalState); + Assert.Equal(1, workerClient.ShutdownCount); + // Worker dispose ran exactly once even with the close/dispose interleave. + Assert.Equal(1, workerClient.DisposeCount); + } + + /// + /// Double-dispose is tolerated: the second call must swallow + /// from the already-disposed semaphore + /// rather than propagating it. + /// + [Fact] + public async Task DisposeAsync_CalledTwice_DoesNotThrow() + { + FakeWorkerClient workerClient = new(); + GatewaySession session = CreateReadySession(workerClient); + await session.CloseAsync("test-close", CancellationToken.None); + + await session.DisposeAsync(); + // No second exception — the dispose's defensive ObjectDisposedException catch + // covers the doubled call path that SessionManager.ShutdownAsync could trigger + // if it re-removed a session. + await session.DisposeAsync(); + } + + private static GatewaySession CreateReadySession(IWorkerClient workerClient) + { + GatewaySession session = new( + sessionId: "session-test", + backendName: "mxaccess", + pipeName: "mxaccess-gateway-1-session-test", + nonce: "nonce", + clientIdentity: "client-1", + clientSessionName: "test-session", + clientCorrelationId: "client-correlation-1", + commandTimeout: TimeSpan.FromSeconds(5), + startupTimeout: TimeSpan.FromSeconds(5), + shutdownTimeout: TimeSpan.FromSeconds(5), + leaseDuration: TimeSpan.FromMinutes(30), + openedAt: DateTimeOffset.UtcNow); + session.AttachWorkerClient(workerClient); + session.MarkReady(); + return session; + } + + /// + /// Minimal worker client that parks until the test + /// explicitly releases it. Used to keep + /// stuck between its Closing and Closed writes so the test can + /// observe and act on the intermediate state. + /// + private sealed class BlockingShutdownWorkerClient : IWorkerClient + { + private readonly TaskCompletionSource _shutdownStarted = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _shutdownReleased = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public string SessionId { get; } = "session-test"; + + public int? ProcessId { get; } = 1234; + + public WorkerClientState State { get; private set; } = WorkerClientState.Ready; + + public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow; + + public int ShutdownCount { get; private set; } + + public int DisposeCount { get; private set; } + + public Task WaitForShutdownStartAsync() + { + return _shutdownStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + } + + public void ReleaseShutdown() + { + _shutdownReleased.TrySetResult(); + } + + public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public Task InvokeAsync( + WorkerCommand command, + TimeSpan timeout, + CancellationToken cancellationToken) => Task.FromResult(new WorkerCommandReply()); + + public async IAsyncEnumerable ReadEventsAsync( + [EnumeratorCancellation] CancellationToken cancellationToken) + { + await Task.CompletedTask.ConfigureAwait(false); + yield break; + } + + public async Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken) + { + ShutdownCount++; + _shutdownStarted.TrySetResult(); + await _shutdownReleased.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + State = WorkerClientState.Closed; + } + + public void Kill(string reason) + { + State = WorkerClientState.Faulted; + } + + public ValueTask DisposeAsync() + { + DisposeCount++; + return ValueTask.CompletedTask; + } + } + + private sealed class FakeWorkerClient : IWorkerClient + { + public string SessionId { get; } = "session-test"; + + public int? ProcessId { get; } = 1234; + + public WorkerClientState State { get; } = WorkerClientState.Ready; + + public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow; + + public int DisposeCount { get; private set; } + + public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public Task InvokeAsync( + WorkerCommand command, + TimeSpan timeout, + CancellationToken cancellationToken) => Task.FromResult(new WorkerCommandReply()); + + public async IAsyncEnumerable ReadEventsAsync( + [EnumeratorCancellation] CancellationToken cancellationToken) + { + await Task.CompletedTask.ConfigureAwait(false); + yield break; + } + + public Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken) => Task.CompletedTask; + + public void Kill(string reason) + { + } + + public ValueTask DisposeAsync() + { + DisposeCount++; + return ValueTask.CompletedTask; + } + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/SessionManagerBulkTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/SessionManagerBulkTests.cs new file mode 100644 index 0000000..e91d2ac --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/SessionManagerBulkTests.cs @@ -0,0 +1,846 @@ +using Google.Protobuf.WellKnownTypes; +using Microsoft.Extensions.Options; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Server.Configuration; +using ZB.MOM.WW.MxGateway.Server.Metrics; +using ZB.MOM.WW.MxGateway.Server.Sessions; +using ZB.MOM.WW.MxGateway.Server.Workers; + +namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Sessions; + +/// +/// Tests-013: per-method gateway-side coverage for every +/// GatewaySession.*BulkAsync entry point. Each method gets a +/// round-trip test that pins the sent to the +/// worker, the per-entry payload shape, a failure-mode (per-entry failure +/// surfaced or protocol-status failure) check, and a cancellation-propagation +/// check. The secured-write variants additionally pin that the credential +/// payload (current_user_id, verifier_user_id) is preserved +/// end-to-end and not flattened/redacted by the gateway's command shape. +/// +public sealed class SessionManagerBulkTests +{ + [Fact] + public async Task AddItemBulkAsync_ForwardsOneAddItemBulkCommandAndReturnsResults() + { + FakeBulkWorkerClient workerClient = WithReply(reply => reply.AddItemBulk = new BulkSubscribeReply + { + Results = + { + new SubscribeResult { ServerHandle = 12, TagAddress = "Galaxy.Tag.Ok", ItemHandle = 511, WasSuccessful = true }, + new SubscribeResult { ServerHandle = 12, TagAddress = "Galaxy.Tag.Bad", ItemHandle = 0, WasSuccessful = false, ErrorMessage = "invalid tag" }, + }, + }, MxCommandKind.AddItemBulk); + GatewaySession session = await OpenSessionAsync(workerClient); + + IReadOnlyList results = await session.AddItemBulkAsync( + 12, + ["Galaxy.Tag.Ok", "Galaxy.Tag.Bad"], + CancellationToken.None); + + Assert.Equal(1, workerClient.InvokeCount); + Assert.Equal(MxCommandKind.AddItemBulk, workerClient.LastCommand?.Command.Kind); + Assert.Equal(12, workerClient.LastCommand?.Command.AddItemBulk.ServerHandle); + Assert.Equal(["Galaxy.Tag.Ok", "Galaxy.Tag.Bad"], workerClient.LastCommand?.Command.AddItemBulk.TagAddresses); + Assert.Equal(2, results.Count); + Assert.True(results[0].WasSuccessful); + Assert.False(results[1].WasSuccessful); + Assert.Equal("invalid tag", results[1].ErrorMessage); + } + + [Fact] + public async Task AddItemBulkAsync_PropagatesCancellation() + { + FakeBulkWorkerClient workerClient = new(); + GatewaySession session = await OpenSessionAsync(workerClient); + using CancellationTokenSource cts = new(); + await cts.CancelAsync(); + + await Assert.ThrowsAnyAsync( + async () => await session.AddItemBulkAsync(12, ["Tag.A"], cts.Token)); + } + + [Fact] + public async Task AdviseItemBulkAsync_ForwardsOneAdviseItemBulkCommandAndReturnsResults() + { + FakeBulkWorkerClient workerClient = WithReply(reply => reply.AdviseItemBulk = new BulkSubscribeReply + { + Results = + { + new SubscribeResult { ServerHandle = 12, ItemHandle = 901, WasSuccessful = true }, + new SubscribeResult { ServerHandle = 12, ItemHandle = 902, WasSuccessful = false, ErrorMessage = "invalid item handle" }, + }, + }, MxCommandKind.AdviseItemBulk); + GatewaySession session = await OpenSessionAsync(workerClient); + + IReadOnlyList results = await session.AdviseItemBulkAsync( + 12, + [901, 902], + CancellationToken.None); + + Assert.Equal(MxCommandKind.AdviseItemBulk, workerClient.LastCommand?.Command.Kind); + Assert.Equal(12, workerClient.LastCommand?.Command.AdviseItemBulk.ServerHandle); + Assert.Equal([901, 902], workerClient.LastCommand?.Command.AdviseItemBulk.ItemHandles); + Assert.Equal(2, results.Count); + Assert.True(results[0].WasSuccessful); + Assert.False(results[1].WasSuccessful); + } + + [Fact] + public async Task AdviseItemBulkAsync_PropagatesCancellation() + { + FakeBulkWorkerClient workerClient = new(); + GatewaySession session = await OpenSessionAsync(workerClient); + using CancellationTokenSource cts = new(); + await cts.CancelAsync(); + + await Assert.ThrowsAnyAsync( + async () => await session.AdviseItemBulkAsync(12, [101], cts.Token)); + } + + [Fact] + public async Task RemoveItemBulkAsync_ForwardsOneRemoveItemBulkCommandAndReturnsResults() + { + FakeBulkWorkerClient workerClient = WithReply(reply => reply.RemoveItemBulk = new BulkSubscribeReply + { + Results = + { + new SubscribeResult { ServerHandle = 12, ItemHandle = 11, WasSuccessful = true }, + new SubscribeResult { ServerHandle = 12, ItemHandle = 12, WasSuccessful = false, ErrorMessage = "unknown handle" }, + }, + }, MxCommandKind.RemoveItemBulk); + GatewaySession session = await OpenSessionAsync(workerClient); + + IReadOnlyList results = await session.RemoveItemBulkAsync( + 12, + [11, 12], + CancellationToken.None); + + Assert.Equal(MxCommandKind.RemoveItemBulk, workerClient.LastCommand?.Command.Kind); + Assert.Equal([11, 12], workerClient.LastCommand?.Command.RemoveItemBulk.ItemHandles); + Assert.Equal(2, results.Count); + Assert.False(results[1].WasSuccessful); + } + + [Fact] + public async Task RemoveItemBulkAsync_PropagatesCancellation() + { + FakeBulkWorkerClient workerClient = new(); + GatewaySession session = await OpenSessionAsync(workerClient); + using CancellationTokenSource cts = new(); + await cts.CancelAsync(); + + await Assert.ThrowsAnyAsync( + async () => await session.RemoveItemBulkAsync(12, [11], cts.Token)); + } + + [Fact] + public async Task UnAdviseItemBulkAsync_ForwardsOneUnAdviseItemBulkCommandAndReturnsResults() + { + FakeBulkWorkerClient workerClient = WithReply(reply => reply.UnAdviseItemBulk = new BulkSubscribeReply + { + Results = + { + new SubscribeResult { ServerHandle = 12, ItemHandle = 21, WasSuccessful = true }, + new SubscribeResult { ServerHandle = 12, ItemHandle = 22, WasSuccessful = false, ErrorMessage = "not advised" }, + }, + }, MxCommandKind.UnAdviseItemBulk); + GatewaySession session = await OpenSessionAsync(workerClient); + + IReadOnlyList results = await session.UnAdviseItemBulkAsync( + 12, + [21, 22], + CancellationToken.None); + + Assert.Equal(MxCommandKind.UnAdviseItemBulk, workerClient.LastCommand?.Command.Kind); + Assert.Equal([21, 22], workerClient.LastCommand?.Command.UnAdviseItemBulk.ItemHandles); + Assert.Equal(2, results.Count); + Assert.False(results[1].WasSuccessful); + Assert.Equal("not advised", results[1].ErrorMessage); + } + + [Fact] + public async Task UnAdviseItemBulkAsync_PropagatesCancellation() + { + FakeBulkWorkerClient workerClient = new(); + GatewaySession session = await OpenSessionAsync(workerClient); + using CancellationTokenSource cts = new(); + await cts.CancelAsync(); + + await Assert.ThrowsAnyAsync( + async () => await session.UnAdviseItemBulkAsync(12, [21], cts.Token)); + } + + [Fact] + public async Task SubscribeBulkAsync_SurfacesPerEntryFailures() + { + // SubscribeBulkAsync already has a happy-path test in SessionManagerTests + // (GatewaySessionSubscribeBulkAsync_ForwardsOneBulkCommandAndReturnsResults); + // this complementary test pins the per-entry failure-surface behaviour. + FakeBulkWorkerClient workerClient = WithReply(reply => reply.SubscribeBulk = new BulkSubscribeReply + { + Results = + { + new SubscribeResult { ServerHandle = 12, TagAddress = "Galaxy.Good", ItemHandle = 501, WasSuccessful = true }, + new SubscribeResult { ServerHandle = 12, TagAddress = "Galaxy.Bad", ItemHandle = 0, WasSuccessful = false, ErrorMessage = "MXAccess subscribe failed" }, + }, + }, MxCommandKind.SubscribeBulk); + GatewaySession session = await OpenSessionAsync(workerClient); + + IReadOnlyList results = await session.SubscribeBulkAsync( + 12, + ["Galaxy.Good", "Galaxy.Bad"], + CancellationToken.None); + + Assert.Equal(2, results.Count); + Assert.True(results[0].WasSuccessful); + Assert.False(results[1].WasSuccessful); + Assert.Equal("MXAccess subscribe failed", results[1].ErrorMessage); + } + + [Fact] + public async Task SubscribeBulkAsync_PropagatesCancellation() + { + FakeBulkWorkerClient workerClient = new(); + GatewaySession session = await OpenSessionAsync(workerClient); + using CancellationTokenSource cts = new(); + await cts.CancelAsync(); + + await Assert.ThrowsAnyAsync( + async () => await session.SubscribeBulkAsync(12, ["Tag"], cts.Token)); + } + + [Fact] + public async Task UnsubscribeBulkAsync_ForwardsOneUnsubscribeBulkCommandAndReturnsResults() + { + FakeBulkWorkerClient workerClient = WithReply(reply => reply.UnsubscribeBulk = new BulkSubscribeReply + { + Results = + { + new SubscribeResult { ServerHandle = 12, ItemHandle = 31, WasSuccessful = true }, + new SubscribeResult { ServerHandle = 12, ItemHandle = 32, WasSuccessful = false, ErrorMessage = "unknown handle" }, + }, + }, MxCommandKind.UnsubscribeBulk); + GatewaySession session = await OpenSessionAsync(workerClient); + + IReadOnlyList results = await session.UnsubscribeBulkAsync( + 12, + [31, 32], + CancellationToken.None); + + Assert.Equal(MxCommandKind.UnsubscribeBulk, workerClient.LastCommand?.Command.Kind); + Assert.Equal([31, 32], workerClient.LastCommand?.Command.UnsubscribeBulk.ItemHandles); + Assert.Equal(2, results.Count); + Assert.False(results[1].WasSuccessful); + } + + [Fact] + public async Task UnsubscribeBulkAsync_PropagatesCancellation() + { + FakeBulkWorkerClient workerClient = new(); + GatewaySession session = await OpenSessionAsync(workerClient); + using CancellationTokenSource cts = new(); + await cts.CancelAsync(); + + await Assert.ThrowsAnyAsync( + async () => await session.UnsubscribeBulkAsync(12, [31], cts.Token)); + } + + [Fact] + public async Task WriteBulkAsync_SurfacesPerEntryFailures() + { + // Complement the existing happy-path WriteBulk test in SessionManagerTests + // with an explicit per-entry failure assertion plus payload-shape pinning. + FakeBulkWorkerClient workerClient = WithReply(reply => reply.WriteBulk = new BulkWriteReply + { + Results = + { + new BulkWriteResult { ServerHandle = 12, ItemHandle = 901, WasSuccessful = true }, + new BulkWriteResult { ServerHandle = 12, ItemHandle = 902, WasSuccessful = false, ErrorMessage = "MXAccess invalid handle" }, + }, + }, MxCommandKind.WriteBulk); + GatewaySession session = await OpenSessionAsync(workerClient); + + IReadOnlyList results = await session.WriteBulkAsync( + 12, + new[] + { + new WriteBulkEntry { ItemHandle = 901, UserId = 5, Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 11 } }, + new WriteBulkEntry { ItemHandle = 902, UserId = 5, Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 22 } }, + }, + CancellationToken.None); + + Assert.Equal(MxCommandKind.WriteBulk, workerClient.LastCommand?.Command.Kind); + Assert.Equal(2, workerClient.LastCommand?.Command.WriteBulk.Entries.Count); + Assert.Equal(901, workerClient.LastCommand?.Command.WriteBulk.Entries[0].ItemHandle); + Assert.Equal(11, workerClient.LastCommand?.Command.WriteBulk.Entries[0].Value.Int32Value); + Assert.False(results[1].WasSuccessful); + Assert.Equal("MXAccess invalid handle", results[1].ErrorMessage); + } + + [Fact] + public async Task WriteBulkAsync_PropagatesCancellation() + { + FakeBulkWorkerClient workerClient = new(); + GatewaySession session = await OpenSessionAsync(workerClient); + using CancellationTokenSource cts = new(); + await cts.CancelAsync(); + + await Assert.ThrowsAnyAsync( + async () => await session.WriteBulkAsync( + 12, + new[] { new WriteBulkEntry { ItemHandle = 1, UserId = 1, Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 0 } } }, + cts.Token)); + } + + [Fact] + public async Task Write2BulkAsync_ForwardsOneWrite2BulkCommandAndPreservesTimestampPayload() + { + FakeBulkWorkerClient workerClient = WithReply(reply => reply.Write2Bulk = new BulkWriteReply + { + Results = + { + new BulkWriteResult { ServerHandle = 12, ItemHandle = 701, WasSuccessful = true }, + new BulkWriteResult { ServerHandle = 12, ItemHandle = 702, WasSuccessful = false, ErrorMessage = "MXAccess Write2 failed" }, + }, + }, MxCommandKind.Write2Bulk); + GatewaySession session = await OpenSessionAsync(workerClient); + + IReadOnlyList results = await session.Write2BulkAsync( + 12, + new[] + { + new Write2BulkEntry + { + ItemHandle = 701, + UserId = 5, + Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 11 }, + TimestampValue = new MxValue { DataType = MxDataType.Time, Int64Value = 1234567890L }, + }, + new Write2BulkEntry + { + ItemHandle = 702, + UserId = 5, + Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 22 }, + TimestampValue = new MxValue { DataType = MxDataType.Time, Int64Value = 1234567891L }, + }, + }, + CancellationToken.None); + + Assert.Equal(MxCommandKind.Write2Bulk, workerClient.LastCommand?.Command.Kind); + Assert.Equal(12, workerClient.LastCommand?.Command.Write2Bulk.ServerHandle); + Assert.Equal(2, workerClient.LastCommand?.Command.Write2Bulk.Entries.Count); + Assert.Equal(701, workerClient.LastCommand?.Command.Write2Bulk.Entries[0].ItemHandle); + Assert.Equal(1234567890L, workerClient.LastCommand?.Command.Write2Bulk.Entries[0].TimestampValue.Int64Value); + Assert.False(results[1].WasSuccessful); + } + + [Fact] + public async Task Write2BulkAsync_PropagatesCancellation() + { + FakeBulkWorkerClient workerClient = new(); + GatewaySession session = await OpenSessionAsync(workerClient); + using CancellationTokenSource cts = new(); + await cts.CancelAsync(); + + await Assert.ThrowsAnyAsync( + async () => await session.Write2BulkAsync( + 12, + new[] + { + new Write2BulkEntry + { + ItemHandle = 1, + UserId = 1, + Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 0 }, + TimestampValue = new MxValue { DataType = MxDataType.Time, Int64Value = 0L }, + }, + }, + cts.Token)); + } + + [Fact] + public async Task WriteSecuredBulkAsync_ForwardsOneWriteSecuredBulkCommandAndPreservesCredentialPayload() + { + // The secured variants carry caller credential identifiers (CurrentUserId / + // VerifierUserId). Pin that those survive the gateway round-trip end-to-end — + // the over-the-wire command shape must NOT redact or flatten them, only the + // *log surface* (see GatewaySession's redaction rules) is allowed to drop them. + FakeBulkWorkerClient workerClient = WithReply(reply => reply.WriteSecuredBulk = new BulkWriteReply + { + Results = + { + new BulkWriteResult { ServerHandle = 12, ItemHandle = 601, WasSuccessful = true }, + new BulkWriteResult { ServerHandle = 12, ItemHandle = 602, WasSuccessful = false, ErrorMessage = "MXAccess secured-write rejected" }, + }, + }, MxCommandKind.WriteSecuredBulk); + GatewaySession session = await OpenSessionAsync(workerClient); + + IReadOnlyList results = await session.WriteSecuredBulkAsync( + 12, + new[] + { + new WriteSecuredBulkEntry + { + ItemHandle = 601, + CurrentUserId = 7, + VerifierUserId = 8, + Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 1 }, + }, + new WriteSecuredBulkEntry + { + ItemHandle = 602, + CurrentUserId = 7, + VerifierUserId = 8, + Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 2 }, + }, + }, + CancellationToken.None); + + Assert.Equal(MxCommandKind.WriteSecuredBulk, workerClient.LastCommand?.Command.Kind); + Assert.Equal(12, workerClient.LastCommand?.Command.WriteSecuredBulk.ServerHandle); + Assert.Equal(2, workerClient.LastCommand?.Command.WriteSecuredBulk.Entries.Count); + WriteSecuredBulkEntry firstEntry = workerClient.LastCommand!.Command.WriteSecuredBulk.Entries[0]; + Assert.Equal(601, firstEntry.ItemHandle); + Assert.Equal(7, firstEntry.CurrentUserId); + Assert.Equal(8, firstEntry.VerifierUserId); + Assert.Equal(1, firstEntry.Value.Int32Value); + Assert.False(results[1].WasSuccessful); + Assert.Equal("MXAccess secured-write rejected", results[1].ErrorMessage); + } + + [Fact] + public async Task WriteSecuredBulkAsync_PropagatesCancellation() + { + FakeBulkWorkerClient workerClient = new(); + GatewaySession session = await OpenSessionAsync(workerClient); + using CancellationTokenSource cts = new(); + await cts.CancelAsync(); + + await Assert.ThrowsAnyAsync( + async () => await session.WriteSecuredBulkAsync( + 12, + new[] + { + new WriteSecuredBulkEntry + { + ItemHandle = 1, + CurrentUserId = 7, + VerifierUserId = 8, + Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 0 }, + }, + }, + cts.Token)); + } + + /// + /// Tests-022: Pin mid-flight cancellation behaviour for at least one bulk + /// path. Unlike the pre-cancel WriteSecuredBulkAsync_PropagatesCancellation + /// above, this fake's + /// returns a -backed task that does NOT + /// complete until the registered token fires. The session call therefore + /// reaches InvokeBulkInternalAsyncInvokeAsync → + /// workerClient.InvokeAsync and parks on an in-flight await; only + /// after that does cts.CancelAsync() fire. This is the path a real + /// client closing its stream would hit, which the pre-cancel pattern can't + /// exercise. + /// + [Fact] + public async Task WriteSecuredBulkAsync_WhenCancelledMidFlight_ThrowsOperationCanceledForRequestToken() + { + MidFlightBulkWorkerClient workerClient = new(); + GatewaySession session = await OpenSessionAsync(workerClient); + using CancellationTokenSource cts = new(); + + Task> writeTask = session.WriteSecuredBulkAsync( + 12, + new[] + { + new WriteSecuredBulkEntry + { + ItemHandle = 1, + CurrentUserId = 7, + VerifierUserId = 8, + Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 0 }, + }, + }, + cts.Token); + + // Wait until the gateway has descended into the worker's InvokeAsync and + // registered its cancellation continuation — only then is this a true + // mid-flight cancel. + await workerClient.InvokeStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.False(writeTask.IsCompleted); + + await cts.CancelAsync(); + + OperationCanceledException exception = await Assert.ThrowsAnyAsync( + async () => await writeTask); + Assert.Equal(cts.Token, exception.CancellationToken); + Assert.Equal(1, workerClient.InvokeCount); + } + + [Fact] + public async Task WriteSecured2BulkAsync_ForwardsOneWriteSecured2BulkCommandAndPreservesCredentialAndTimestampPayload() + { + FakeBulkWorkerClient workerClient = WithReply(reply => reply.WriteSecured2Bulk = new BulkWriteReply + { + Results = + { + new BulkWriteResult { ServerHandle = 12, ItemHandle = 801, WasSuccessful = true }, + new BulkWriteResult { ServerHandle = 12, ItemHandle = 802, WasSuccessful = false, ErrorMessage = "MXAccess secured2-write rejected" }, + }, + }, MxCommandKind.WriteSecured2Bulk); + GatewaySession session = await OpenSessionAsync(workerClient); + + IReadOnlyList results = await session.WriteSecured2BulkAsync( + 12, + new[] + { + new WriteSecured2BulkEntry + { + ItemHandle = 801, + CurrentUserId = 7, + VerifierUserId = 8, + Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 1 }, + TimestampValue = new MxValue { DataType = MxDataType.Time, Int64Value = 1700000000L }, + }, + new WriteSecured2BulkEntry + { + ItemHandle = 802, + CurrentUserId = 7, + VerifierUserId = 8, + Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 2 }, + TimestampValue = new MxValue { DataType = MxDataType.Time, Int64Value = 1700000001L }, + }, + }, + CancellationToken.None); + + Assert.Equal(MxCommandKind.WriteSecured2Bulk, workerClient.LastCommand?.Command.Kind); + Assert.Equal(2, workerClient.LastCommand?.Command.WriteSecured2Bulk.Entries.Count); + WriteSecured2BulkEntry firstEntry = workerClient.LastCommand!.Command.WriteSecured2Bulk.Entries[0]; + Assert.Equal(801, firstEntry.ItemHandle); + Assert.Equal(7, firstEntry.CurrentUserId); + Assert.Equal(8, firstEntry.VerifierUserId); + Assert.Equal(1, firstEntry.Value.Int32Value); + Assert.Equal(1700000000L, firstEntry.TimestampValue.Int64Value); + Assert.False(results[1].WasSuccessful); + } + + [Fact] + public async Task WriteSecured2BulkAsync_PropagatesCancellation() + { + FakeBulkWorkerClient workerClient = new(); + GatewaySession session = await OpenSessionAsync(workerClient); + using CancellationTokenSource cts = new(); + await cts.CancelAsync(); + + await Assert.ThrowsAnyAsync( + async () => await session.WriteSecured2BulkAsync( + 12, + new[] + { + new WriteSecured2BulkEntry + { + ItemHandle = 1, + CurrentUserId = 7, + VerifierUserId = 8, + Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 0 }, + TimestampValue = new MxValue { DataType = MxDataType.Time, Int64Value = 0L }, + }, + }, + cts.Token)); + } + + [Fact] + public async Task ReadBulkAsync_SurfacesPerEntryFailures() + { + // Complement the existing happy-path ReadBulk test in SessionManagerTests + // with the failure-mode case where one tag failed to read. + FakeBulkWorkerClient workerClient = WithReply(reply => reply.ReadBulk = new BulkReadReply + { + Results = + { + new BulkReadResult + { + ServerHandle = 12, + TagAddress = "Galaxy.Good", + ItemHandle = 511, + WasSuccessful = true, + WasCached = false, + Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 42 }, + }, + new BulkReadResult + { + ServerHandle = 12, + TagAddress = "Galaxy.Bad", + ItemHandle = 0, + WasSuccessful = false, + ErrorMessage = "MXAccess read timed out", + }, + }, + }, MxCommandKind.ReadBulk); + GatewaySession session = await OpenSessionAsync(workerClient); + + IReadOnlyList results = await session.ReadBulkAsync( + 12, + ["Galaxy.Good", "Galaxy.Bad"], + TimeSpan.FromMilliseconds(750), + CancellationToken.None); + + Assert.Equal(MxCommandKind.ReadBulk, workerClient.LastCommand?.Command.Kind); + Assert.Equal(750u, workerClient.LastCommand?.Command.ReadBulk.TimeoutMs); + Assert.Equal(["Galaxy.Good", "Galaxy.Bad"], workerClient.LastCommand?.Command.ReadBulk.TagAddresses); + Assert.Equal(2, results.Count); + Assert.True(results[0].WasSuccessful); + Assert.False(results[1].WasSuccessful); + Assert.Equal("MXAccess read timed out", results[1].ErrorMessage); + } + + [Fact] + public async Task ReadBulkAsync_PropagatesCancellation() + { + FakeBulkWorkerClient workerClient = new(); + GatewaySession session = await OpenSessionAsync(workerClient); + using CancellationTokenSource cts = new(); + await cts.CancelAsync(); + + await Assert.ThrowsAnyAsync( + async () => await session.ReadBulkAsync( + 12, + ["Galaxy.Tag"], + TimeSpan.FromMilliseconds(500), + cts.Token)); + } + + // ----------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------- + + private static FakeBulkWorkerClient WithReply(Action populate, MxCommandKind kind) + { + MxCommandReply reply = new() + { + SessionId = "session-1", + CorrelationId = "correlation-1", + Kind = kind, + ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok }, + }; + populate(reply); + return new FakeBulkWorkerClient + { + InvokeReply = new WorkerCommandReply { Reply = reply }, + }; + } + + private static async Task OpenSessionAsync(FakeBulkWorkerClient workerClient) + { + return await OpenSessionAsync((IWorkerClient)workerClient); + } + + private static async Task OpenSessionAsync(IWorkerClient workerClient) + { + SessionManager manager = CreateManager(workerClient); + return await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", CancellationToken.None); + } + + private static SessionManager CreateManager(IWorkerClient workerClient) + { + return new SessionManager( + new SessionRegistry(), + new FakeBulkSessionWorkerClientFactory(workerClient), + Options.Create(new GatewayOptions + { + Sessions = new SessionOptions + { + DefaultCommandTimeoutSeconds = 30, + MaxSessions = 16, + DefaultLeaseSeconds = 1800, + }, + Worker = new WorkerOptions + { + StartupTimeoutSeconds = 30, + ShutdownTimeoutSeconds = 10, + }, + }), + new GatewayMetrics()); + } + + private static SessionOpenRequest CreateOpenRequest() + { + return new SessionOpenRequest( + RequestedBackend: null, + ClientSessionName: "test-session", + ClientCorrelationId: "client-correlation-1", + CommandTimeout: Duration.FromTimeSpan(TimeSpan.FromSeconds(5))); + } + + private sealed class FakeBulkSessionWorkerClientFactory(IWorkerClient workerClient) : ISessionWorkerClientFactory + { + /// + public Task CreateAsync( + GatewaySession session, + CancellationToken cancellationToken) + { + return Task.FromResult(workerClient); + } + } + + private sealed class FakeBulkWorkerClient : IWorkerClient + { + /// + public string SessionId { get; init; } = "session-1"; + + /// + public int? ProcessId { get; init; } = 1234; + + /// + public WorkerClientState State { get; set; } = WorkerClientState.Ready; + + /// + public DateTimeOffset LastHeartbeatAt { get; init; } = DateTimeOffset.UtcNow; + + /// Gets the number of times Invoke was called on the fake worker client. + public int InvokeCount { get; private set; } + + /// Gets the last command invoked on the fake worker client. + public WorkerCommand? LastCommand { get; private set; } + + /// Gets the reply to return for invoke calls on the fake worker client. + public WorkerCommandReply? InvokeReply { get; init; } + + /// + public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + /// + public Task InvokeAsync( + WorkerCommand command, + TimeSpan timeout, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + InvokeCount++; + LastCommand = command; + if (InvokeReply is not null) + { + return Task.FromResult(InvokeReply); + } + + MxCommandKind kind = command.Command?.Kind ?? MxCommandKind.Unspecified; + return Task.FromResult(new WorkerCommandReply + { + Reply = new MxCommandReply + { + SessionId = SessionId, + CorrelationId = "correlation-1", + Kind = kind, + ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok }, + }, + }); + } + + /// + public async IAsyncEnumerable ReadEventsAsync( + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + await Task.CompletedTask; + yield break; + } + + /// + public Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken) + { + State = WorkerClientState.Closed; + return Task.CompletedTask; + } + + /// + public void Kill(string reason) => State = WorkerClientState.Faulted; + + /// + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } + + /// + /// Mid-flight cancellation fake for Tests-022. + /// signals , registers + /// a cancellation continuation on the caller's , + /// and parks on a that completes + /// only when the token fires or the fake is shut down. This is the only + /// way to land an on the async + /// continuation rather than the synchronous fast-path inside + /// ThrowIfCancellationRequested. + /// + private sealed class MidFlightBulkWorkerClient : IWorkerClient + { + private readonly TaskCompletionSource _invokeCompletion = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// + public string SessionId { get; init; } = "session-1"; + + /// + public int? ProcessId { get; init; } = 1234; + + /// + public WorkerClientState State { get; set; } = WorkerClientState.Ready; + + /// + public DateTimeOffset LastHeartbeatAt { get; init; } = DateTimeOffset.UtcNow; + + /// Gets the number of times was entered. + public int InvokeCount { get; private set; } + + /// Signals when first enters — the test + /// awaits this before triggering mid-flight cancellation. + public TaskCompletionSource InvokeStarted { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// + public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + /// + public Task InvokeAsync( + WorkerCommand command, + TimeSpan timeout, + CancellationToken cancellationToken) + { + InvokeCount++; + // Register cancellation BEFORE signalling start so the test can be + // certain the continuation is wired the moment InvokeStarted resolves. + cancellationToken.Register(() => _invokeCompletion.TrySetCanceled(cancellationToken)); + InvokeStarted.TrySetResult(); + return _invokeCompletion.Task; + } + + /// + public async IAsyncEnumerable ReadEventsAsync( + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + await Task.CompletedTask; + yield break; + } + + /// + public Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken) + { + State = WorkerClientState.Closed; + _invokeCompletion.TrySetCanceled(cancellationToken); + return Task.CompletedTask; + } + + /// + public void Kill(string reason) + { + State = WorkerClientState.Faulted; + _invokeCompletion.TrySetCanceled(); + } + + /// + public ValueTask DisposeAsync() + { + _invokeCompletion.TrySetCanceled(); + return ValueTask.CompletedTask; + } + } +} diff --git a/src/MxGateway.Tests/Gateway/Sessions/SessionManagerTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/SessionManagerTests.cs similarity index 79% rename from src/MxGateway.Tests/Gateway/Sessions/SessionManagerTests.cs rename to src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/SessionManagerTests.cs index ae20bb5..5c64db7 100644 --- a/src/MxGateway.Tests/Gateway/Sessions/SessionManagerTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/SessionManagerTests.cs @@ -1,12 +1,13 @@ using Google.Protobuf.WellKnownTypes; using Microsoft.Extensions.Options; -using MxGateway.Contracts.Proto; -using MxGateway.Server.Configuration; -using MxGateway.Server.Metrics; -using MxGateway.Server.Sessions; -using MxGateway.Server.Workers; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Server.Configuration; +using ZB.MOM.WW.MxGateway.Server.Metrics; +using ZB.MOM.WW.MxGateway.Server.Sessions; +using ZB.MOM.WW.MxGateway.Server.Workers; +using ZB.MOM.WW.MxGateway.Tests.TestSupport; -namespace MxGateway.Tests.Gateway.Sessions; +namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Sessions; public sealed class SessionManagerTests { @@ -24,7 +25,7 @@ public sealed class SessionManagerTests GatewaySession session = await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", CancellationToken.None); - Assert.True(manager.TryGetSession(session.SessionId, out GatewaySession registered)); + Assert.True(manager.TryGetSession(session.SessionId, out GatewaySession? registered)); Assert.Same(session, registered); Assert.Equal(SessionState.Ready, session.State); Assert.Equal("client-1", session.ClientIdentity); @@ -33,11 +34,11 @@ public sealed class SessionManagerTests Assert.Equal(1, metrics.GetSnapshot().SessionsOpened); } - /// Verifies that opening a session generates a correlation ID from the client name and session ID. + /// Verifies that opening a session sets the initial lease expiry from the configured default lease. [Fact] public async Task OpenSessionAsync_SetsInitialDefaultLease() { - ManualTimeProvider clock = new(DateTimeOffset.Parse("2026-04-29T10:00:00Z")); + ManualTimeProvider clock = new(DateTimeOffset.Parse("2026-04-29T10:00:00Z", System.Globalization.CultureInfo.InvariantCulture)); GatewayOptions options = CreateOptions(defaultLeaseSeconds: 1800); SessionManager manager = CreateManager( new FakeSessionWorkerClientFactory(new FakeWorkerClient()), @@ -96,7 +97,7 @@ public sealed class SessionManagerTests Assert.Equal(MxCommandKind.Ping, reply.Reply.Kind); } - /// Verifies that bulk subscribe forwards the command and returns subscription results. + /// Verifies that invoking a command on a ready session refreshes its lease expiry. [Fact] public async Task InvokeAsync_WhenSessionReady_RefreshesLease() { @@ -167,6 +168,119 @@ public sealed class SessionManagerTests Assert.Equal(["Galaxy.Tag.Value"], workerClient.LastCommand?.Command.SubscribeBulk.TagAddresses); } + [Fact] + public async Task GatewaySessionWriteBulkAsync_ForwardsOneBulkCommandAndReturnsResults() + { + FakeWorkerClient workerClient = new() + { + InvokeReply = new WorkerCommandReply + { + Reply = new MxCommandReply + { + SessionId = "session-1", + CorrelationId = "correlation-1", + Kind = MxCommandKind.WriteBulk, + ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok }, + WriteBulk = new BulkWriteReply + { + Results = + { + new BulkWriteResult + { + ServerHandle = 12, + ItemHandle = 901, + WasSuccessful = true, + }, + new BulkWriteResult + { + ServerHandle = 12, + ItemHandle = 902, + WasSuccessful = false, + ErrorMessage = "MXAccess invalid handle", + }, + }, + }, + }, + }, + }; + SessionManager manager = CreateManager(new FakeSessionWorkerClientFactory(workerClient)); + GatewaySession session = await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", CancellationToken.None); + + IReadOnlyList results = await session.WriteBulkAsync( + 12, + new[] + { + new WriteBulkEntry + { + ItemHandle = 901, + UserId = 5, + Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 11 }, + }, + new WriteBulkEntry + { + ItemHandle = 902, + UserId = 5, + Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 22 }, + }, + }, + CancellationToken.None); + + Assert.Equal(2, results.Count); + Assert.True(results[0].WasSuccessful); + Assert.False(results[1].WasSuccessful); + Assert.Equal(MxCommandKind.WriteBulk, workerClient.LastCommand?.Command.Kind); + Assert.Equal(2, workerClient.LastCommand?.Command.WriteBulk.Entries.Count); + } + + [Fact] + public async Task GatewaySessionReadBulkAsync_ForwardsOneBulkCommandAndReturnsResults() + { + FakeWorkerClient workerClient = new() + { + InvokeReply = new WorkerCommandReply + { + Reply = new MxCommandReply + { + SessionId = "session-1", + CorrelationId = "correlation-1", + Kind = MxCommandKind.ReadBulk, + ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok }, + ReadBulk = new BulkReadReply + { + Results = + { + new BulkReadResult + { + ServerHandle = 12, + TagAddress = "Galaxy.Tag.Value", + ItemHandle = 512, + WasSuccessful = true, + WasCached = true, + Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 42 }, + }, + }, + }, + }, + }, + }; + SessionManager manager = CreateManager(new FakeSessionWorkerClientFactory(workerClient)); + GatewaySession session = await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", CancellationToken.None); + + IReadOnlyList results = await session.ReadBulkAsync( + 12, + ["Galaxy.Tag.Value"], + TimeSpan.FromMilliseconds(500), + CancellationToken.None); + + BulkReadResult result = Assert.Single(results); + Assert.True(result.WasSuccessful); + Assert.True(result.WasCached); + Assert.Equal(42, result.Value.Int32Value); + Assert.Equal(MxCommandKind.ReadBulk, workerClient.LastCommand?.Command.Kind); + Assert.Equal(["Galaxy.Tag.Value"], workerClient.LastCommand?.Command.ReadBulk.TagAddresses); + Assert.Equal(500u, workerClient.LastCommand?.Command.ReadBulk.TimeoutMs); + } + /// Verifies that invoking a command on a faulted session rejects the command. [Fact] public async Task InvokeAsync_WhenSessionFaulted_RejectsCommand() @@ -186,6 +300,36 @@ public sealed class SessionManagerTests Assert.Equal(0, workerClient.InvokeCount); } + /// + /// Server-030 regression: when the gateway-side SessionState is + /// Ready but the worker client's own state is not, the diagnostic + /// must surface both states so the mismatch is actionable instead of + /// producing a self-contradictory "Session ... is not ready. Current + /// state is Ready." message. + /// + [Fact] + public async Task InvokeAsync_WhenWorkerNotReadyButSessionReady_DiagnosticIncludesBothStates() + { + FakeWorkerClient workerClient = new(); + SessionManager manager = CreateManager(new FakeSessionWorkerClientFactory(workerClient)); + GatewaySession session = await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", CancellationToken.None); + + // Force a state mismatch: session stays Ready, worker transitions out. + workerClient.State = WorkerClientState.Handshaking; + Assert.Equal(SessionState.Ready, session.State); + + SessionManagerException exception = await Assert.ThrowsAsync( + async () => await manager.InvokeAsync( + session.SessionId, + CreateCommand(MxCommandKind.Ping), + CancellationToken.None)); + + Assert.Equal(SessionManagerErrorCode.SessionNotReady, exception.ErrorCode); + Assert.Contains("Session state is Ready", exception.Message); + Assert.Contains("worker state is Handshaking", exception.Message); + Assert.Equal(0, workerClient.InvokeCount); + } + /// Verifies that closing a session removes it from the registry. [Fact] public async Task CloseSessionAsync_RemovesClosedSession() @@ -362,7 +506,7 @@ public sealed class SessionManagerTests Assert.Equal(0, activeClient.ShutdownCount); } - /// Verifies that shutdown closes all registered sessions. + /// Verifies that an expired-lease sweep leaves a session with an active event subscriber open. [Fact] public async Task CloseExpiredLeasesAsync_DoesNotCloseActiveEventSubscriber() { @@ -650,10 +794,4 @@ public sealed class SessionManagerTests } } - private sealed class ManualTimeProvider(DateTimeOffset start) : TimeProvider - { - private DateTimeOffset _now = start; - - public override DateTimeOffset GetUtcNow() => _now; - } } diff --git a/src/MxGateway.Tests/Gateway/Sessions/SessionWorkerClientFactoryFakeWorkerTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/SessionWorkerClientFactoryFakeWorkerTests.cs similarity index 60% rename from src/MxGateway.Tests/Gateway/Sessions/SessionWorkerClientFactoryFakeWorkerTests.cs rename to src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/SessionWorkerClientFactoryFakeWorkerTests.cs index c9b132f..a61cb96 100644 --- a/src/MxGateway.Tests/Gateway/Sessions/SessionWorkerClientFactoryFakeWorkerTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/SessionWorkerClientFactoryFakeWorkerTests.cs @@ -1,24 +1,38 @@ using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; -using MxGateway.Contracts; -using MxGateway.Contracts.Proto; -using MxGateway.Server.Configuration; -using MxGateway.Server.Metrics; -using MxGateway.Server.Sessions; -using MxGateway.Server.Workers; -using MxGateway.Tests.Gateway.Workers.Fakes; +using ZB.MOM.WW.MxGateway.Contracts; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Server.Configuration; +using ZB.MOM.WW.MxGateway.Server.Metrics; +using ZB.MOM.WW.MxGateway.Server.Sessions; +using ZB.MOM.WW.MxGateway.Server.Workers; +using ZB.MOM.WW.MxGateway.Tests.Gateway.Workers.Fakes; -namespace MxGateway.Tests.Gateway.Sessions; +namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Sessions; -public sealed class SessionWorkerClientFactoryFakeWorkerTests +public sealed class SessionWorkerClientFactoryFakeWorkerTests : IAsyncDisposable { private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(5); + private readonly List _launchers = []; + + /// + /// Awaits every scripted worker task so an unhandled exception fails the owning test + /// instead of surfacing later as an unobserved . + /// + public async ValueTask DisposeAsync() + { + foreach (IWorkerTaskLauncher launcher in _launchers) + { + await launcher.ObserveWorkerTaskAsync(TestTimeout); + } + } + /// Verifies that the factory creates a ready worker client with a scripted fake worker process. [Fact] public async Task CreateAsync_WithScriptedFakeWorker_ReturnsReadyClient() { - ScriptedFakeWorkerProcessLauncher launcher = new(); + ScriptedFakeWorkerProcessLauncher launcher = Track(new ScriptedFakeWorkerProcessLauncher()); using GatewayMetrics metrics = new(); SessionWorkerClientFactory factory = new( launcher, @@ -51,7 +65,7 @@ public sealed class SessionWorkerClientFactoryFakeWorkerTests [Fact] public async Task CreateAsync_WhenFakeWorkerStartupFails_ThrowsWorkerClientException() { - FailingStartupWorkerProcessLauncher launcher = new(); + FailingStartupWorkerProcessLauncher launcher = Track(new FailingStartupWorkerProcessLauncher()); using GatewayMetrics metrics = new(); SessionWorkerClientFactory factory = new( launcher, @@ -71,7 +85,7 @@ public sealed class SessionWorkerClientFactoryFakeWorkerTests [Fact] public async Task CreateAsync_WhenFakeWorkerNeverSendsReady_TimesOutAndKillsWorker() { - NeverReadyWorkerProcessLauncher launcher = new(); + NeverReadyWorkerProcessLauncher launcher = Track(new NeverReadyWorkerProcessLauncher()); using GatewayMetrics metrics = new(); SessionWorkerClientFactory factory = new( launcher, @@ -134,8 +148,50 @@ public sealed class SessionWorkerClientFactoryFakeWorkerTests }; } + private T Track(T launcher) + where T : IWorkerTaskLauncher + { + _launchers.Add(launcher); + + return launcher; + } + + /// + /// A fake worker launcher that runs a scripted worker on a background task and exposes + /// that task so the owning test observes it rather than leaking an unobserved fault. + /// + private interface IWorkerTaskLauncher : IWorkerProcessLauncher + { + /// + /// Awaits the scripted worker task within the timeout, swallowing only the pipe + /// teardown faults expected when the worker client kills or disposes the worker. + /// + /// Maximum time to wait for the worker task. + Task ObserveWorkerTaskAsync(TimeSpan timeout); + } + + /// + /// Awaits a scripted worker task, treating cancellation and pipe-disconnect I/O faults as + /// the expected outcome of the worker client tearing the worker down, and rethrowing anything else. + /// + private static async Task ObserveWorkerTaskAsync(Task workerTask, TimeSpan timeout) + { + try + { + await workerTask.WaitAsync(timeout).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Expected: the worker client cancelled the scripted worker during teardown. + } + catch (IOException) + { + // Expected: the gateway pipe was closed when the worker client disposed. + } + } + /// Fake worker launcher that connects a scripted fake worker harness. - private sealed class ScriptedFakeWorkerProcessLauncher : IWorkerProcessLauncher + private sealed class ScriptedFakeWorkerProcessLauncher : IWorkerTaskLauncher { /// The fake process ID used by the scripted launcher. public const int ProcessId = 2468; @@ -144,16 +200,23 @@ public sealed class SessionWorkerClientFactoryFakeWorkerTests /// Gets the connected fake worker harness. public FakeWorkerHarness? Harness { get; private set; } + /// Gets the scripted worker task. + public Task WorkerTask { get; private set; } = Task.CompletedTask; + /// public Task LaunchAsync( WorkerProcessLaunchRequest request, CancellationToken cancellationToken = default) { - _ = RunWorkerAsync(request, cancellationToken); + WorkerTask = RunWorkerAsync(request, cancellationToken); return Task.FromResult(CreateHandle(_process)); } + /// + public Task ObserveWorkerTaskAsync(TimeSpan timeout) => + SessionWorkerClientFactoryFakeWorkerTests.ObserveWorkerTaskAsync(WorkerTask, timeout); + private async Task RunWorkerAsync( WorkerProcessLaunchRequest request, CancellationToken cancellationToken) @@ -169,21 +232,28 @@ public sealed class SessionWorkerClientFactoryFakeWorkerTests } /// Fake worker launcher that fails during startup with protocol version mismatch. - private sealed class FailingStartupWorkerProcessLauncher : IWorkerProcessLauncher + private sealed class FailingStartupWorkerProcessLauncher : IWorkerTaskLauncher { /// Gets the fake worker process. public FakeWorkerProcess Process { get; } = new(processId: 3579); + /// Gets the scripted worker task. + public Task WorkerTask { get; private set; } = Task.CompletedTask; + /// public Task LaunchAsync( WorkerProcessLaunchRequest request, CancellationToken cancellationToken = default) { - _ = RunWorkerAsync(request, cancellationToken); + WorkerTask = RunWorkerAsync(request, cancellationToken); return Task.FromResult(CreateHandle(Process)); } + /// + public Task ObserveWorkerTaskAsync(TimeSpan timeout) => + SessionWorkerClientFactoryFakeWorkerTests.ObserveWorkerTaskAsync(WorkerTask, timeout); + private async Task RunWorkerAsync( WorkerProcessLaunchRequest request, CancellationToken cancellationToken) @@ -203,37 +273,52 @@ public sealed class SessionWorkerClientFactoryFakeWorkerTests } /// Fake worker launcher that never completes startup, simulating a hung worker. - private sealed class NeverReadyWorkerProcessLauncher : IWorkerProcessLauncher + private sealed class NeverReadyWorkerProcessLauncher : IWorkerTaskLauncher { + private readonly CancellationTokenSource _stop = new(); + /// Gets the fake worker process. public FakeWorkerProcess Process { get; } = new(processId: 4680); + /// Gets the scripted worker task. + public Task WorkerTask { get; private set; } = Task.CompletedTask; + /// public Task LaunchAsync( WorkerProcessLaunchRequest request, CancellationToken cancellationToken = default) { - _ = RunWorkerAsync(request, cancellationToken); + WorkerTask = RunWorkerAsync(request); return Task.FromResult(CreateHandle(Process)); } - private async Task RunWorkerAsync( - WorkerProcessLaunchRequest request, - CancellationToken cancellationToken) + /// + public async Task ObserveWorkerTaskAsync(TimeSpan timeout) + { + // The scripted worker parks on an infinite delay; cancel it so disposal observes + // the task instead of leaking it as an unobserved fault. + await _stop.CancelAsync().ConfigureAwait(false); + await SessionWorkerClientFactoryFakeWorkerTests + .ObserveWorkerTaskAsync(WorkerTask, timeout) + .ConfigureAwait(false); + _stop.Dispose(); + } + + private async Task RunWorkerAsync(WorkerProcessLaunchRequest request) { await using FakeWorkerHarness harness = await FakeWorkerHarness.ConnectToGatewayPipeAsync( request.SessionId, request.Nonce, request.PipeName, request.ProtocolVersion, - cancellationToken: cancellationToken).ConfigureAwait(false); - _ = await harness.ReadGatewayEnvelopeAsync(cancellationToken).ConfigureAwait(false); + cancellationToken: _stop.Token).ConfigureAwait(false); + _ = await harness.ReadGatewayEnvelopeAsync(_stop.Token).ConfigureAwait(false); await harness.SendWorkerHelloAsync( workerProcessId: Process.Id, workerProtocolVersion: request.ProtocolVersion, - cancellationToken: cancellationToken).ConfigureAwait(false); - await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false); + cancellationToken: _stop.Token).ConfigureAwait(false); + await Task.Delay(Timeout.InfiniteTimeSpan, _stop.Token).ConfigureAwait(false); } } @@ -245,18 +330,28 @@ public sealed class SessionWorkerClientFactoryFakeWorkerTests DateTimeOffset.UtcNow); } - /// Fake worker process for testing process lifecycle. + /// + /// Fake worker process for testing process lifecycle. + /// awaits a completed only by + /// or , so a caller observing + /// completion can trust that exit actually happened — bringing this fake into + /// parity with the smoke-test variant in GatewayEndToEndFakeWorkerSmokeTests + /// (Tests-015 / Tests-023). This removes the latent regression vector where a + /// future Assert.True(launcher.Process.HasExited) in this file would + /// pass spuriously regardless of whether the worker truly exited. + /// private sealed class FakeWorkerProcess(int processId) : IWorkerProcess { + private readonly TaskCompletionSource _exited = new(TaskCreationOptions.RunContinuationsAsynchronously); private bool _disposed; /// public int Id { get; } = processId; - /// Gets or sets a value indicating whether the process has exited. + /// Gets a value indicating whether the process has exited. public bool HasExited { get; private set; } - /// Gets or sets the process exit code. + /// Gets the process exit code, or null if the process has not exited. public int? ExitCode { get; private set; } /// Gets the number of times the Kill method was called. @@ -265,17 +360,14 @@ public sealed class SessionWorkerClientFactoryFakeWorkerTests /// public ValueTask WaitForExitAsync(CancellationToken cancellationToken) { - HasExited = true; - ExitCode = 0; - return ValueTask.CompletedTask; + return new ValueTask(_exited.Task.WaitAsync(cancellationToken)); } /// public void Kill(bool entireProcessTree) { KillCount++; - HasExited = true; - ExitCode = -1; + MarkExited(-1); } /// @@ -286,5 +378,14 @@ public sealed class SessionWorkerClientFactoryFakeWorkerTests /// Gets a value indicating whether this process has been disposed. public bool IsDisposed => _disposed; + + /// Marks the process as exited with the specified exit code. + /// The process exit code. + public void MarkExited(int exitCode) + { + HasExited = true; + ExitCode = exitCode; + _exited.TrySetResult(); + } } } diff --git a/src/MxGateway.Tests/Gateway/Workers/FakeWorkerHarnessTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/FakeWorkerHarnessTests.cs similarity index 91% rename from src/MxGateway.Tests/Gateway/Workers/FakeWorkerHarnessTests.cs rename to src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/FakeWorkerHarnessTests.cs index 4a92174..a4edd57 100644 --- a/src/MxGateway.Tests/Gateway/Workers/FakeWorkerHarnessTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/FakeWorkerHarnessTests.cs @@ -1,9 +1,10 @@ -using MxGateway.Contracts; -using MxGateway.Contracts.Proto; -using MxGateway.Server.Workers; -using MxGateway.Tests.Gateway.Workers.Fakes; +using ZB.MOM.WW.MxGateway.Contracts; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Server.Workers; +using ZB.MOM.WW.MxGateway.Tests.Gateway.Workers.Fakes; +using ZB.MOM.WW.MxGateway.Tests.TestSupport; -namespace MxGateway.Tests.Gateway.Workers; +namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Workers; public sealed class FakeWorkerHarnessTests { @@ -110,16 +111,21 @@ public sealed class FakeWorkerHarnessTests Assert.Equal(WorkerClientState.Faulted, client.State); } - /// Verifies that sending a heartbeat updates the client heartbeat state. + /// + /// Verifies that sending a heartbeat updates the client heartbeat state. Uses a + /// so the timestamp advance is deterministic rather + /// than relying on a wall-clock Task.Delay exceeding clock resolution. + /// [Fact] public async Task SendHeartbeatAsync_UpdatesClientHeartbeatState() { + ManualTimeProvider clock = new(DateTimeOffset.Parse("2026-05-18T12:00:00Z", System.Globalization.CultureInfo.InvariantCulture)); await using FakeWorkerHarness fakeWorker = await FakeWorkerHarness.CreateConnectedPairAsync(); - await using WorkerClient client = fakeWorker.CreateClient(); + await using WorkerClient client = fakeWorker.CreateClient(timeProvider: clock); await StartClientAsync(fakeWorker, client); DateTimeOffset previousHeartbeat = client.LastHeartbeatAt; - await Task.Delay(TimeSpan.FromMilliseconds(20)); + clock.Advance(TimeSpan.FromSeconds(1)); await fakeWorker.SendHeartbeatAsync( configureHeartbeat: heartbeat => heartbeat.WorkerProcessId = 2468); @@ -128,6 +134,7 @@ public sealed class FakeWorkerHarnessTests TestTimeout); Assert.Equal(WorkerClientState.Ready, client.State); + Assert.Equal(previousHeartbeat + TimeSpan.FromSeconds(1), client.LastHeartbeatAt); } /// Verifies that a hung worker times out pending command invocations. @@ -215,4 +222,5 @@ public sealed class FakeWorkerHarnessTests await Task.Delay(TimeSpan.FromMilliseconds(10), cancellationTokenSource.Token); } } + } diff --git a/src/MxGateway.Tests/Gateway/Workers/Fakes/FakeWorkerHarness.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/Fakes/FakeWorkerHarness.cs similarity index 98% rename from src/MxGateway.Tests/Gateway/Workers/Fakes/FakeWorkerHarness.cs rename to src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/Fakes/FakeWorkerHarness.cs index 2541c33..9d03b6d 100644 --- a/src/MxGateway.Tests/Gateway/Workers/Fakes/FakeWorkerHarness.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/Fakes/FakeWorkerHarness.cs @@ -1,12 +1,12 @@ using System.Buffers.Binary; using System.IO.Pipes; using Google.Protobuf.WellKnownTypes; -using MxGateway.Contracts; -using MxGateway.Contracts.Proto; -using MxGateway.Server.Metrics; -using MxGateway.Server.Workers; +using ZB.MOM.WW.MxGateway.Contracts; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Server.Metrics; +using ZB.MOM.WW.MxGateway.Server.Workers; -namespace MxGateway.Tests.Gateway.Workers.Fakes; +namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Workers.Fakes; public sealed class FakeWorkerHarness : IAsyncDisposable { diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/OrphanWorkerTerminatorTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/OrphanWorkerTerminatorTests.cs new file mode 100644 index 0000000..3507700 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/OrphanWorkerTerminatorTests.cs @@ -0,0 +1,137 @@ +using Microsoft.Extensions.Options; +using ZB.MOM.WW.MxGateway.Server.Configuration; +using ZB.MOM.WW.MxGateway.Server.Metrics; +using ZB.MOM.WW.MxGateway.Server.Workers; + +namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Workers; + +/// +/// Server-002 regression: per gateway.md the gateway must terminate +/// orphaned worker processes on startup. These tests pin that the terminator +/// kills leftover workers (matched by executable path, or by image name when +/// the path is unreadable) without touching unrelated processes or itself. +/// +public sealed class OrphanWorkerTerminatorTests +{ + private const string WorkerExecutablePath = @"C:\app\src\ZB.MOM.WW.MxGateway.Worker\bin\x86\Release\ZB.MOM.WW.MxGateway.Worker.exe"; + + [Fact] + public void TerminateOrphans_KillsWorkerProcessesMatchingConfiguredExecutablePath() + { + FakeProcessInspector inspector = new( + [ + new RunningProcessInfo(101, WorkerExecutablePath), + new RunningProcessInfo(102, WorkerExecutablePath), + ]); + OrphanWorkerTerminator terminator = CreateTerminator(inspector); + + int killed = terminator.TerminateOrphans(); + + Assert.Equal(2, killed); + Assert.Equal([101, 102], inspector.KilledProcessIds.Order()); + } + + [Fact] + public void TerminateOrphans_KillsImageNameMatchWhenExecutablePathUnreadable() + { + // The x64 gateway cannot introspect the x86 worker's main module, so the + // path comes back null. Image-name match is the only signal — and it is + // exactly the orphan worker case, so the process must still be killed. + FakeProcessInspector inspector = new( + [ + new RunningProcessInfo(201, ExecutablePath: null), + ]); + OrphanWorkerTerminator terminator = CreateTerminator(inspector); + + int killed = terminator.TerminateOrphans(); + + Assert.Equal(1, killed); + Assert.Equal([201], inspector.KilledProcessIds); + } + + [Fact] + public void TerminateOrphans_DoesNotKillUnrelatedProcessSharingImageName() + { + // A process with the same image name but a different executable path is + // not our worker and must be left alone. + FakeProcessInspector inspector = new( + [ + new RunningProcessInfo(301, @"C:\other\place\ZB.MOM.WW.MxGateway.Worker.exe"), + ]); + OrphanWorkerTerminator terminator = CreateTerminator(inspector); + + int killed = terminator.TerminateOrphans(); + + Assert.Equal(0, killed); + Assert.Empty(inspector.KilledProcessIds); + } + + [Fact] + public void TerminateOrphans_DoesNotKillCurrentProcess() + { + FakeProcessInspector inspector = new( + [ + new RunningProcessInfo(Environment.ProcessId, WorkerExecutablePath), + ]); + OrphanWorkerTerminator terminator = CreateTerminator(inspector); + + int killed = terminator.TerminateOrphans(); + + Assert.Equal(0, killed); + Assert.Empty(inspector.KilledProcessIds); + } + + [Fact] + public void TerminateOrphans_ContinuesWhenOneKillThrows() + { + FakeProcessInspector inspector = new( + [ + new RunningProcessInfo(401, WorkerExecutablePath), + new RunningProcessInfo(402, WorkerExecutablePath), + ]) + { + ThrowOnKillProcessId = 401, + }; + OrphanWorkerTerminator terminator = CreateTerminator(inspector); + + int killed = terminator.TerminateOrphans(); + + Assert.Equal(1, killed); + Assert.Contains(402, inspector.KilledProcessIds); + } + + private static OrphanWorkerTerminator CreateTerminator(IRunningProcessInspector inspector) + { + GatewayOptions options = new() + { + Worker = new WorkerOptions + { + ExecutablePath = WorkerExecutablePath, + }, + }; + return new OrphanWorkerTerminator( + Options.Create(options), + inspector, + new GatewayMetrics()); + } + + private sealed class FakeProcessInspector(IReadOnlyList processes) + : IRunningProcessInspector + { + public List KilledProcessIds { get; } = []; + + public int? ThrowOnKillProcessId { get; init; } + + public IReadOnlyList GetProcessesByName(string processName) => processes; + + public void Kill(int processId) + { + if (ThrowOnKillProcessId == processId) + { + throw new InvalidOperationException("Process has already exited."); + } + + KilledProcessIds.Add(processId); + } + } +} diff --git a/src/MxGateway.Tests/Gateway/Workers/WorkerClientTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/WorkerClientTests.cs similarity index 59% rename from src/MxGateway.Tests/Gateway/Workers/WorkerClientTests.cs rename to src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/WorkerClientTests.cs index 862191b..ef06a0a 100644 --- a/src/MxGateway.Tests/Gateway/Workers/WorkerClientTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/WorkerClientTests.cs @@ -1,11 +1,12 @@ using System.IO.Pipes; using Google.Protobuf.WellKnownTypes; -using MxGateway.Contracts; -using MxGateway.Contracts.Proto; -using MxGateway.Server.Metrics; -using MxGateway.Server.Workers; +using ZB.MOM.WW.MxGateway.Contracts; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Server.Metrics; +using ZB.MOM.WW.MxGateway.Server.Workers; +using ZB.MOM.WW.MxGateway.Tests.TestSupport; -namespace MxGateway.Tests.Gateway.Workers; +namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Workers; public sealed class WorkerClientTests { @@ -71,9 +72,11 @@ public sealed class WorkerClientTests async () => await timedOutInvokeTask); Assert.Equal(WorkerClientErrorCode.CommandTimeout, exception.ErrorCode); + // Send the stale reply for the already-timed-out command, then the second + // command's reply. The pipe is FIFO, so the read loop processes (and discards) + // the stale reply before the second reply — no fixed Task.Delay needed. await pipePair.WorkerWriter.WriteAsync( CreateCommandReplyEnvelope(timedOutCommand.CorrelationId, MxCommandKind.Ping)); - await Task.Delay(TimeSpan.FromMilliseconds(50)); Task secondInvokeTask = client.InvokeAsync( CreateCommand(MxCommandKind.GetWorkerInfo), @@ -125,6 +128,7 @@ public sealed class WorkerClientTests new WorkerClientOptions { EventChannelCapacity = 1, + EventChannelFullModeTimeout = TimeSpan.FromMilliseconds(50), HeartbeatGrace = TimeSpan.FromSeconds(30), HeartbeatCheckInterval = TimeSpan.FromSeconds(30), }); @@ -142,7 +146,14 @@ public sealed class WorkerClientTests Assert.Equal(WorkerClientState.Faulted, client.State); } - /// Verifies that the read loop faults the client when the pipe disconnects. + /// + /// Verifies that when the client faults it kills the owned worker process. + /// The assertion waits on , which + /// completes exactly when Kill runs, instead of polling client.State. + /// Polling state is racy: publishes the + /// Faulted state before it calls KillOwnedProcess, so a state-based + /// wait can observe Faulted while KillCount is still 0. + /// [Fact] public async Task ReadLoop_WhenClientFaults_KillsOwnedWorkerProcess() { @@ -153,6 +164,7 @@ public sealed class WorkerClientTests new WorkerClientOptions { EventChannelCapacity = 1, + EventChannelFullModeTimeout = TimeSpan.FromMilliseconds(50), HeartbeatGrace = TimeSpan.FromSeconds(30), HeartbeatCheckInterval = TimeSpan.FromSeconds(30), }, @@ -164,15 +176,77 @@ public sealed class WorkerClientTests await pipePair.WorkerWriter.WriteAsync( CreateEventEnvelope(sequence: 12, MxEventFamily.OnDataChange)); - await WaitUntilAsync( - () => client.State == WorkerClientState.Faulted, - TestTimeout); + // Deterministic: this completes the instant Kill() runs, with no timing window. + using CancellationTokenSource exitTimeout = new(TestTimeout); + await process.WaitForExitAsync(exitTimeout.Token); + Assert.Equal(WorkerClientState.Faulted, client.State); Assert.Equal(1, process.KillCount); Assert.True(process.KillEntireProcessTree); Assert.True(process.HasExited); } + /// + /// Verifies that a worker faulting mid-command — the pipe dropping while an + /// is still pending — completes the pending + /// invoke task with a carrying the + /// pipe-disconnected error code rather than hanging until the command timeout. + /// + [Fact] + public async Task InvokeAsync_WhenPipeDisconnectsMidCommand_FailsPendingInvokeWithPipeDisconnected() + { + await using PipePair pipePair = await PipePair.CreateAsync(); + await using WorkerClient client = CreateClient(pipePair); + await CompleteHandshakeAsync(client, pipePair); + + Task invokeTask = client.InvokeAsync( + CreateCommand(MxCommandKind.Ping), + TestTimeout, + CancellationToken.None); + + // The worker received the command but disconnects before replying. + WorkerEnvelope commandEnvelope = await pipePair.WorkerReader.ReadAsync().AsTask().WaitAsync(TestTimeout); + Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerCommand, commandEnvelope.BodyCase); + await pipePair.DisposeWorkerSideAsync(); + + WorkerClientException exception = await Assert.ThrowsAsync( + async () => await invokeTask.WaitAsync(TestTimeout)); + + Assert.Equal(WorkerClientErrorCode.PipeDisconnected, exception.ErrorCode); + await WaitUntilAsync(() => client.State == WorkerClientState.Faulted, TestTimeout); + Assert.Equal(WorkerClientState.Faulted, client.State); + } + + /// + /// Verifies that a worker emitting a WorkerFault envelope while an + /// is pending completes the pending invoke + /// task with a carrying the worker-faulted + /// error code. + /// + [Fact] + public async Task InvokeAsync_WhenWorkerFaultsMidCommand_FailsPendingInvokeWithWorkerFaulted() + { + await using PipePair pipePair = await PipePair.CreateAsync(); + await using WorkerClient client = CreateClient(pipePair); + await CompleteHandshakeAsync(client, pipePair); + + Task invokeTask = client.InvokeAsync( + CreateCommand(MxCommandKind.Ping), + TestTimeout, + CancellationToken.None); + + WorkerEnvelope commandEnvelope = await pipePair.WorkerReader.ReadAsync().AsTask().WaitAsync(TestTimeout); + Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerCommand, commandEnvelope.BodyCase); + await pipePair.WorkerWriter.WriteAsync(CreateWorkerFaultEnvelope("scripted mid-command fault")); + + WorkerClientException exception = await Assert.ThrowsAsync( + async () => await invokeTask.WaitAsync(TestTimeout)); + + Assert.Equal(WorkerClientErrorCode.WorkerFaulted, exception.ErrorCode); + await WaitUntilAsync(() => client.State == WorkerClientState.Faulted, TestTimeout); + Assert.Equal(WorkerClientState.Faulted, client.State); + } + [Fact] public async Task ReadLoop_WhenPipeDisconnects_FaultsClient() { @@ -244,15 +318,22 @@ public sealed class WorkerClientTests Assert.True(process.Disposed); } + /// + /// Verifies that a heartbeat envelope updates the last-heartbeat timestamp and worker + /// process id. Uses a so the timestamp advance is + /// deterministic instead of relying on a wall-clock Task.Delay exceeding + /// resolution. + /// [Fact] public async Task ReadLoop_WhenHeartbeatArrives_UpdatesLastHeartbeatAndWorkerProcess() { + ManualTimeProvider clock = new(DateTimeOffset.Parse("2026-05-18T12:00:00Z", System.Globalization.CultureInfo.InvariantCulture)); await using PipePair pipePair = await PipePair.CreateAsync(); - await using WorkerClient client = CreateClient(pipePair); + await using WorkerClient client = CreateClient(pipePair, timeProvider: clock); await CompleteHandshakeAsync(client, pipePair); DateTimeOffset previousHeartbeat = client.LastHeartbeatAt; - await Task.Delay(TimeSpan.FromMilliseconds(20)); + clock.Advance(TimeSpan.FromSeconds(1)); await pipePair.WorkerWriter.WriteAsync(CreateHeartbeatEnvelope(workerProcessId: 9876)); await WaitUntilAsync( @@ -260,12 +341,20 @@ public sealed class WorkerClientTests TestTimeout); Assert.Equal(WorkerClientState.Ready, client.State); + Assert.Equal(previousHeartbeat + TimeSpan.FromSeconds(1), client.LastHeartbeatAt); } - /// Verifies that the heartbeat monitor faults the client when the heartbeat expires. + /// + /// Verifies that the heartbeat monitor faults the client when the heartbeat expires. + /// Uses an injected so the grace comparison is deterministic + /// instead of depending on real wall-clock advance; the monitor's + /// timer stays on the real clock and + /// observes the manually-advanced grace on its next tick. + /// [Fact] public async Task HeartbeatMonitor_WhenHeartbeatExpires_FaultsClient() { + ManualTimeProvider clock = new(DateTimeOffset.Parse("2026-05-20T12:00:00Z", System.Globalization.CultureInfo.InvariantCulture)); await using PipePair pipePair = await PipePair.CreateAsync(); await using WorkerClient client = CreateClient( pipePair, @@ -274,9 +363,12 @@ public sealed class WorkerClientTests HeartbeatGrace = TimeSpan.FromMilliseconds(80), HeartbeatCheckInterval = TimeSpan.FromMilliseconds(20), EventChannelCapacity = 8, - }); + }, + timeProvider: clock); await CompleteHandshakeAsync(client, pipePair); + clock.Advance(TimeSpan.FromSeconds(2)); + await WaitUntilAsync( () => client.State == WorkerClientState.Faulted, TestTimeout); @@ -284,11 +376,156 @@ public sealed class WorkerClientTests Assert.Equal(WorkerClientState.Faulted, client.State); } + /// + /// Server-031 regression: while a command is in flight on the + /// gateway↔worker pipe and the oldest pending command is younger + /// than , the + /// heartbeat watchdog must NOT fault on heartbeat-expired alone — the + /// gap is more likely caused by pipe-write contention than by a hung + /// worker. Mirrors Worker-023 on the worker side. + /// + [Fact] + public async Task HeartbeatMonitor_WhenCommandInFlightWithinCeiling_DoesNotFaultOnExpiredHeartbeat() + { + ManualTimeProvider clock = new(DateTimeOffset.Parse("2026-05-20T13:00:00Z", System.Globalization.CultureInfo.InvariantCulture)); + await using PipePair pipePair = await PipePair.CreateAsync(); + await using WorkerClient client = CreateClient( + pipePair, + new WorkerClientOptions + { + HeartbeatGrace = TimeSpan.FromMilliseconds(80), + HeartbeatCheckInterval = TimeSpan.FromMilliseconds(20), + EventChannelCapacity = 8, + HeartbeatStuckCeiling = TimeSpan.FromSeconds(30), + }, + timeProvider: clock); + await CompleteHandshakeAsync(client, pipePair); + + // Begin a command that the test never replies to — keeps the + // PendingCommand alive in `_pendingCommands` for the duration. + Task pendingInvoke = client.InvokeAsync( + CreateCommand(MxCommandKind.Ping), + TestTimeout, + CancellationToken.None); + WorkerEnvelope commandEnvelope = await pipePair.WorkerReader.ReadAsync().AsTask().WaitAsync(TestTimeout); + Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerCommand, commandEnvelope.BodyCase); + + // Advance well past HeartbeatGrace but well within HeartbeatStuckCeiling. + clock.Advance(TimeSpan.FromSeconds(2)); + + // Give the heartbeat monitor a few real check-intervals to observe the gap. + await Task.Delay(TimeSpan.FromMilliseconds(150)); + + Assert.Equal(WorkerClientState.Ready, client.State); + Assert.False(pendingInvoke.IsCompleted); + } + + /// + /// Server-031 regression: once the oldest pending command exceeds + /// , the + /// heartbeat watchdog fires anyway — a truly stuck COM call shouldn't + /// keep the watchdog suppressed indefinitely. + /// + [Fact] + public async Task HeartbeatMonitor_WhenPendingCommandExceedsStuckCeiling_FaultsClient() + { + ManualTimeProvider clock = new(DateTimeOffset.Parse("2026-05-20T13:00:00Z", System.Globalization.CultureInfo.InvariantCulture)); + await using PipePair pipePair = await PipePair.CreateAsync(); + await using WorkerClient client = CreateClient( + pipePair, + new WorkerClientOptions + { + HeartbeatGrace = TimeSpan.FromMilliseconds(80), + HeartbeatCheckInterval = TimeSpan.FromMilliseconds(20), + EventChannelCapacity = 8, + HeartbeatStuckCeiling = TimeSpan.FromMilliseconds(200), + }, + timeProvider: clock); + await CompleteHandshakeAsync(client, pipePair); + + Task pendingInvoke = client.InvokeAsync( + CreateCommand(MxCommandKind.Ping), + TestTimeout, + CancellationToken.None); + await pipePair.WorkerReader.ReadAsync().AsTask().WaitAsync(TestTimeout); + + // Advance the clock past HeartbeatStuckCeiling. The worker pipe's + // PendingCommand.StartTimestamp uses TimeProvider.GetTimestamp(), so the + // ManualTimeProvider's GetElapsedTime sees the advanced gap. + clock.Advance(TimeSpan.FromSeconds(2)); + + await WaitUntilAsync( + () => client.State == WorkerClientState.Faulted, + TestTimeout); + + Assert.Equal(WorkerClientState.Faulted, client.State); + } + + /// + /// Server-032 regression: a transient burst that exceeds + /// must be + /// absorbed for up to + /// (the channel is configured for BoundedChannelFullMode.Wait); + /// only when the wait elapses without progress is the worker faulted, + /// and the diagnostic must name the channel capacity, depth, and + /// actionable remediation. + /// + [Fact] + public async Task EnqueueWorkerEvent_WhenChannelFullPastTimeout_FaultsWithRichDiagnostic() + { + await using PipePair pipePair = await PipePair.CreateAsync(); + await using WorkerClient client = CreateClient( + pipePair, + new WorkerClientOptions + { + EventChannelCapacity = 4, + EventChannelFullModeTimeout = TimeSpan.FromMilliseconds(100), + HeartbeatGrace = TimeSpan.FromSeconds(30), + HeartbeatCheckInterval = TimeSpan.FromSeconds(1), + }); + await CompleteHandshakeAsync(client, pipePair); + + // Fill the 4-slot channel and write exactly one more to force the + // overflow path. The gateway never opens a StreamEvents consumer, so + // the events stay buffered. Exactly five events are written: the + // worker client faults while reading the fifth, after which its read + // loop stops — a sixth event would never be drained and its pipe + // write would block forever on a full OS pipe buffer. + for (ulong sequence = 1; sequence <= 5; sequence++) + { + await pipePair.WorkerWriter.WriteAsync( + CreateEventEnvelope(sequence: sequence, MxEventFamily.OnDataChange)); + } + + await WaitUntilAsync( + () => client.State == WorkerClientState.Faulted, + TestTimeout); + + Assert.Equal(WorkerClientState.Faulted, client.State); + + // Reading the events channel after fault throws the propagated + // WorkerClientException carrying the rich diagnostic message. The + // drain is bounded by TestTimeout so a regression that leaves the + // channel uncompleted fails the test instead of hanging it. + using CancellationTokenSource drainTimeout = new(TestTimeout); + WorkerClientException fault = await Assert.ThrowsAsync(async () => + { + await foreach (WorkerEvent _ in client.ReadEventsAsync(drainTimeout.Token)) + { + } + }); + Assert.Contains("Worker event channel rejected", fault.Message); + Assert.Contains("of 4 capacity", fault.Message); + Assert.Contains("StreamEvents", fault.Message); + Assert.Contains("MxGateway:Events:QueueCapacity", fault.Message); + } + private static WorkerClient CreateClient( PipePair pipePair, WorkerClientOptions? options = null, GatewayMetrics? metrics = null, - WorkerProcessHandle? processHandle = null) + WorkerProcessHandle? processHandle = null, + TimeProvider? timeProvider = null) { WorkerFrameProtocolOptions frameOptions = new(SessionId); WorkerClientConnection connection = new( @@ -298,14 +535,14 @@ public sealed class WorkerClientTests frameOptions, processHandle); - return new WorkerClient(connection, options, metrics); + return new WorkerClient(connection, options, metrics, timeProvider); } private static WorkerProcessHandle CreateProcessHandle(FakeWorkerProcess process) { return new WorkerProcessHandle( process, - new WorkerProcessCommandLine("MxGateway.Worker.exe", []), + new WorkerProcessCommandLine("ZB.MOM.WW.MxGateway.Worker.exe", []), DateTimeOffset.UtcNow); } @@ -399,6 +636,23 @@ public sealed class WorkerClientTests }); } + private static WorkerEnvelope CreateWorkerFaultEnvelope(string diagnosticMessage) + { + return CreateWorkerEnvelope( + correlationId: string.Empty, + sequence: 30, + envelope => envelope.WorkerFault = new WorkerFault + { + Category = WorkerFaultCategory.MxaccessCommandFailed, + DiagnosticMessage = diagnosticMessage, + ProtocolStatus = new ProtocolStatus + { + Code = ProtocolStatusCode.WorkerUnavailable, + Message = diagnosticMessage, + }, + }); + } + private static WorkerEnvelope CreateHeartbeatEnvelope(int workerProcessId) { return CreateWorkerEnvelope( diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/WorkerExecutableValidatorTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/WorkerExecutableValidatorTests.cs new file mode 100644 index 0000000..ace4475 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/WorkerExecutableValidatorTests.cs @@ -0,0 +1,141 @@ +using System.Buffers.Binary; +using ZB.MOM.WW.MxGateway.Server.Configuration; +using ZB.MOM.WW.MxGateway.Server.Workers; + +namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Workers; + +/// +/// Coverage for PE-header architecture parsing +/// (finding Server-013). The validator reads the DOS MZ stub, follows the PE +/// header offset at 0x3c, checks the PE\0\0 signature, and compares the +/// machine field against the required . +/// +public sealed class WorkerExecutableValidatorTests : IDisposable +{ + private const ushort ImageFileMachineI386 = 0x014c; + private const ushort ImageFileMachineAmd64 = 0x8664; + + private readonly List _tempFiles = []; + + [Fact] + public void Validate_X86ExecutableMatchingRequiredArchitecture_DoesNotThrow() + { + string path = WritePeFile(ImageFileMachineI386); + + WorkerExecutableValidator.Validate(path, WorkerArchitecture.X86); + } + + [Fact] + public void Validate_X64ExecutableMatchingRequiredArchitecture_DoesNotThrow() + { + string path = WritePeFile(ImageFileMachineAmd64); + + WorkerExecutableValidator.Validate(path, WorkerArchitecture.X64); + } + + [Fact] + public void Validate_X64ExecutableWhenX86Required_ThrowsInvalidExecutable() + { + string path = WritePeFile(ImageFileMachineAmd64); + + WorkerProcessLaunchException exception = Assert.Throws( + () => WorkerExecutableValidator.Validate(path, WorkerArchitecture.X86)); + + Assert.Equal(WorkerProcessLaunchErrorCode.InvalidExecutable, exception.ErrorCode); + Assert.Contains("architecture", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Validate_X86ExecutableWhenX64Required_ThrowsInvalidExecutable() + { + string path = WritePeFile(ImageFileMachineI386); + + WorkerProcessLaunchException exception = Assert.Throws( + () => WorkerExecutableValidator.Validate(path, WorkerArchitecture.X64)); + + Assert.Equal(WorkerProcessLaunchErrorCode.InvalidExecutable, exception.ErrorCode); + } + + [Fact] + public void Validate_FileWithoutMzHeader_ThrowsInvalidExecutable() + { + byte[] bytes = new byte[0x80]; + // Leave the first two bytes as zero so the MZ signature check fails. + string path = WriteTempFile(bytes); + + WorkerProcessLaunchException exception = Assert.Throws( + () => WorkerExecutableValidator.Validate(path, WorkerArchitecture.X86)); + + Assert.Equal(WorkerProcessLaunchErrorCode.InvalidExecutable, exception.ErrorCode); + Assert.Contains("MZ", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void Validate_FileTooSmallForPeHeader_ThrowsInvalidExecutable() + { + string path = WriteTempFile([(byte)'M', (byte)'Z']); + + WorkerProcessLaunchException exception = Assert.Throws( + () => WorkerExecutableValidator.Validate(path, WorkerArchitecture.X86)); + + Assert.Equal(WorkerProcessLaunchErrorCode.InvalidExecutable, exception.ErrorCode); + } + + [Fact] + public void Validate_FileWithoutPeSignature_ThrowsInvalidExecutable() + { + // Build a valid MZ header pointing at a PE offset that holds a wrong signature. + byte[] bytes = new byte[0x100]; + bytes[0] = (byte)'M'; + bytes[1] = (byte)'Z'; + BinaryPrimitives.WriteInt32LittleEndian(bytes.AsSpan(0x3c, sizeof(int)), 0x80); + // PE region left as zeros — the "PE\0\0" signature check fails. + string path = WriteTempFile(bytes); + + WorkerProcessLaunchException exception = Assert.Throws( + () => WorkerExecutableValidator.Validate(path, WorkerArchitecture.X86)); + + Assert.Equal(WorkerProcessLaunchErrorCode.InvalidExecutable, exception.ErrorCode); + Assert.Contains("PE", exception.Message, StringComparison.Ordinal); + } + + private string WritePeFile(ushort machine) + { + const int peHeaderOffset = 0x80; + byte[] bytes = new byte[peHeaderOffset + 6]; + bytes[0] = (byte)'M'; + bytes[1] = (byte)'Z'; + BinaryPrimitives.WriteInt32LittleEndian(bytes.AsSpan(0x3c, sizeof(int)), peHeaderOffset); + bytes[peHeaderOffset] = (byte)'P'; + bytes[peHeaderOffset + 1] = (byte)'E'; + bytes[peHeaderOffset + 2] = 0; + bytes[peHeaderOffset + 3] = 0; + BinaryPrimitives.WriteUInt16LittleEndian(bytes.AsSpan(peHeaderOffset + 4, sizeof(ushort)), machine); + return WriteTempFile(bytes); + } + + private string WriteTempFile(byte[] bytes) + { + string path = Path.Combine(Path.GetTempPath(), $"mxgw-pe-{Guid.NewGuid():N}.bin"); + File.WriteAllBytes(path, bytes); + _tempFiles.Add(path); + return path; + } + + public void Dispose() + { + foreach (string path in _tempFiles) + { + try + { + File.Delete(path); + } + catch (IOException) + { + // Best-effort cleanup of the temp PE fixtures. + } + } + + _tempFiles.Clear(); + } +} diff --git a/src/MxGateway.Tests/Gateway/Workers/WorkerFrameProtocolTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/WorkerFrameProtocolTests.cs similarity index 98% rename from src/MxGateway.Tests/Gateway/Workers/WorkerFrameProtocolTests.cs rename to src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/WorkerFrameProtocolTests.cs index afe6459..6696ac6 100644 --- a/src/MxGateway.Tests/Gateway/Workers/WorkerFrameProtocolTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/WorkerFrameProtocolTests.cs @@ -1,10 +1,10 @@ using System.Buffers.Binary; using Google.Protobuf; -using MxGateway.Contracts; -using MxGateway.Contracts.Proto; -using MxGateway.Server.Workers; +using ZB.MOM.WW.MxGateway.Contracts; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Server.Workers; -namespace MxGateway.Tests.Gateway.Workers; +namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Workers; public sealed class WorkerFrameProtocolTests { diff --git a/src/MxGateway.Tests/Gateway/Workers/WorkerProcessLauncherTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/WorkerProcessLauncherTests.cs similarity index 98% rename from src/MxGateway.Tests/Gateway/Workers/WorkerProcessLauncherTests.cs rename to src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/WorkerProcessLauncherTests.cs index 31f233b..f8f93a5 100644 --- a/src/MxGateway.Tests/Gateway/Workers/WorkerProcessLauncherTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/WorkerProcessLauncherTests.cs @@ -1,11 +1,11 @@ using System.Diagnostics; using Microsoft.Extensions.Options; -using MxGateway.Contracts; -using MxGateway.Server.Configuration; -using MxGateway.Server.Metrics; -using MxGateway.Server.Workers; +using ZB.MOM.WW.MxGateway.Contracts; +using ZB.MOM.WW.MxGateway.Server.Configuration; +using ZB.MOM.WW.MxGateway.Server.Metrics; +using ZB.MOM.WW.MxGateway.Server.Workers; -namespace MxGateway.Tests.Gateway.Workers; +namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Workers; public sealed class WorkerProcessLauncherTests { @@ -376,7 +376,7 @@ public sealed class WorkerProcessLauncherTests /// Full path to the created executable file. public string CreateWorkerExecutable(ushort machine) { - string path = System.IO.Path.Combine(Path, "MxGateway.Worker.exe"); + string path = System.IO.Path.Combine(Path, "ZB.MOM.WW.MxGateway.Worker.exe"); byte[] bytes = new byte[0x100]; bytes[0] = (byte)'M'; bytes[1] = (byte)'Z'; diff --git a/src/MxGateway.Tests/Metrics/GatewayMetricsTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Metrics/GatewayMetricsTests.cs similarity index 97% rename from src/MxGateway.Tests/Metrics/GatewayMetricsTests.cs rename to src/ZB.MOM.WW.MxGateway.Tests/Metrics/GatewayMetricsTests.cs index d6d473a..ac1f9c2 100644 --- a/src/MxGateway.Tests/Metrics/GatewayMetricsTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Metrics/GatewayMetricsTests.cs @@ -1,6 +1,6 @@ -using MxGateway.Server.Metrics; +using ZB.MOM.WW.MxGateway.Server.Metrics; -namespace MxGateway.Tests.Metrics; +namespace ZB.MOM.WW.MxGateway.Tests.Metrics; public sealed class GatewayMetricsTests { diff --git a/src/MxGateway.Tests/ProjectStructure/GatewayProjectReferenceTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/ProjectStructure/GatewayProjectReferenceTests.cs similarity index 85% rename from src/MxGateway.Tests/ProjectStructure/GatewayProjectReferenceTests.cs rename to src/ZB.MOM.WW.MxGateway.Tests/ProjectStructure/GatewayProjectReferenceTests.cs index 7cd4775..959ffa0 100644 --- a/src/MxGateway.Tests/ProjectStructure/GatewayProjectReferenceTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/ProjectStructure/GatewayProjectReferenceTests.cs @@ -1,6 +1,6 @@ using System.Xml.Linq; -namespace MxGateway.Tests.ProjectStructure; +namespace ZB.MOM.WW.MxGateway.Tests.ProjectStructure; public sealed class GatewayProjectReferenceTests { @@ -8,7 +8,7 @@ public sealed class GatewayProjectReferenceTests [Fact] public void GatewayProject_TargetsNet10() { - XDocument project = LoadProject("MxGateway.Server"); + XDocument project = LoadProject("ZB.MOM.WW.MxGateway.Server"); Assert.Equal("net10.0", ElementValue(project, "TargetFramework")); } @@ -17,7 +17,7 @@ public sealed class GatewayProjectReferenceTests [Fact] public void GatewayProject_DoesNotReferenceMxAccessCom() { - XDocument project = LoadProject("MxGateway.Server"); + XDocument project = LoadProject("ZB.MOM.WW.MxGateway.Server"); IReadOnlyList referenceNames = project .Descendants() @@ -53,7 +53,7 @@ public sealed class GatewayProjectReferenceTests while (current is not null) { - if (File.Exists(Path.Combine(current.FullName, "MxGateway.sln"))) + if (File.Exists(Path.Combine(current.FullName, "ZB.MOM.WW.MxGateway.slnx"))) { return current; } @@ -61,6 +61,6 @@ public sealed class GatewayProjectReferenceTests current = current.Parent; } - throw new DirectoryNotFoundException("Could not locate src/MxGateway.sln from the test output directory."); + throw new DirectoryNotFoundException("Could not locate src/ZB.MOM.WW.MxGateway.slnx from the test output directory."); } } diff --git a/src/MxGateway.Tests/Security/Authentication/ApiKeyAdminCliRunnerTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/ApiKeyAdminCliRunnerTests.cs similarity index 93% rename from src/MxGateway.Tests/Security/Authentication/ApiKeyAdminCliRunnerTests.cs rename to src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/ApiKeyAdminCliRunnerTests.cs index 80399d5..65a1fe9 100644 --- a/src/MxGateway.Tests/Security/Authentication/ApiKeyAdminCliRunnerTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/ApiKeyAdminCliRunnerTests.cs @@ -1,13 +1,14 @@ using System.Text.Json; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using MxGateway.Server.Configuration; -using MxGateway.Server.Security.Authentication; +using ZB.MOM.WW.MxGateway.Server.Configuration; +using ZB.MOM.WW.MxGateway.Server.Security.Authentication; -namespace MxGateway.Tests.Security.Authentication; +namespace ZB.MOM.WW.MxGateway.Tests.Security.Authentication; -public sealed class ApiKeyAdminCliRunnerTests +public sealed class ApiKeyAdminCliRunnerTests : IDisposable { + private readonly List _tempDirectories = []; /// Verifies that CreateKeyAsync creates an authenticating key and audits the action. [Fact] public async Task CreateKeyAsync_CreatesAuthenticatingKeyAndAudits() @@ -249,12 +250,23 @@ public sealed class ApiKeyAdminCliRunnerTests return services.BuildServiceProvider(validateScopes: true); } - private static string CreateTempDatabasePath() + /// Clears SQLite pools and deletes every temporary directory created by this test. + public void Dispose() { - string directory = Path.Combine(Path.GetTempPath(), "mxgateway-auth-cli-tests", Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(directory); + foreach (TempDatabaseDirectory directory in _tempDirectories) + { + directory.Dispose(); + } - return Path.Combine(directory, "gateway-auth.db"); + _tempDirectories.Clear(); + } + + private string CreateTempDatabasePath() + { + TempDatabaseDirectory directory = TempDatabaseDirectory.Create("mxgateway-auth-cli-tests"); + _tempDirectories.Add(directory); + + return directory.DatabasePath(); } private static string ReadApiKey(string json) diff --git a/src/MxGateway.Tests/Security/Authentication/ApiKeyAdminCommandLineParserTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/ApiKeyAdminCommandLineParserTests.cs similarity index 66% rename from src/MxGateway.Tests/Security/Authentication/ApiKeyAdminCommandLineParserTests.cs rename to src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/ApiKeyAdminCommandLineParserTests.cs index de43c31..08f5674 100644 --- a/src/MxGateway.Tests/Security/Authentication/ApiKeyAdminCommandLineParserTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/ApiKeyAdminCommandLineParserTests.cs @@ -1,6 +1,6 @@ -using MxGateway.Server.Security.Authentication; +using ZB.MOM.WW.MxGateway.Server.Security.Authentication; -namespace MxGateway.Tests.Security.Authentication; +namespace ZB.MOM.WW.MxGateway.Tests.Security.Authentication; public sealed class ApiKeyAdminCommandLineParserTests { @@ -52,6 +52,56 @@ public sealed class ApiKeyAdminCommandLineParserTests Assert.Contains("events:read", result.Command.Scopes); } + /// + /// Server-004 regression: a create-key command with a non-canonical scope + /// string (e.g. CLAUDE.md's stale invoke instead of invoke:read) + /// must be rejected at parse time rather than silently persisting an + /// unusable scope the authorization resolver never matches. + /// + [Fact] + public void Parse_CreateKeyCommand_RejectsUnknownScope() + { + ApiKeyAdminParseResult result = ApiKeyAdminCommandLineParser.Parse( + [ + "apikey", + "create-key", + "--key-id", + "operator01", + "--display-name", + "Operator", + "--scopes", + "session:open,invoke,metadata", + ]); + + Assert.True(result.IsApiKeyCommand); + Assert.Null(result.Command); + Assert.NotNull(result.Error); + Assert.Contains("invoke", result.Error, StringComparison.Ordinal); + Assert.Contains("metadata", result.Error, StringComparison.Ordinal); + } + + /// Verifies a create-key command with only canonical scopes parses successfully. + [Fact] + public void Parse_CreateKeyCommand_AcceptsAllCanonicalScopes() + { + ApiKeyAdminParseResult result = ApiKeyAdminCommandLineParser.Parse( + [ + "apikey", + "create-key", + "--key-id", + "operator01", + "--display-name", + "Operator", + "--scopes", + "session:open,session:close,invoke:read,invoke:write,invoke:secure,events:read,metadata:read,admin", + ]); + + Assert.True(result.IsApiKeyCommand); + Assert.Null(result.Error); + Assert.NotNull(result.Command); + Assert.Equal(8, result.Command.Scopes.Count); + } + /// /// Verifies create key without display name returns error. /// diff --git a/src/MxGateway.Tests/Security/Authentication/ApiKeyParserTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/ApiKeyParserTests.cs similarity index 92% rename from src/MxGateway.Tests/Security/Authentication/ApiKeyParserTests.cs rename to src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/ApiKeyParserTests.cs index b4ef124..385de26 100644 --- a/src/MxGateway.Tests/Security/Authentication/ApiKeyParserTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/ApiKeyParserTests.cs @@ -1,6 +1,6 @@ -using MxGateway.Server.Security.Authentication; +using ZB.MOM.WW.MxGateway.Server.Security.Authentication; -namespace MxGateway.Tests.Security.Authentication; +namespace ZB.MOM.WW.MxGateway.Tests.Security.Authentication; public sealed class ApiKeyParserTests { diff --git a/src/MxGateway.Tests/Security/Authentication/ApiKeySecretHasherTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/ApiKeySecretHasherTests.cs similarity index 92% rename from src/MxGateway.Tests/Security/Authentication/ApiKeySecretHasherTests.cs rename to src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/ApiKeySecretHasherTests.cs index 0577673..0a4136a 100644 --- a/src/MxGateway.Tests/Security/Authentication/ApiKeySecretHasherTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/ApiKeySecretHasherTests.cs @@ -1,9 +1,9 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Options; -using MxGateway.Server.Configuration; -using MxGateway.Server.Security.Authentication; +using ZB.MOM.WW.MxGateway.Server.Configuration; +using ZB.MOM.WW.MxGateway.Server.Security.Authentication; -namespace MxGateway.Tests.Security.Authentication; +namespace ZB.MOM.WW.MxGateway.Tests.Security.Authentication; public sealed class ApiKeySecretHasherTests { diff --git a/src/MxGateway.Tests/Security/Authentication/ApiKeyVerifierTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/ApiKeyVerifierTests.cs similarity index 98% rename from src/MxGateway.Tests/Security/Authentication/ApiKeyVerifierTests.cs rename to src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/ApiKeyVerifierTests.cs index 2d96b2b..6def040 100644 --- a/src/MxGateway.Tests/Security/Authentication/ApiKeyVerifierTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/ApiKeyVerifierTests.cs @@ -1,10 +1,10 @@ using System.Text.Json; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Options; -using MxGateway.Server.Configuration; -using MxGateway.Server.Security.Authentication; +using ZB.MOM.WW.MxGateway.Server.Configuration; +using ZB.MOM.WW.MxGateway.Server.Security.Authentication; -namespace MxGateway.Tests.Security.Authentication; +namespace ZB.MOM.WW.MxGateway.Tests.Security.Authentication; public sealed class ApiKeyVerifierTests { diff --git a/src/MxGateway.Tests/Security/Authentication/SqliteAuthStoreTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/SqliteAuthStoreTests.cs similarity index 83% rename from src/MxGateway.Tests/Security/Authentication/SqliteAuthStoreTests.cs rename to src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/SqliteAuthStoreTests.cs index 7602cc3..fd8076d 100644 --- a/src/MxGateway.Tests/Security/Authentication/SqliteAuthStoreTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/SqliteAuthStoreTests.cs @@ -2,17 +2,18 @@ using Microsoft.AspNetCore.Builder; using Microsoft.Data.Sqlite; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using MxGateway.Server; -using MxGateway.Server.Configuration; -using MxGateway.Server.Security.Authentication; +using ZB.MOM.WW.MxGateway.Server; +using ZB.MOM.WW.MxGateway.Server.Configuration; +using ZB.MOM.WW.MxGateway.Server.Security.Authentication; -namespace MxGateway.Tests.Security.Authentication; +namespace ZB.MOM.WW.MxGateway.Tests.Security.Authentication; /// /// Tests for . /// -public sealed class SqliteAuthStoreTests +public sealed class SqliteAuthStoreTests : IDisposable { + private readonly List _tempDirectories = []; /// /// Verifies that MigrateAsync initializes the database schema. /// @@ -149,6 +150,32 @@ public sealed class SqliteAuthStoreTests Assert.Equal("matched active key", record.Details); } + /// + /// Verifies that opens + /// the auth database in WAL journal mode so concurrent readers and writers degrade + /// gracefully instead of surfacing SQLITE_BUSY on the request path. + /// + [Fact] + public async Task OpenConnectionAsync_EnablesWalJournalModeAndBusyTimeout() + { + string databasePath = CreateTempDatabasePath(); + await using ServiceProvider services = BuildAuthServices(databasePath); + AuthSqliteConnectionFactory factory = services.GetRequiredService(); + + await using SqliteConnection connection = await factory.OpenConnectionAsync(CancellationToken.None); + + await using SqliteCommand journalModeCommand = connection.CreateCommand(); + journalModeCommand.CommandText = "PRAGMA journal_mode;"; + string? journalMode = (string?)await journalModeCommand.ExecuteScalarAsync(CancellationToken.None); + + await using SqliteCommand busyTimeoutCommand = connection.CreateCommand(); + busyTimeoutCommand.CommandText = "PRAGMA busy_timeout;"; + long busyTimeout = (long)(await busyTimeoutCommand.ExecuteScalarAsync(CancellationToken.None) ?? 0L); + + Assert.Equal("wal", journalMode, ignoreCase: true); + Assert.True(busyTimeout > 0, $"Expected a non-zero busy_timeout but found {busyTimeout}."); + } + private static ServiceProvider BuildAuthServices(string databasePath) { IConfigurationRoot configuration = new ConfigurationBuilder() @@ -167,12 +194,23 @@ public sealed class SqliteAuthStoreTests return services.BuildServiceProvider(validateScopes: true); } - private static string CreateTempDatabasePath() + /// Clears SQLite pools and deletes every temporary directory created by this test. + public void Dispose() { - string directory = Path.Combine(Path.GetTempPath(), "mxgateway-auth-tests", Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(directory); + foreach (TempDatabaseDirectory directory in _tempDirectories) + { + directory.Dispose(); + } - return Path.Combine(directory, "gateway-auth.db"); + _tempDirectories.Clear(); + } + + private string CreateTempDatabasePath() + { + TempDatabaseDirectory directory = TempDatabaseDirectory.Create("mxgateway-auth-tests"); + _tempDirectories.Add(directory); + + return directory.DatabasePath(); } private static async Task CreateVersionZeroDatabaseAsync(string databasePath) diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/TempDatabaseDirectory.cs b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/TempDatabaseDirectory.cs new file mode 100644 index 0000000..ff97cab --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/TempDatabaseDirectory.cs @@ -0,0 +1,73 @@ +using Microsoft.Data.Sqlite; + +namespace ZB.MOM.WW.MxGateway.Tests.Security.Authentication; + +/// +/// Disposable temporary directory for SQLite auth-store tests. Each instance owns a +/// unique directory under %TEMP%; clears SQLite connection +/// pools (which otherwise keep the .db file handle open) and deletes the directory +/// so test runs do not leak temp files or open handles. +/// +internal sealed class TempDatabaseDirectory : IDisposable +{ + private bool _disposed; + + private TempDatabaseDirectory(string path) + { + Path = path; + } + + /// Gets the path to the temporary directory. + public string Path { get; } + + /// Creates a new uniquely named temporary directory under the given prefix. + /// Folder name placed under %TEMP% to group related test directories. + public static TempDatabaseDirectory Create(string prefix) + { + string path = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + prefix, + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(path); + + return new TempDatabaseDirectory(path); + } + + /// Returns a database file path inside this temporary directory. + /// Database file name; defaults to the gateway auth database name. + public string DatabasePath(string fileName = "gateway-auth.db") + { + return System.IO.Path.Combine(Path, fileName); + } + + /// + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + + // Microsoft.Data.Sqlite pools connections by default; clear the pools so the + // underlying file handle is released before the directory is deleted. + SqliteConnection.ClearAllPools(); + + try + { + if (Directory.Exists(Path)) + { + Directory.Delete(Path, recursive: true); + } + } + catch (IOException) + { + // Best-effort cleanup; a transient handle should not fail the test. + } + catch (UnauthorizedAccessException) + { + // Best-effort cleanup; a transient handle should not fail the test. + } + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/ConstraintEnforcerTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/ConstraintEnforcerTests.cs new file mode 100644 index 0000000..0560b23 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/ConstraintEnforcerTests.cs @@ -0,0 +1,247 @@ +using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Server.Dashboard; +using ZB.MOM.WW.MxGateway.Server.Galaxy; +using ZB.MOM.WW.MxGateway.Server.Security.Authentication; +using ZB.MOM.WW.MxGateway.Server.Security.Authorization; +using ZB.MOM.WW.MxGateway.Server.Sessions; + +namespace ZB.MOM.WW.MxGateway.Tests.Security.Authorization; + +public sealed class ConstraintEnforcerTests +{ + [Fact] + public async Task CheckReadTagAsync_WhenOutsideReadSubtree_ReturnsFailure() + { + ConstraintEnforcer enforcer = CreateEnforcer(out _); + ApiKeyIdentity identity = CreateIdentity(ApiKeyConstraints.Empty with + { + ReadSubtrees = ["Area1/*"], + }); + + ConstraintFailure? failure = await enforcer.CheckReadTagAsync( + identity, + "Other_001.PV", + CancellationToken.None); + + Assert.NotNull(failure); + Assert.Equal("read_scope", failure.ConstraintName); + } + + [Fact] + public async Task CheckWriteHandleAsync_WhenClassificationTooHigh_ReturnsFailureAndAudits() + { + ConstraintEnforcer enforcer = CreateEnforcer(out FakeAuditStore auditStore); + ApiKeyIdentity identity = CreateIdentity(ApiKeyConstraints.Empty with + { + WriteSubtrees = ["Area1/*"], + MaxWriteClassification = 1, + }); + GatewaySession session = CreateSession(); + session.TrackCommandReply( + new MxCommand + { + Kind = MxCommandKind.AddItem, + AddItem = new AddItemCommand + { + ServerHandle = 12, + ItemDefinition = "Pump_001.PV", + }, + }, + new MxCommandReply + { + ProtocolStatus = ZB.MOM.WW.MxGateway.Server.Grpc.MxAccessGrpcMapper.Ok(), + AddItem = new AddItemReply { ItemHandle = 42 }, + }); + + ConstraintFailure? failure = await enforcer.CheckWriteHandleAsync( + identity, + session, + serverHandle: 12, + itemHandle: 42, + CancellationToken.None); + Assert.NotNull(failure); + + await enforcer.RecordDenialAsync(identity, "Write", "42", failure, CancellationToken.None); + + ApiKeyAuditEntry entry = Assert.Single(auditStore.Entries); + Assert.Equal("operator01", entry.KeyId); + Assert.Equal("constraint-denied", entry.EventType); + Assert.Contains("max_write_classification", entry.Details, StringComparison.Ordinal); + } + + [Fact] + public async Task CheckReadTagAsync_WithHistorizedOnly_RequiresRequestedAttributeToBeHistorized() + { + ConstraintEnforcer enforcer = CreateEnforcer(out _); + ApiKeyIdentity identity = CreateIdentity(ApiKeyConstraints.Empty with + { + ReadHistorizedOnly = true, + }); + + ConstraintFailure? failure = await enforcer.CheckReadTagAsync( + identity, + "Pump_001.NonHistorized", + CancellationToken.None); + + Assert.NotNull(failure); + Assert.Equal("read_historized_only", failure.ConstraintName); + } + + [Fact] + public async Task CheckReadTagAsync_WithAlarmOnly_RequiresRequestedAttributeToBeAlarm() + { + ConstraintEnforcer enforcer = CreateEnforcer(out _); + ApiKeyIdentity identity = CreateIdentity(ApiKeyConstraints.Empty with + { + ReadAlarmOnly = true, + }); + + ConstraintFailure? failure = await enforcer.CheckReadTagAsync( + identity, + "Pump_001.PV", + CancellationToken.None); + + Assert.NotNull(failure); + Assert.Equal("read_alarm_only", failure.ConstraintName); + } + + [Fact] + public async Task CheckReadTagAsync_WithAttributeOnlyConstraint_FailsClosedForObjectTag() + { + ConstraintEnforcer enforcer = CreateEnforcer(out _); + ApiKeyIdentity identity = CreateIdentity(ApiKeyConstraints.Empty with + { + ReadHistorizedOnly = true, + }); + + ConstraintFailure? failure = await enforcer.CheckReadTagAsync( + identity, + "Pump_001", + CancellationToken.None); + + Assert.NotNull(failure); + Assert.Equal("read_historized_only", failure.ConstraintName); + } + + private static ConstraintEnforcer CreateEnforcer(out FakeAuditStore auditStore) + { + auditStore = new FakeAuditStore(); + return new ConstraintEnforcer(new StubGalaxyHierarchyCache(CreateEntry()), auditStore); + } + + private static ApiKeyIdentity CreateIdentity(ApiKeyConstraints constraints) + { + return new ApiKeyIdentity( + KeyId: "operator01", + KeyPrefix: "mxgw_operator01", + DisplayName: "Operator", + Scopes: new HashSet(StringComparer.Ordinal), + Constraints: constraints); + } + + private static GatewaySession CreateSession() + { + GatewaySession session = new( + "session-1", + "mxaccess", + "pipe", + "nonce", + "operator", + "client", + "correlation", + TimeSpan.FromSeconds(30), + TimeSpan.FromSeconds(5), + TimeSpan.FromSeconds(5), + DateTimeOffset.UtcNow); + return session; + } + + private static GalaxyHierarchyCacheEntry CreateEntry() + { + IReadOnlyList objects = + [ + new GalaxyObject + { + GobjectId = 1, + TagName = "Area1", + ContainedName = "Area1", + }, + new GalaxyObject + { + GobjectId = 2, + TagName = "Pump_001", + ContainedName = "Pump", + ParentGobjectId = 1, + Attributes = + { + new GalaxyAttribute + { + AttributeName = "PV", + FullTagReference = "Pump_001.PV", + SecurityClassification = 2, + IsHistorized = true, + }, + new GalaxyAttribute + { + AttributeName = "Alarm", + FullTagReference = "Pump_001.Alarm", + IsAlarm = true, + }, + new GalaxyAttribute + { + AttributeName = "NonHistorized", + FullTagReference = "Pump_001.NonHistorized", + }, + }, + }, + new GalaxyObject + { + GobjectId = 3, + TagName = "Other_001", + ContainedName = "Other", + Attributes = + { + new GalaxyAttribute + { + AttributeName = "PV", + FullTagReference = "Other_001.PV", + }, + }, + }, + ]; + + return GalaxyHierarchyCacheEntry.Empty with + { + Status = GalaxyCacheStatus.Healthy, + Objects = objects, + Index = GalaxyHierarchyIndex.Build(objects), + DashboardSummary = DashboardGalaxySummary.Unknown, + }; + } + + private sealed class StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry current) : IGalaxyHierarchyCache + { + public GalaxyHierarchyCacheEntry Current { get; } = current; + + public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask; + } + + private sealed class FakeAuditStore : IApiKeyAuditStore + { + public List Entries { get; } = []; + + public Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken cancellationToken) + { + Entries.Add(entry); + return Task.CompletedTask; + } + + public Task> ListRecentAsync(int count, CancellationToken cancellationToken) + { + return Task.FromResult>([]); + } + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/GatewayGrpcAuthorizationInterceptorTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/GatewayGrpcAuthorizationInterceptorTests.cs new file mode 100644 index 0000000..e7cc3c5 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/GatewayGrpcAuthorizationInterceptorTests.cs @@ -0,0 +1,512 @@ +using System.Runtime.CompilerServices; +using Grpc.Core; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using ZB.MOM.WW.MxGateway.Contracts; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Server.Configuration; +using ZB.MOM.WW.MxGateway.Server.Grpc; +using ZB.MOM.WW.MxGateway.Server.Metrics; +using ZB.MOM.WW.MxGateway.Server.Security.Authentication; +using ZB.MOM.WW.MxGateway.Server.Security.Authorization; +using ZB.MOM.WW.MxGateway.Server.Sessions; +using ZB.MOM.WW.MxGateway.Tests.TestSupport; + +namespace ZB.MOM.WW.MxGateway.Tests.Security.Authorization; + +public sealed class GatewayGrpcAuthorizationInterceptorTests +{ + /// Verifies that missing API key returns unauthenticated status. + [Fact] + public async Task UnaryServerHandler_MissingApiKey_ReturnsUnauthenticated() + { + GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor( + new FakeApiKeyVerifier(ApiKeyVerificationResult.Fail( + ApiKeyVerificationFailure.MissingOrMalformedCredentials)), + new GatewayRequestIdentityAccessor()); + + RpcException exception = await Assert.ThrowsAsync( + () => interceptor.UnaryServerHandler( + new OpenSessionRequest(), + new TestServerCallContext([]), + (_, _) => Task.FromResult(new OpenSessionReply()))); + + Assert.Equal(StatusCode.Unauthenticated, exception.StatusCode); + Assert.DoesNotContain("secret", exception.Status.Detail, StringComparison.OrdinalIgnoreCase); + } + + /// Verifies that invalid API key error does not expose raw credentials. + [Fact] + public async Task UnaryServerHandler_InvalidApiKey_DoesNotExposeRawCredentialInStatus() + { + GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor( + new FakeApiKeyVerifier(ApiKeyVerificationResult.Fail(ApiKeyVerificationFailure.SecretMismatch)), + new GatewayRequestIdentityAccessor()); + + RpcException exception = await Assert.ThrowsAsync( + () => interceptor.UnaryServerHandler( + new OpenSessionRequest(), + ContextWithAuthorization("Bearer mxgw_operator01_super-secret"), + (_, _) => Task.FromResult(new OpenSessionReply()))); + + Assert.Equal(StatusCode.Unauthenticated, exception.StatusCode); + Assert.DoesNotContain("super-secret", exception.Status.Detail, StringComparison.Ordinal); + } + + /// Verifies that valid key without required scope returns permission denied. + [Fact] + public async Task UnaryServerHandler_ValidApiKeyMissingScope_ReturnsPermissionDenied() + { + GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor( + new FakeApiKeyVerifier(SuccessWithScopes(GatewayScopes.EventsRead)), + new GatewayRequestIdentityAccessor()); + + RpcException exception = await Assert.ThrowsAsync( + () => interceptor.UnaryServerHandler( + new OpenSessionRequest(), + ContextWithAuthorization("Bearer mxgw_operator01_secret"), + (_, _) => Task.FromResult(new OpenSessionReply()))); + + Assert.Equal(StatusCode.PermissionDenied, exception.StatusCode); + Assert.Contains(GatewayScopes.SessionOpen, exception.Status.Detail, StringComparison.Ordinal); + } + + /// Verifies that valid key with scope sets request identity for the handler. + [Fact] + public async Task UnaryServerHandler_ValidApiKeyWithScope_SetsRequestIdentity() + { + GatewayRequestIdentityAccessor identityAccessor = new(); + ApiKeyIdentity? identitySeenByHandler = null; + GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor( + new FakeApiKeyVerifier(SuccessWithScopes(GatewayScopes.SessionOpen)), + identityAccessor); + + OpenSessionReply reply = await interceptor.UnaryServerHandler( + new OpenSessionRequest(), + ContextWithAuthorization("Bearer mxgw_operator01_secret"), + (_, _) => + { + identitySeenByHandler = identityAccessor.Current; + + return Task.FromResult(new OpenSessionReply { SessionId = "session-1" }); + }); + + Assert.Equal("session-1", reply.SessionId); + Assert.NotNull(identitySeenByHandler); + Assert.Equal("operator01", identitySeenByHandler.KeyId); + Assert.Null(identityAccessor.Current); + } + + /// Verifies that server stream handler requires proper scope. + [Fact] + public async Task ServerStreamingServerHandler_ValidApiKeyMissingScope_ReturnsPermissionDenied() + { + GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor( + new FakeApiKeyVerifier(SuccessWithScopes(GatewayScopes.SessionOpen)), + new GatewayRequestIdentityAccessor()); + + RpcException exception = await Assert.ThrowsAsync( + () => interceptor.ServerStreamingServerHandler( + new StreamEventsRequest(), + new RecordingServerStreamWriter(), + ContextWithAuthorization("Bearer mxgw_operator01_secret"), + (_, _, _) => Task.CompletedTask)); + + Assert.Equal(StatusCode.PermissionDenied, exception.StatusCode); + Assert.Contains(GatewayScopes.EventsRead, exception.Status.Detail, StringComparison.Ordinal); + } + + /// Verifies that server stream handler allows streams with proper scope. + [Fact] + public async Task ServerStreamingServerHandler_ValidApiKeyWithScope_AllowsStream() + { + GatewayRequestIdentityAccessor identityAccessor = new(); + GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor( + new FakeApiKeyVerifier(SuccessWithScopes(GatewayScopes.EventsRead)), + identityAccessor); + RecordingServerStreamWriter streamWriter = new(); + + await interceptor.ServerStreamingServerHandler( + new StreamEventsRequest(), + streamWriter, + ContextWithAuthorization("Bearer mxgw_operator01_secret"), + async (_, writer, _) => + { + Assert.Equal("operator01", identityAccessor.Current?.KeyId); + await writer.WriteAsync(new MxEvent { SessionId = "session-1" }); + }); + + MxEvent eventMessage = Assert.Single(streamWriter.Messages); + Assert.Equal("session-1", eventMessage.SessionId); + Assert.Null(identityAccessor.Current); + } + + /// Verifies that disabled authentication skips API key verification. + [Fact] + public async Task UnaryServerHandler_AuthenticationDisabled_SkipsApiKeyVerification() + { + GatewayRequestIdentityAccessor identityAccessor = new(); + FakeApiKeyVerifier verifier = new(ApiKeyVerificationResult.Fail( + ApiKeyVerificationFailure.MissingOrMalformedCredentials)); + GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor( + verifier, + identityAccessor, + AuthenticationMode.Disabled); + + OpenSessionReply reply = await interceptor.UnaryServerHandler( + new OpenSessionRequest(), + new TestServerCallContext([]), + (_, _) => Task.FromResult(new OpenSessionReply { SessionId = "session-1" })); + + Assert.Equal("session-1", reply.SessionId); + Assert.False(verifier.WasCalled); + Assert.Null(identityAccessor.Current); + } + + /// + /// End-to-end composition test: runs an OpenSession call through the real + /// interceptor in front of the real with a key + /// that lacks the session:open scope, and asserts the interceptor denies the + /// call with before the service runs. + /// + [Fact] + public async Task InterceptorComposedWithService_OpenSessionMissingScope_DeniesBeforeServiceRuns() + { + GatewayRequestIdentityAccessor identityAccessor = new(); + RecordingSessionManager sessionManager = new(); + GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor( + new FakeApiKeyVerifier(SuccessWithScopes(GatewayScopes.EventsRead)), + identityAccessor); + MxAccessGatewayService service = CreateService(sessionManager, identityAccessor); + + RpcException exception = await Assert.ThrowsAsync( + () => interceptor.UnaryServerHandler( + new OpenSessionRequest { ClientSessionName = "operator-session" }, + ContextWithAuthorization("Bearer mxgw_operator01_secret"), + (request, context) => service.OpenSession(request, context))); + + Assert.Equal(StatusCode.PermissionDenied, exception.StatusCode); + Assert.Contains(GatewayScopes.SessionOpen, exception.Status.Detail, StringComparison.Ordinal); + Assert.Equal(0, sessionManager.OpenSessionCount); + } + + /// + /// End-to-end composition test: runs an OpenSession call through the real + /// interceptor in front of the real with a key + /// that holds session:open, and asserts the service runs and observes the + /// interceptor-supplied identity. + /// + [Fact] + public async Task InterceptorComposedWithService_OpenSessionWithScope_RunsServiceWithIdentity() + { + GatewayRequestIdentityAccessor identityAccessor = new(); + RecordingSessionManager sessionManager = new(); + GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor( + new FakeApiKeyVerifier(SuccessWithScopes(GatewayScopes.SessionOpen)), + identityAccessor); + MxAccessGatewayService service = CreateService(sessionManager, identityAccessor); + + OpenSessionReply reply = await interceptor.UnaryServerHandler( + new OpenSessionRequest { ClientSessionName = "operator-session" }, + ContextWithAuthorization("Bearer mxgw_operator01_secret"), + (request, context) => service.OpenSession(request, context)); + + Assert.Equal("session-1", reply.SessionId); + Assert.Equal(1, sessionManager.OpenSessionCount); + Assert.Equal("Operator Key", sessionManager.LastClientIdentity); + } + + /// + /// End-to-end composition test: an Invoke call through the real interceptor in + /// front of the real service with a key holding only invoke:read is denied + /// because the wrapped command is a write, confirming command-scope mapping is + /// enforced through the full composition. + /// + [Fact] + public async Task InterceptorComposedWithService_InvokeWriteCommandWithReadScope_DeniesBeforeServiceRuns() + { + GatewayRequestIdentityAccessor identityAccessor = new(); + RecordingSessionManager sessionManager = new(); + GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor( + new FakeApiKeyVerifier(SuccessWithScopes(GatewayScopes.InvokeRead)), + identityAccessor); + MxAccessGatewayService service = CreateService(sessionManager, identityAccessor); + MxCommandRequest request = new() + { + SessionId = "session-1", + Command = new MxCommand + { + Kind = MxCommandKind.Write, + Write = new WriteCommand { ServerHandle = 1, ItemHandle = 2 }, + }, + }; + + RpcException exception = await Assert.ThrowsAsync( + () => interceptor.UnaryServerHandler( + request, + ContextWithAuthorization("Bearer mxgw_operator01_secret"), + (req, context) => service.Invoke(req, context))); + + Assert.Equal(StatusCode.PermissionDenied, exception.StatusCode); + Assert.Contains(GatewayScopes.InvokeWrite, exception.Status.Detail, StringComparison.Ordinal); + Assert.Equal(0, sessionManager.InvokeCount); + } + + /// + /// Verifies the interceptor denies AcknowledgeAlarm calls that lack + /// . Ack is a write-shaped mutation against + /// alarm state, so it carries the same scope as MxCommandKind.Write. + /// + [Fact] + public async Task UnaryServerHandler_AcknowledgeAlarmMissingScope_ReturnsPermissionDenied() + { + GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor( + new FakeApiKeyVerifier(SuccessWithScopes(GatewayScopes.InvokeRead)), + new GatewayRequestIdentityAccessor()); + + RpcException exception = await Assert.ThrowsAsync( + () => interceptor.UnaryServerHandler( + new AcknowledgeAlarmRequest { AlarmFullReference = "ref" }, + ContextWithAuthorization("Bearer mxgw_operator01_secret"), + (_, _) => Task.FromResult(new AcknowledgeAlarmReply()))); + + Assert.Equal(StatusCode.PermissionDenied, exception.StatusCode); + Assert.Contains(GatewayScopes.InvokeWrite, exception.Status.Detail, StringComparison.Ordinal); + } + + /// Verifies that an API key holding invoke:write may call AcknowledgeAlarm. + [Fact] + public async Task UnaryServerHandler_AcknowledgeAlarmWithScope_RunsHandler() + { + GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor( + new FakeApiKeyVerifier(SuccessWithScopes(GatewayScopes.InvokeWrite)), + new GatewayRequestIdentityAccessor()); + bool handlerRan = false; + + AcknowledgeAlarmReply reply = await interceptor.UnaryServerHandler( + new AcknowledgeAlarmRequest { AlarmFullReference = "ref" }, + ContextWithAuthorization("Bearer mxgw_operator01_secret"), + (_, _) => + { + handlerRan = true; + return Task.FromResult(new AcknowledgeAlarmReply()); + }); + + Assert.NotNull(reply); + Assert.True(handlerRan); + } + + /// + /// Verifies the interceptor denies QueryActiveAlarms server-streaming calls that + /// lack . Active-alarm snapshots are part of the + /// alarm/event surface and share the same scope as StreamEvents. + /// + [Fact] + public async Task ServerStreamingServerHandler_QueryActiveAlarmsMissingScope_ReturnsPermissionDenied() + { + GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor( + new FakeApiKeyVerifier(SuccessWithScopes(GatewayScopes.InvokeRead)), + new GatewayRequestIdentityAccessor()); + + RpcException exception = await Assert.ThrowsAsync( + () => interceptor.ServerStreamingServerHandler( + new StreamAlarmsRequest(), + new RecordingServerStreamWriter(), + ContextWithAuthorization("Bearer mxgw_operator01_secret"), + (_, _, _) => Task.CompletedTask)); + + Assert.Equal(StatusCode.PermissionDenied, exception.StatusCode); + Assert.Contains(GatewayScopes.EventsRead, exception.Status.Detail, StringComparison.Ordinal); + } + + /// Verifies that an API key holding events:read may call QueryActiveAlarms. + [Fact] + public async Task ServerStreamingServerHandler_QueryActiveAlarmsWithScope_RunsHandler() + { + GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor( + new FakeApiKeyVerifier(SuccessWithScopes(GatewayScopes.EventsRead)), + new GatewayRequestIdentityAccessor()); + RecordingServerStreamWriter streamWriter = new(); + + await interceptor.ServerStreamingServerHandler( + new StreamAlarmsRequest(), + streamWriter, + ContextWithAuthorization("Bearer mxgw_operator01_secret"), + async (_, writer, _) => + { + await writer.WriteAsync(new ActiveAlarmSnapshot()); + }); + + Assert.Single(streamWriter.Messages); + } + + private static MxAccessGatewayService CreateService( + ISessionManager sessionManager, + IGatewayRequestIdentityAccessor identityAccessor) + { + return new MxAccessGatewayService( + sessionManager, + identityAccessor, + new AllowAllConstraintEnforcer(), + new MxAccessGrpcRequestValidator(), + new MxAccessGrpcMapper(), + new NoOpEventStreamService(), + new GatewayMetrics(), + NullLogger.Instance, + new FakeGatewayAlarmService()); + } + + private static GatewayGrpcAuthorizationInterceptor CreateInterceptor( + IApiKeyVerifier apiKeyVerifier, + IGatewayRequestIdentityAccessor identityAccessor, + AuthenticationMode authenticationMode = AuthenticationMode.ApiKey) + { + return new GatewayGrpcAuthorizationInterceptor( + apiKeyVerifier, + new GatewayGrpcScopeResolver(), + identityAccessor, + Options.Create(new GatewayOptions + { + Authentication = new AuthenticationOptions + { + Mode = authenticationMode + } + })); + } + + private static ApiKeyVerificationResult SuccessWithScopes(params string[] scopes) + { + return ApiKeyVerificationResult.Success(new ApiKeyIdentity( + KeyId: "operator01", + KeyPrefix: "mxgw_operator01", + DisplayName: "Operator Key", + Scopes: new HashSet(scopes, StringComparer.Ordinal))); + } + + private static TestServerCallContext ContextWithAuthorization(string authorizationHeader) + { + return new TestServerCallContext([new Metadata.Entry("authorization", authorizationHeader)]); + } + + /// Records whether the gateway service ran past the interceptor for composition tests. + private sealed class RecordingSessionManager : ISessionManager + { + /// Gets the number of times OpenSessionAsync was invoked. + public int OpenSessionCount { get; private set; } + + /// Gets the number of times InvokeAsync was invoked. + public int InvokeCount { get; private set; } + + /// Gets the last client identity passed to OpenSessionAsync. + public string? LastClientIdentity { get; private set; } + + /// + public Task OpenSessionAsync( + SessionOpenRequest request, + string? clientIdentity, + CancellationToken cancellationToken) + { + OpenSessionCount++; + LastClientIdentity = clientIdentity; + + GatewaySession session = new( + "session-1", + GatewayContractInfo.DefaultBackendName, + "pipe", + "nonce", + clientIdentity ?? "client", + "client-session", + "client-correlation", + TimeSpan.FromSeconds(7), + TimeSpan.FromSeconds(30), + TimeSpan.FromSeconds(10), + DateTimeOffset.UtcNow); + + return Task.FromResult(session); + } + + /// + public bool TryGetSession(string sessionId, out GatewaySession session) + { + session = null!; + return false; + } + + /// + public Task InvokeAsync( + string sessionId, + WorkerCommand command, + CancellationToken cancellationToken) + { + InvokeCount++; + return Task.FromResult(new WorkerCommandReply()); + } + + /// + public IAsyncEnumerable ReadEventsAsync( + string sessionId, + CancellationToken cancellationToken) + { + return AsyncEnumerable.Empty(); + } + + /// + public Task CloseSessionAsync( + string sessionId, + CancellationToken cancellationToken) + { + return Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false)); + } + + /// + public Task CloseExpiredLeasesAsync( + DateTimeOffset now, + CancellationToken cancellationToken) + { + return Task.FromResult(0); + } + + /// + public Task ShutdownAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + } + + /// Event stream service that yields nothing; alarm/event RPCs are not under test here. + private sealed class NoOpEventStreamService : IEventStreamService + { + /// + public async IAsyncEnumerable StreamEventsAsync( + StreamEventsRequest request, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + await Task.CompletedTask; + yield break; + } + } + + private sealed class FakeApiKeyVerifier(ApiKeyVerificationResult result) : IApiKeyVerifier + { + /// Gets whether the verifier was called. + public bool WasCalled { get; private set; } + + /// Gets the last authorization header seen by the verifier. + public string? LastAuthorizationHeader { get; private set; } + + /// Verifies the authorization header against stored result. + /// The authorization header to verify. + /// Cancellation token. + /// Configured verification result. + public Task VerifyAsync( + string? authorizationHeader, + CancellationToken cancellationToken) + { + WasCalled = true; + LastAuthorizationHeader = authorizationHeader; + + return Task.FromResult(result); + } + } + +} diff --git a/src/MxGateway.Tests/Security/Authorization/GatewayGrpcScopeResolverTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/GatewayGrpcScopeResolverTests.cs similarity index 56% rename from src/MxGateway.Tests/Security/Authorization/GatewayGrpcScopeResolverTests.cs rename to src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/GatewayGrpcScopeResolverTests.cs index 2df832c..7f55c77 100644 --- a/src/MxGateway.Tests/Security/Authorization/GatewayGrpcScopeResolverTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/GatewayGrpcScopeResolverTests.cs @@ -1,8 +1,8 @@ -using MxGateway.Contracts.Proto; -using MxGateway.Contracts.Proto.Galaxy; -using MxGateway.Server.Security.Authorization; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; +using ZB.MOM.WW.MxGateway.Server.Security.Authorization; -namespace MxGateway.Tests.Security.Authorization; +namespace ZB.MOM.WW.MxGateway.Tests.Security.Authorization; public sealed class GatewayGrpcScopeResolverTests { @@ -13,9 +13,12 @@ public sealed class GatewayGrpcScopeResolverTests [InlineData(typeof(OpenSessionRequest), GatewayScopes.SessionOpen)] [InlineData(typeof(CloseSessionRequest), GatewayScopes.SessionClose)] [InlineData(typeof(StreamEventsRequest), GatewayScopes.EventsRead)] + [InlineData(typeof(AcknowledgeAlarmRequest), GatewayScopes.InvokeWrite)] + [InlineData(typeof(StreamAlarmsRequest), GatewayScopes.EventsRead)] [InlineData(typeof(TestConnectionRequest), GatewayScopes.MetadataRead)] [InlineData(typeof(GetLastDeployTimeRequest), GatewayScopes.MetadataRead)] [InlineData(typeof(DiscoverHierarchyRequest), GatewayScopes.MetadataRead)] + [InlineData(typeof(WatchDeployEventsRequest), GatewayScopes.MetadataRead)] public void ResolveRequiredScope_KnownRpcRequest_ReturnsExpectedScope( Type requestType, string expectedScope) @@ -39,6 +42,11 @@ public sealed class GatewayGrpcScopeResolverTests [InlineData(MxCommandKind.Write2, GatewayScopes.InvokeWrite)] [InlineData(MxCommandKind.WriteSecured, GatewayScopes.InvokeSecure)] [InlineData(MxCommandKind.WriteSecured2, GatewayScopes.InvokeSecure)] + [InlineData(MxCommandKind.WriteBulk, GatewayScopes.InvokeWrite)] + [InlineData(MxCommandKind.Write2Bulk, GatewayScopes.InvokeWrite)] + [InlineData(MxCommandKind.WriteSecuredBulk, GatewayScopes.InvokeSecure)] + [InlineData(MxCommandKind.WriteSecured2Bulk, GatewayScopes.InvokeSecure)] + [InlineData(MxCommandKind.ReadBulk, GatewayScopes.InvokeRead)] [InlineData(MxCommandKind.AuthenticateUser, GatewayScopes.InvokeSecure)] [InlineData(MxCommandKind.ArchestraUserToId, GatewayScopes.MetadataRead)] [InlineData(MxCommandKind.GetSessionState, GatewayScopes.MetadataRead)] @@ -61,4 +69,42 @@ public sealed class GatewayGrpcScopeResolverTests Assert.Equal(expectedScope, scope); } + + /// + /// Verifies that an unmapped request type fails closed: the resolver returns the + /// most-restrictive scope rather than a permissive + /// default, so a newly added RPC that is never mapped is denied to ordinary keys. + /// + [Fact] + public void ResolveRequiredScope_UnmappedRequestType_FailsClosedToAdminScope() + { + GatewayGrpcScopeResolver resolver = new(); + + string scope = resolver.ResolveRequiredScope(new UnmappedRequest()); + + Assert.Equal(GatewayScopes.Admin, scope); + } + + /// + /// Verifies that an with an unrecognized command kind + /// resolves to the read scope rather than silently granting write or admin access. + /// + [Fact] + public void ResolveRequiredScope_UnknownInvokeCommandKind_ReturnsInvokeReadScope() + { + GatewayGrpcScopeResolver resolver = new(); + + string scope = resolver.ResolveRequiredScope(new MxCommandRequest + { + Command = new MxCommand + { + Kind = (MxCommandKind)9999, + }, + }); + + Assert.Equal(GatewayScopes.InvokeRead, scope); + } + + /// Request type intentionally not mapped by the scope resolver. + private sealed class UnmappedRequest; } diff --git a/src/ZB.MOM.WW.MxGateway.Tests/TestSupport/AllowAllConstraintEnforcer.cs b/src/ZB.MOM.WW.MxGateway.Tests/TestSupport/AllowAllConstraintEnforcer.cs new file mode 100644 index 0000000..0bdaf36 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/TestSupport/AllowAllConstraintEnforcer.cs @@ -0,0 +1,42 @@ +using ZB.MOM.WW.MxGateway.Server.Security.Authentication; +using ZB.MOM.WW.MxGateway.Server.Security.Authorization; +using ZB.MOM.WW.MxGateway.Server.Sessions; + +namespace ZB.MOM.WW.MxGateway.Tests.TestSupport; + +/// +/// that permits every operation, for tests that +/// exercise gRPC service or interceptor behaviour without constraint policy. +/// +public sealed class AllowAllConstraintEnforcer : IConstraintEnforcer +{ + /// + public Task CheckReadTagAsync( + ApiKeyIdentity? identity, + string tagAddress, + CancellationToken cancellationToken) => Task.FromResult(null); + + /// + public Task CheckReadHandleAsync( + ApiKeyIdentity? identity, + GatewaySession session, + int serverHandle, + int itemHandle, + CancellationToken cancellationToken) => Task.FromResult(null); + + /// + public Task CheckWriteHandleAsync( + ApiKeyIdentity? identity, + GatewaySession session, + int serverHandle, + int itemHandle, + CancellationToken cancellationToken) => Task.FromResult(null); + + /// + public Task RecordDenialAsync( + ApiKeyIdentity? identity, + string commandKind, + string target, + ConstraintFailure failure, + CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/src/ZB.MOM.WW.MxGateway.Tests/TestSupport/FakeGatewayAlarmService.cs b/src/ZB.MOM.WW.MxGateway.Tests/TestSupport/FakeGatewayAlarmService.cs new file mode 100644 index 0000000..e0e1674 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/TestSupport/FakeGatewayAlarmService.cs @@ -0,0 +1,54 @@ +using System.Runtime.CompilerServices; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Server.Alarms; + +namespace ZB.MOM.WW.MxGateway.Tests.TestSupport; + +/// +/// test double — serves a scripted +/// active-alarm set and acknowledges every request with an OK status, +/// so gRPC service tests can exercise the alarm handlers without the +/// real gateway alarm monitor or a worker. +/// +public sealed class FakeGatewayAlarmService : IGatewayAlarmService +{ + /// + public GatewayAlarmMonitorState State { get; set; } = GatewayAlarmMonitorState.Monitoring; + + /// + public string? LastError { get; set; } + + /// + public int? WorkerProcessId { get; set; } + + /// + public IReadOnlyList CurrentAlarms { get; set; } = []; + + /// + public async IAsyncEnumerable StreamAsync( + string? alarmFilterPrefix, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + foreach (ActiveAlarmSnapshot alarm in CurrentAlarms) + { + cancellationToken.ThrowIfCancellationRequested(); + yield return new AlarmFeedMessage { ActiveAlarm = alarm }; + } + + yield return new AlarmFeedMessage { SnapshotComplete = true }; + await Task.CompletedTask.ConfigureAwait(false); + } + + /// + public Task AcknowledgeAsync( + AcknowledgeAlarmRequest request, + CancellationToken cancellationToken) + { + return Task.FromResult(new AcknowledgeAlarmReply + { + CorrelationId = request.ClientCorrelationId, + ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok }, + DiagnosticMessage = string.Empty, + }); + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Tests/TestSupport/ManualTimeProvider.cs b/src/ZB.MOM.WW.MxGateway.Tests/TestSupport/ManualTimeProvider.cs new file mode 100644 index 0000000..ce33044 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/TestSupport/ManualTimeProvider.cs @@ -0,0 +1,22 @@ +namespace ZB.MOM.WW.MxGateway.Tests.TestSupport; + +/// +/// with a manually advanced clock for deterministic +/// timestamp / heartbeat / lease tests. Tests inject one of these instead of +/// so timing assertions don't depend on the +/// wall clock. Constructed without arguments (or with default) it seeds +/// from ; for fully deterministic tests pass +/// an explicit start instant. +/// +/// Initial clock value. When default, the clock seeds from . +public sealed class ManualTimeProvider(DateTimeOffset start = default) : TimeProvider +{ + private DateTimeOffset _now = start == default ? DateTimeOffset.UtcNow : start; + + /// + public override DateTimeOffset GetUtcNow() => _now; + + /// Advances the manual clock by the given amount. + /// Amount of time to add to the current clock value. + public void Advance(TimeSpan delta) => _now += delta; +} diff --git a/src/ZB.MOM.WW.MxGateway.Tests/TestSupport/PredicateConstraintEnforcer.cs b/src/ZB.MOM.WW.MxGateway.Tests/TestSupport/PredicateConstraintEnforcer.cs new file mode 100644 index 0000000..27537fe --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/TestSupport/PredicateConstraintEnforcer.cs @@ -0,0 +1,89 @@ +using ZB.MOM.WW.MxGateway.Server.Security.Authentication; +using ZB.MOM.WW.MxGateway.Server.Security.Authorization; +using ZB.MOM.WW.MxGateway.Server.Sessions; + +namespace ZB.MOM.WW.MxGateway.Tests.TestSupport; + +/// +/// for tests that exercise the constraint +/// filtering and reply-merging code paths in +/// MxAccessGatewayService.ApplyConstraintsAsync and the +/// BulkConstraintPlan family. Callers supply predicates that decide +/// whether a given tag address or (server, item) handle is denied; recorded +/// denials are exposed for assertions. +/// +public sealed class PredicateConstraintEnforcer : IConstraintEnforcer +{ + /// Deny predicate keyed on tag address (returns true to deny). + public Func DenyTag { get; init; } = _ => false; + + /// Deny predicate keyed on (serverHandle, itemHandle) (returns true to deny). + public Func DenyReadHandle { get; init; } = (_, _) => false; + + /// Deny predicate keyed on (serverHandle, itemHandle) (returns true to deny). + public Func DenyWriteHandle { get; init; } = (_, _) => false; + + /// Recorded denial messages — (commandKind, target) tuples. + public List<(string CommandKind, string Target)> RecordedDenials { get; } = []; + + /// + public Task CheckReadTagAsync( + ApiKeyIdentity? identity, + string tagAddress, + CancellationToken cancellationToken) + { + if (DenyTag(tagAddress)) + { + return Task.FromResult( + new ConstraintFailure("read-tag", $"Read denied for tag '{tagAddress}'.")); + } + + return Task.FromResult(null); + } + + /// + public Task CheckReadHandleAsync( + ApiKeyIdentity? identity, + GatewaySession session, + int serverHandle, + int itemHandle, + CancellationToken cancellationToken) + { + if (DenyReadHandle(serverHandle, itemHandle)) + { + return Task.FromResult( + new ConstraintFailure("read-handle", $"Read denied for handle {itemHandle}.")); + } + + return Task.FromResult(null); + } + + /// + public Task CheckWriteHandleAsync( + ApiKeyIdentity? identity, + GatewaySession session, + int serverHandle, + int itemHandle, + CancellationToken cancellationToken) + { + if (DenyWriteHandle(serverHandle, itemHandle)) + { + return Task.FromResult( + new ConstraintFailure("write-handle", $"Write denied for handle {itemHandle}.")); + } + + return Task.FromResult(null); + } + + /// + public Task RecordDenialAsync( + ApiKeyIdentity? identity, + string commandKind, + string target, + ConstraintFailure failure, + CancellationToken cancellationToken) + { + RecordedDenials.Add((commandKind, target)); + return Task.CompletedTask; + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Tests/TestSupport/RecordingServerStreamWriter.cs b/src/ZB.MOM.WW.MxGateway.Tests/TestSupport/RecordingServerStreamWriter.cs new file mode 100644 index 0000000..d18eeef --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/TestSupport/RecordingServerStreamWriter.cs @@ -0,0 +1,50 @@ +using Grpc.Core; + +namespace ZB.MOM.WW.MxGateway.Tests.TestSupport; + +/// +/// Thread-safe that records every written message +/// and lets a test await the first message with a timeout. +/// +/// The streamed message type. +public sealed class RecordingServerStreamWriter : IServerStreamWriter +{ + private readonly object _syncRoot = new(); + private readonly TaskCompletionSource _firstMessage = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly List _messages = []; + + /// Gets the messages written to this stream, in order. + public IReadOnlyList Messages + { + get + { + lock (_syncRoot) + { + return _messages.ToArray(); + } + } + } + + /// Gets or sets options for writing messages to the stream. + public WriteOptions? WriteOptions { get; set; } + + /// Records the supplied message. + /// The message to record. + /// A completed task. + public Task WriteAsync(T message) + { + lock (_syncRoot) + { + _messages.Add(message); + } + + _firstMessage.TrySetResult(message); + return Task.CompletedTask; + } + + /// Waits for the first message to be written within the specified timeout. + /// Maximum time to wait for the first message. + /// The first message written to this stream. + public async Task WaitForFirstMessageAsync(TimeSpan timeout) => + await _firstMessage.Task.WaitAsync(timeout).ConfigureAwait(false); +} diff --git a/src/ZB.MOM.WW.MxGateway.Tests/TestSupport/TestServerCallContext.cs b/src/ZB.MOM.WW.MxGateway.Tests/TestSupport/TestServerCallContext.cs new file mode 100644 index 0000000..b29958b --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/TestSupport/TestServerCallContext.cs @@ -0,0 +1,76 @@ +using Grpc.Core; + +namespace ZB.MOM.WW.MxGateway.Tests.TestSupport; + +/// +/// Minimal in-memory for exercising gRPC service +/// implementations directly in unit tests, without a real gRPC transport. +/// +public sealed class TestServerCallContext : ServerCallContext +{ + private readonly Metadata _requestHeaders; + private readonly Metadata _responseTrailers = []; + private readonly Dictionary _userState = []; + private readonly CancellationToken _cancellationToken; + private Status _status; + private WriteOptions? _writeOptions; + + /// Initializes the context with the supplied request headers and cancellation token. + /// Request headers visible to the service; defaults to empty. + /// Cancellation token surfaced to the service. + public TestServerCallContext(Metadata? requestHeaders = null, CancellationToken cancellationToken = default) + { + _requestHeaders = requestHeaders ?? []; + _cancellationToken = cancellationToken; + } + + /// + protected override string MethodCore => "/mxaccess_gateway.v1.MxAccessGateway/Test"; + + /// + protected override string HostCore => "localhost"; + + /// + protected override string PeerCore => "ipv4:127.0.0.1:5000"; + + /// + protected override DateTime DeadlineCore => DateTime.UtcNow.AddMinutes(1); + + /// + protected override Metadata RequestHeadersCore => _requestHeaders; + + /// + protected override CancellationToken CancellationTokenCore => _cancellationToken; + + /// + protected override Metadata ResponseTrailersCore => _responseTrailers; + + /// + protected override Status StatusCore + { + get => _status; + set => _status = value; + } + + /// + protected override WriteOptions? WriteOptionsCore + { + get => _writeOptions; + set => _writeOptions = value; + } + + /// + protected override AuthContext AuthContextCore { get; } = new( + string.Empty, + new Dictionary>(StringComparer.Ordinal)); + + /// + protected override IDictionary UserStateCore => _userState; + + /// + protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders) => Task.CompletedTask; + + /// + protected override ContextPropagationToken CreatePropagationTokenCore(ContextPropagationOptions? options) => + throw new NotSupportedException(); +} diff --git a/src/MxGateway.Tests/MxGateway.Tests.csproj b/src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj similarity index 50% rename from src/MxGateway.Tests/MxGateway.Tests.csproj rename to src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj index 27e10c0..714c3cf 100644 --- a/src/MxGateway.Tests/MxGateway.Tests.csproj +++ b/src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj @@ -17,8 +17,15 @@ - - + + + + + + + diff --git a/src/ZB.MOM.WW.MxGateway.Tests/xunit.runner.json b/src/ZB.MOM.WW.MxGateway.Tests/xunit.runner.json new file mode 100644 index 0000000..14a4420 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/xunit.runner.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", + "appDomain": "denied", + "parallelizeAssembly": false, + "parallelizeTestCollections": true, + "maxParallelThreads": -1, + "longRunningTestSeconds": 30 +} diff --git a/src/MxGateway.Worker.Tests/AlarmClientDiscoveryTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/AlarmClientDiscoveryTests.cs similarity index 98% rename from src/MxGateway.Worker.Tests/AlarmClientDiscoveryTests.cs rename to src/ZB.MOM.WW.MxGateway.Worker.Tests/AlarmClientDiscoveryTests.cs index 892f36a..c81239a 100644 --- a/src/MxGateway.Worker.Tests/AlarmClientDiscoveryTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/AlarmClientDiscoveryTests.cs @@ -4,7 +4,7 @@ using System.Reflection; using Xunit; using Xunit.Abstractions; -namespace MxGateway.Worker.Tests; +namespace ZB.MOM.WW.MxGateway.Worker.Tests; /// /// One-shot reflection probe — discovers the public surface of diff --git a/src/MxGateway.Worker.Tests/Bootstrap/MemoryWorkerEnvironment.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Bootstrap/MemoryWorkerEnvironment.cs similarity index 92% rename from src/MxGateway.Worker.Tests/Bootstrap/MemoryWorkerEnvironment.cs rename to src/ZB.MOM.WW.MxGateway.Worker.Tests/Bootstrap/MemoryWorkerEnvironment.cs index 75afd1e..0ffc271 100644 --- a/src/MxGateway.Worker.Tests/Bootstrap/MemoryWorkerEnvironment.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Bootstrap/MemoryWorkerEnvironment.cs @@ -1,8 +1,8 @@ using System; using System.Collections.Generic; -using MxGateway.Worker.Bootstrap; +using ZB.MOM.WW.MxGateway.Worker.Bootstrap; -namespace MxGateway.Worker.Tests.Bootstrap; +namespace ZB.MOM.WW.MxGateway.Worker.Tests.Bootstrap; /// /// In-memory test implementation of the worker environment. diff --git a/src/MxGateway.Worker.Tests/Bootstrap/MemoryWorkerLogEntry.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Bootstrap/MemoryWorkerLogEntry.cs similarity index 95% rename from src/MxGateway.Worker.Tests/Bootstrap/MemoryWorkerLogEntry.cs rename to src/ZB.MOM.WW.MxGateway.Worker.Tests/Bootstrap/MemoryWorkerLogEntry.cs index 41e533d..85c5a2f 100644 --- a/src/MxGateway.Worker.Tests/Bootstrap/MemoryWorkerLogEntry.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Bootstrap/MemoryWorkerLogEntry.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; -namespace MxGateway.Worker.Tests.Bootstrap; +namespace ZB.MOM.WW.MxGateway.Worker.Tests.Bootstrap; /// /// Captures a log entry for testing. diff --git a/src/MxGateway.Worker.Tests/Bootstrap/MemoryWorkerLogger.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Bootstrap/MemoryWorkerLogger.cs similarity index 89% rename from src/MxGateway.Worker.Tests/Bootstrap/MemoryWorkerLogger.cs rename to src/ZB.MOM.WW.MxGateway.Worker.Tests/Bootstrap/MemoryWorkerLogger.cs index 7af3a56..c3d7ba0 100644 --- a/src/MxGateway.Worker.Tests/Bootstrap/MemoryWorkerLogger.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Bootstrap/MemoryWorkerLogger.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; -using MxGateway.Worker.Bootstrap; +using ZB.MOM.WW.MxGateway.Worker.Bootstrap; -namespace MxGateway.Worker.Tests.Bootstrap; +namespace ZB.MOM.WW.MxGateway.Worker.Tests.Bootstrap; /// /// In-memory logger that records all log entries for test inspection. diff --git a/src/MxGateway.Worker.Tests/Bootstrap/WorkerApplicationTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Bootstrap/WorkerApplicationTests.cs similarity index 91% rename from src/MxGateway.Worker.Tests/Bootstrap/WorkerApplicationTests.cs rename to src/ZB.MOM.WW.MxGateway.Worker.Tests/Bootstrap/WorkerApplicationTests.cs index b43209b..e1155c3 100644 --- a/src/MxGateway.Worker.Tests/Bootstrap/WorkerApplicationTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Bootstrap/WorkerApplicationTests.cs @@ -1,11 +1,11 @@ using System; using System.Threading; using System.Threading.Tasks; -using MxGateway.Contracts; -using MxGateway.Worker.Bootstrap; -using MxGateway.Worker.Ipc; +using ZB.MOM.WW.MxGateway.Contracts; +using ZB.MOM.WW.MxGateway.Worker.Bootstrap; +using ZB.MOM.WW.MxGateway.Worker.Ipc; -namespace MxGateway.Worker.Tests.Bootstrap; +namespace ZB.MOM.WW.MxGateway.Worker.Tests.Bootstrap; public sealed class WorkerApplicationTests { @@ -16,7 +16,7 @@ public sealed class WorkerApplicationTests MemoryWorkerEnvironment environment = CreateEnvironment("nonce-secret"); MemoryWorkerLogger logger = new(); - int exitCode = MxGateway.Worker.WorkerApplication.Run( + int exitCode = ZB.MOM.WW.MxGateway.Worker.WorkerApplication.Run( ValidArgs(), environment, logger, @@ -41,7 +41,7 @@ public sealed class WorkerApplicationTests MemoryWorkerEnvironment environment = CreateEnvironment("nonce-secret"); MemoryWorkerLogger logger = new(); - int exitCode = MxGateway.Worker.WorkerApplication.Run( + int exitCode = ZB.MOM.WW.MxGateway.Worker.WorkerApplication.Run( [], environment, logger); @@ -60,7 +60,7 @@ public sealed class WorkerApplicationTests MemoryWorkerEnvironment environment = CreateEnvironment("nonce-secret"); MemoryWorkerLogger logger = new(); - int exitCode = MxGateway.Worker.WorkerApplication.Run( + int exitCode = ZB.MOM.WW.MxGateway.Worker.WorkerApplication.Run( ValidArgs(protocolVersion: "999"), environment, logger); @@ -75,7 +75,7 @@ public sealed class WorkerApplicationTests MemoryWorkerEnvironment environment = new(); MemoryWorkerLogger logger = new(); - int exitCode = MxGateway.Worker.WorkerApplication.Run( + int exitCode = ZB.MOM.WW.MxGateway.Worker.WorkerApplication.Run( ValidArgs(), environment, logger); @@ -90,7 +90,7 @@ public sealed class WorkerApplicationTests MemoryWorkerEnvironment environment = CreateEnvironment("nonce-secret"); MemoryWorkerLogger logger = new(); - int exitCode = MxGateway.Worker.WorkerApplication.Run( + int exitCode = ZB.MOM.WW.MxGateway.Worker.WorkerApplication.Run( ValidArgs(), environment, logger, @@ -109,7 +109,7 @@ public sealed class WorkerApplicationTests MemoryWorkerEnvironment environment = new(new InvalidOperationException("environment failed")); MemoryWorkerLogger logger = new(); - int exitCode = MxGateway.Worker.WorkerApplication.Run( + int exitCode = ZB.MOM.WW.MxGateway.Worker.WorkerApplication.Run( ValidArgs(), environment, logger); diff --git a/src/MxGateway.Worker.Tests/Bootstrap/WorkerConsoleLoggerTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Bootstrap/WorkerConsoleLoggerTests.cs similarity index 89% rename from src/MxGateway.Worker.Tests/Bootstrap/WorkerConsoleLoggerTests.cs rename to src/ZB.MOM.WW.MxGateway.Worker.Tests/Bootstrap/WorkerConsoleLoggerTests.cs index 611d1ed..80f2b1d 100644 --- a/src/MxGateway.Worker.Tests/Bootstrap/WorkerConsoleLoggerTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Bootstrap/WorkerConsoleLoggerTests.cs @@ -1,8 +1,8 @@ using System.Collections.Generic; using System.IO; -using MxGateway.Worker.Bootstrap; +using ZB.MOM.WW.MxGateway.Worker.Bootstrap; -namespace MxGateway.Worker.Tests.Bootstrap; +namespace ZB.MOM.WW.MxGateway.Worker.Tests.Bootstrap; public sealed class WorkerConsoleLoggerTests { diff --git a/src/MxGateway.Worker.Tests/Bootstrap/WorkerLogRedactorTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Bootstrap/WorkerLogRedactorTests.cs similarity index 61% rename from src/MxGateway.Worker.Tests/Bootstrap/WorkerLogRedactorTests.cs rename to src/ZB.MOM.WW.MxGateway.Worker.Tests/Bootstrap/WorkerLogRedactorTests.cs index ea2b68a..b7a93df 100644 --- a/src/MxGateway.Worker.Tests/Bootstrap/WorkerLogRedactorTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Bootstrap/WorkerLogRedactorTests.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; -using MxGateway.Worker.Bootstrap; +using ZB.MOM.WW.MxGateway.Worker.Bootstrap; -namespace MxGateway.Worker.Tests.Bootstrap; +namespace ZB.MOM.WW.MxGateway.Worker.Tests.Bootstrap; public sealed class WorkerLogRedactorTests { @@ -32,4 +32,16 @@ public sealed class WorkerLogRedactorTests Assert.Equal("[redacted]", redacted["api_key"]); Assert.Equal("session-1", redacted["session_id"]); } + + /// + /// Verifies redacts individual + /// credential-bearing fields before they reach a log sink. + /// + [Fact] + public void RedactValue_WithCredentialBearingFieldNames_ReturnsRedactedValue() + { + Assert.Equal(WorkerLogRedactor.RedactedValue, WorkerLogRedactor.RedactValue("credential_value", "secret")); + Assert.Equal(WorkerLogRedactor.RedactedValue, WorkerLogRedactor.RedactValue("password_value", "secret")); + Assert.Equal(WorkerLogRedactor.RedactedValue, WorkerLogRedactor.RedactValue("secured_write_token", "secret")); + } } diff --git a/src/MxGateway.Worker.Tests/Bootstrap/WorkerOptionsParserTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Bootstrap/WorkerOptionsParserTests.cs similarity index 97% rename from src/MxGateway.Worker.Tests/Bootstrap/WorkerOptionsParserTests.cs rename to src/ZB.MOM.WW.MxGateway.Worker.Tests/Bootstrap/WorkerOptionsParserTests.cs index 12e6ad0..6fc0409 100644 --- a/src/MxGateway.Worker.Tests/Bootstrap/WorkerOptionsParserTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Bootstrap/WorkerOptionsParserTests.cs @@ -1,7 +1,7 @@ -using MxGateway.Contracts; -using MxGateway.Worker.Bootstrap; +using ZB.MOM.WW.MxGateway.Contracts; +using ZB.MOM.WW.MxGateway.Worker.Bootstrap; -namespace MxGateway.Worker.Tests.Bootstrap; +namespace ZB.MOM.WW.MxGateway.Worker.Tests.Bootstrap; public sealed class WorkerOptionsParserTests { diff --git a/src/MxGateway.Worker.Tests/Contracts/WorkerContractInfoTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Contracts/WorkerContractInfoTests.cs similarity index 84% rename from src/MxGateway.Worker.Tests/Contracts/WorkerContractInfoTests.cs rename to src/ZB.MOM.WW.MxGateway.Worker.Tests/Contracts/WorkerContractInfoTests.cs index d24c4e1..6f040f4 100644 --- a/src/MxGateway.Worker.Tests/Contracts/WorkerContractInfoTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Contracts/WorkerContractInfoTests.cs @@ -1,7 +1,7 @@ -using MxGateway.Contracts; -using MxGateway.Worker.Ipc; +using ZB.MOM.WW.MxGateway.Contracts; +using ZB.MOM.WW.MxGateway.Worker.Ipc; -namespace MxGateway.Worker.Tests.Contracts; +namespace ZB.MOM.WW.MxGateway.Worker.Tests.Contracts; public sealed class WorkerContractInfoTests { diff --git a/src/MxGateway.Worker.Tests/Conversion/HResultConverterTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Conversion/HResultConverterTests.cs similarity index 93% rename from src/MxGateway.Worker.Tests/Conversion/HResultConverterTests.cs rename to src/ZB.MOM.WW.MxGateway.Worker.Tests/Conversion/HResultConverterTests.cs index de936e3..892ab29 100644 --- a/src/MxGateway.Worker.Tests/Conversion/HResultConverterTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Conversion/HResultConverterTests.cs @@ -1,9 +1,9 @@ using System; using System.Runtime.InteropServices; -using MxGateway.Contracts.Proto; -using MxGateway.Worker.Conversion; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Worker.Conversion; -namespace MxGateway.Worker.Tests.Conversion; +namespace ZB.MOM.WW.MxGateway.Worker.Tests.Conversion; /// /// Tests for . diff --git a/src/MxGateway.Worker.Tests/Conversion/MxStatusProxyConverterTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Conversion/MxStatusProxyConverterTests.cs similarity index 96% rename from src/MxGateway.Worker.Tests/Conversion/MxStatusProxyConverterTests.cs rename to src/ZB.MOM.WW.MxGateway.Worker.Tests/Conversion/MxStatusProxyConverterTests.cs index 7021f5e..4f732c3 100644 --- a/src/MxGateway.Worker.Tests/Conversion/MxStatusProxyConverterTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Conversion/MxStatusProxyConverterTests.cs @@ -1,9 +1,9 @@ using System; using System.Collections.Generic; -using MxGateway.Contracts.Proto; -using MxGateway.Worker.Conversion; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Worker.Conversion; -namespace MxGateway.Worker.Tests.Conversion; +namespace ZB.MOM.WW.MxGateway.Worker.Tests.Conversion; public sealed class MxStatusProxyConverterTests { diff --git a/src/MxGateway.Worker.Tests/Conversion/VariantConverterTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Conversion/VariantConverterTests.cs similarity index 69% rename from src/MxGateway.Worker.Tests/Conversion/VariantConverterTests.cs rename to src/ZB.MOM.WW.MxGateway.Worker.Tests/Conversion/VariantConverterTests.cs index 865548e..559d7bf 100644 --- a/src/MxGateway.Worker.Tests/Conversion/VariantConverterTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Conversion/VariantConverterTests.cs @@ -1,11 +1,10 @@ using System; using Google.Protobuf; -using MxGateway.Contracts.Proto; -using MxGateway.Worker.Bootstrap; -using MxGateway.Worker.Conversion; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Worker.Conversion; using ProtobufTimestamp = Google.Protobuf.WellKnownTypes.Timestamp; -namespace MxGateway.Worker.Tests.Conversion; +namespace ZB.MOM.WW.MxGateway.Worker.Tests.Conversion; public sealed class VariantConverterTests { @@ -47,6 +46,78 @@ public sealed class VariantConverterTests Assert.Equal("VT_DATE", converted.VariantType); } + /// Verifies that scalar MxValue kinds convert to the matching boxed CLR type for a COM write. + [Fact] + public void ConvertToComValue_WithInt32_ReturnsBoxedInt() + { + object? result = _converter.ConvertToComValue(new MxValue { Int32Value = 123 }); + + Assert.Equal(123, Assert.IsType(result)); + } + + /// Verifies that a boolean MxValue converts to a boxed bool for a COM write. + [Fact] + public void ConvertToComValue_WithBool_ReturnsBoxedBool() + { + object? result = _converter.ConvertToComValue(new MxValue { BoolValue = true }); + + Assert.True(Assert.IsType(result)); + } + + /// Verifies that a string MxValue converts to a string for a COM write. + [Fact] + public void ConvertToComValue_WithString_ReturnsString() + { + object? result = _converter.ConvertToComValue(new MxValue { StringValue = "abc" }); + + Assert.Equal("abc", Assert.IsType(result)); + } + + /// Verifies that a timestamp MxValue converts to a UTC DateTime the COM marshaler renders as VT_DATE. + [Fact] + public void ConvertToComValue_WithTimestamp_ReturnsUtcDateTime() + { + DateTime dateTime = new(2026, 5, 19, 12, 0, 0, DateTimeKind.Utc); + + object? result = _converter.ConvertToComValue( + new MxValue { TimestampValue = ProtobufTimestamp.FromDateTime(dateTime) }); + + Assert.Equal(dateTime, Assert.IsType(result)); + } + + /// Verifies that an MXAccess-null MxValue converts to a CLR null. + [Fact] + public void ConvertToComValue_WithNull_ReturnsNull() + { + object? result = _converter.ConvertToComValue(new MxValue { IsNull = true }); + + Assert.Null(result); + } + + /// Verifies that an integer-array MxValue converts to an int array the COM marshaler renders as a SAFEARRAY. + [Fact] + public void ConvertToComValue_WithInt32Array_ReturnsInt32Array() + { + MxValue value = new() + { + ArrayValue = new MxArray + { + Int32Values = new Int32Array { Values = { 1, 2, 3 } }, + }, + }; + + object? result = _converter.ConvertToComValue(value); + + Assert.Equal(new[] { 1, 2, 3 }, Assert.IsType(result)); + } + + /// Verifies that an MxValue with no value kind set cannot be converted for a COM write. + [Fact] + public void ConvertToComValue_WithNoKind_Throws() + { + Assert.Throws(() => _converter.ConvertToComValue(new MxValue())); + } + /// Verifies that file time values with expected time data type are converted to protobuf timestamps. [Fact] public void Convert_WithFileTimeAndExpectedTime_ProjectsTimestamp() @@ -60,6 +131,26 @@ public sealed class VariantConverterTests Assert.Equal("VT_I8", converted.VariantType); } + /// + /// Worker-010 regression: a 32-bit with an expected + /// data type of must not be projected as a + /// Windows FILETIME. A uint can only hold the low 32 bits of a FILETIME, + /// which would silently render as a near-1601 timestamp; the converter + /// must fall through to an integer projection instead. + /// + [Fact] + public void Convert_WithUInt32AndExpectedTime_DoesNotProjectFileTime() + { + const uint value = 123456789u; + + MxValue converted = _converter.Convert(value, MxDataType.Time); + + Assert.Equal(MxDataType.Integer, converted.DataType); + Assert.Equal(MxValue.KindOneofCase.Int64Value, converted.KindCase); + Assert.Equal(value, converted.Int64Value); + Assert.Equal("VT_UI4", converted.VariantType); + } + /// Verifies that null-like values preserve their null semantics and variant type. /// Null-like value to convert. /// Expected variant type string. @@ -172,15 +263,6 @@ public sealed class VariantConverterTests Assert.Contains(typeof(UnsupportedVariant).FullName!, converted.ArrayValue.RawDiagnostic); } - /// Verifies that credential-bearing fields are redacted before logging. - [Fact] - public void Redactor_WithCredentialBearingValueFields_RedactsBeforeLogging() - { - Assert.Equal(WorkerLogRedactor.RedactedValue, WorkerLogRedactor.RedactValue("credential_value", "secret")); - Assert.Equal(WorkerLogRedactor.RedactedValue, WorkerLogRedactor.RedactValue("password_value", "secret")); - Assert.Equal(WorkerLogRedactor.RedactedValue, WorkerLogRedactor.RedactValue("secured_write_token", "secret")); - } - /// Fake unsupported variant type for testing unknown type handling. private sealed class UnsupportedVariant { diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/Ipc/WorkerFrameProtocolTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Ipc/WorkerFrameProtocolTests.cs new file mode 100644 index 0000000..4b60181 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Ipc/WorkerFrameProtocolTests.cs @@ -0,0 +1,315 @@ +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using ZB.MOM.WW.MxGateway.Contracts; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Worker.Ipc; +using ZB.MOM.WW.MxGateway.Worker.Tests.TestSupport; + +namespace ZB.MOM.WW.MxGateway.Worker.Tests.Ipc; + +public sealed class WorkerFrameProtocolTests +{ + private const string SessionId = "session-1"; + private const string Nonce = "nonce-secret"; + + /// Verifies that valid envelopes round-trip through write and read. + [Fact] + public async Task WriteAndReadAsync_WithValidEnvelope_RoundTripsFrame() + { + WorkerFrameProtocolOptions options = CreateOptions(); + using MemoryStream stream = new(); + WorkerEnvelope original = CreateGatewayHelloEnvelope(); + + WorkerFrameWriter writer = new(stream, options); + await writer.WriteAsync(original); + stream.Position = 0; + + WorkerFrameReader reader = new(stream, options); + WorkerEnvelope parsed = await reader.ReadAsync(); + + Assert.Equal(original, parsed); + } + + /// Verifies that wrong protocol version throws mismatch error. + [Fact] + public async Task ReadAsync_WithWrongProtocolVersion_ThrowsProtocolVersionMismatch() + { + WorkerFrameProtocolOptions options = CreateOptions(); + WorkerEnvelope envelope = CreateGatewayHelloEnvelope(); + envelope.ProtocolVersion++; + using MemoryStream stream = new(WorkerFrameTestHelpers.CreateFrame(envelope)); + + WorkerFrameReader reader = new(stream, options); + WorkerFrameProtocolException exception = + await Assert.ThrowsAsync( + async () => await reader.ReadAsync()); + + Assert.Equal(WorkerFrameProtocolErrorCode.ProtocolVersionMismatch, exception.ErrorCode); + } + + /// Verifies that wrong session ID throws mismatch error. + [Fact] + public async Task ReadAsync_WithWrongSessionId_ThrowsSessionMismatch() + { + WorkerFrameProtocolOptions options = CreateOptions(); + WorkerEnvelope envelope = CreateGatewayHelloEnvelope(); + envelope.SessionId = "different-session"; + using MemoryStream stream = new(WorkerFrameTestHelpers.CreateFrame(envelope)); + + WorkerFrameReader reader = new(stream, options); + WorkerFrameProtocolException exception = + await Assert.ThrowsAsync( + async () => await reader.ReadAsync()); + + Assert.Equal(WorkerFrameProtocolErrorCode.SessionMismatch, exception.ErrorCode); + } + + /// + /// Verifies that a frame whose length prefix is zero is rejected before the + /// payload buffer is allocated. docs/WorkerFrameProtocol.md states the + /// reader rejects zero-length payloads as a malformed-length error. The + /// length prefix is the leading four bytes of the stream, so a four-zero-byte + /// stream is exactly a frame declaring a zero-length payload. + /// + [Fact] + public async Task ReadAsync_WithZeroLengthPayload_ThrowsMalformedLength() + { + WorkerFrameProtocolOptions options = CreateOptions(); + using MemoryStream stream = new(new byte[sizeof(uint)]); + + WorkerFrameReader reader = new(stream, options); + WorkerFrameProtocolException exception = + await Assert.ThrowsAsync( + async () => await reader.ReadAsync()); + + Assert.Equal(WorkerFrameProtocolErrorCode.MalformedLength, exception.ErrorCode); + } + + /// + /// Verifies that a frame whose length prefix exceeds the configured maximum + /// is rejected before the payload buffer is allocated. docs/WorkerFrameProtocol.md + /// states the reader rejects oversized payloads as a message-too-large error. + /// A small maximum is configured so the rejection is asserted without + /// allocating a multi-megabyte buffer. + /// + [Fact] + public async Task ReadAsync_WithPayloadAboveConfiguredMaximum_ThrowsMessageTooLarge() + { + const int maxMessageBytes = 64; + WorkerFrameProtocolOptions options = new( + SessionId, + GatewayContractInfo.WorkerProtocolVersion, + Nonce, + maxMessageBytes); + byte[] frame = new byte[sizeof(uint)]; + WorkerFrameTestHelpers.WriteUInt32LittleEndian(frame, maxMessageBytes + 1); + using MemoryStream stream = new(frame); + + WorkerFrameReader reader = new(stream, options); + WorkerFrameProtocolException exception = + await Assert.ThrowsAsync( + async () => await reader.ReadAsync()); + + Assert.Equal(WorkerFrameProtocolErrorCode.MessageTooLarge, exception.ErrorCode); + } + + /// Verifies that malformed payload throws invalid envelope error. + [Fact] + public async Task ReadAsync_WithMalformedPayload_ThrowsInvalidEnvelope() + { + WorkerFrameProtocolOptions options = CreateOptions(); + using MemoryStream stream = new(WorkerFrameTestHelpers.CreateFrame(new byte[] { 0x80 })); + + WorkerFrameReader reader = new(stream, options); + WorkerFrameProtocolException exception = + await Assert.ThrowsAsync( + async () => await reader.ReadAsync()); + + Assert.Equal(WorkerFrameProtocolErrorCode.InvalidEnvelope, exception.ErrorCode); + } + + /// + /// Worker.Tests-021 (a): pins the EndOfStream branch of + /// WorkerFrameReader.ReadExactlyOrThrowAsync. The gateway + /// closing its end of the pipe during a partial-frame read is the + /// most common production transport failure; the reader must + /// surface this as WorkerFrameProtocolErrorCode.EndOfStream + /// so the worker session can fault deterministically rather than + /// spinning on a partial buffer. The stream here declares a 100-byte + /// payload but only supplies 50 bytes, so the inner read loop sees + /// bytesRead == 0 mid-frame. + /// + [Fact] + public async Task ReadAsync_WhenStreamEndsMidFrame_ThrowsEndOfStream() + { + WorkerFrameProtocolOptions options = CreateOptions(); + byte[] frame = new byte[sizeof(uint) + 50]; + WorkerFrameTestHelpers.WriteUInt32LittleEndian(frame, 100); + using MemoryStream stream = new(frame); + + WorkerFrameReader reader = new(stream, options); + WorkerFrameProtocolException exception = + await Assert.ThrowsAsync( + async () => await reader.ReadAsync()); + + Assert.Equal(WorkerFrameProtocolErrorCode.EndOfStream, exception.ErrorCode); + } + + /// + /// Worker.Tests-021 (b): pins the writer-side + /// MessageTooLarge branch. A session that constructs an + /// envelope whose serialised size exceeds MaxMessageBytes + /// must be rejected by the writer before any bytes are sent down + /// the pipe, so a misbehaving producer cannot push the receiver + /// past its bounds. A small MaxMessageBytes is configured + /// so a modest GatewayHello payload — with its nonce + /// padded out to several hundred bytes — exceeds the limit + /// without allocating anything large. + /// + [Fact] + public async Task WriteAsync_WithEnvelopeAboveConfiguredMaximum_ThrowsMessageTooLarge() + { + const int maxMessageBytes = 64; + WorkerFrameProtocolOptions options = new( + SessionId, + GatewayContractInfo.WorkerProtocolVersion, + Nonce, + maxMessageBytes); + using MemoryStream stream = new(); + WorkerFrameWriter writer = new(stream, options); + + WorkerEnvelope envelope = CreateGatewayHelloEnvelope(); + envelope.GatewayHello.GatewayVersion = new string('x', 1024); + + WorkerFrameProtocolException exception = + await Assert.ThrowsAsync( + async () => await writer.WriteAsync(envelope)); + + Assert.Equal(WorkerFrameProtocolErrorCode.MessageTooLarge, exception.ErrorCode); + Assert.Equal(0, stream.Length); + } + + /// + /// Worker.Tests-021 (c): documents that the writer-side + /// InvalidEnvelope branch (raised when + /// WorkerEnvelope.CalculateSize() returns 0) is unreachable + /// through public API. WorkerEnvelopeValidator.Validate (run + /// before the size check in WorkerFrameWriter.WriteAsync) + /// rejects any envelope whose BodyCase is None with + /// InvalidEnvelope; a body-less envelope is therefore + /// intercepted before the empty-payload branch can fire. Any + /// envelope carrying a typed body serialises at least the field + /// tag bytes, so CalculateSize() is strictly positive. This + /// test exercises the body-less path and asserts the same + /// InvalidEnvelope error code reaches the caller, pinning + /// the contract that "no body" is rejected before any size check. + /// The defensive zero-length branch in WriteAsync is left + /// in place because the cost is one comparison and removing it + /// would weaken the writer against future serialisation + /// regressions; this test makes its rationale visible. + /// + [Fact] + public async Task WriteAsync_WithEmptyEnvelope_ThrowsInvalidEnvelopeFromValidator() + { + WorkerFrameProtocolOptions options = CreateOptions(); + using MemoryStream stream = new(); + WorkerFrameWriter writer = new(stream, options); + + WorkerEnvelope envelope = new() + { + ProtocolVersion = GatewayContractInfo.WorkerProtocolVersion, + SessionId = SessionId, + Sequence = 1, + // No body — BodyCase == None, validator rejects. + }; + + WorkerFrameProtocolException exception = + await Assert.ThrowsAsync( + async () => await writer.WriteAsync(envelope)); + + Assert.Equal(WorkerFrameProtocolErrorCode.InvalidEnvelope, exception.ErrorCode); + Assert.Equal(0, stream.Length); + } + + /// Verifies that concurrent writes produce complete serialized frames. + [Fact] + public async Task WriteAsync_WithConcurrentCalls_SerializesCompleteFrames() + { + WorkerFrameProtocolOptions options = CreateOptions(); + using MemoryStream stream = new(); + WorkerFrameWriter writer = new(stream, options); + + await Task.WhenAll( + writer.WriteAsync(CreateGatewayHelloEnvelope(sequence: 1)), + writer.WriteAsync(CreateGatewayHelloEnvelope(sequence: 2)), + writer.WriteAsync(CreateGatewayHelloEnvelope(sequence: 3))); + + stream.Position = 0; + WorkerFrameReader reader = new(stream, options); + + WorkerEnvelope first = await reader.ReadAsync(); + WorkerEnvelope second = await reader.ReadAsync(); + WorkerEnvelope third = await reader.ReadAsync(); + + Assert.Equal(new ulong[] { 1, 2, 3 }, new[] { first.Sequence, second.Sequence, third.Sequence }.OrderBy(sequence => sequence)); + } + + /// + /// Worker-009 regression: the reader rents its payload buffer from a + /// shared pool, so a rented buffer can be larger than the current frame + /// and may carry bytes from a previous, larger frame. Reading frames of + /// differing sizes back-to-back through one reader must parse each frame + /// using only its own payload length, never trailing pooled bytes. + /// + [Fact] + public async Task ReadAsync_WithVaryingFrameSizes_ParsesEachFrameExactly() + { + WorkerFrameProtocolOptions options = CreateOptions(); + using MemoryStream stream = new(); + WorkerFrameWriter writer = new(stream, options); + + // A large-payload frame followed by a small-payload frame: if the + // reader reused a pooled buffer without honouring the second frame's + // length, the small frame would parse with stale trailing bytes. + WorkerEnvelope large = CreateGatewayHelloEnvelope(sequence: 1); + large.GatewayHello.GatewayVersion = new string('x', 4096); + WorkerEnvelope small = CreateGatewayHelloEnvelope(sequence: 2); + + await writer.WriteAsync(large); + await writer.WriteAsync(small); + stream.Position = 0; + + WorkerFrameReader reader = new(stream, options); + WorkerEnvelope firstParsed = await reader.ReadAsync(); + WorkerEnvelope secondParsed = await reader.ReadAsync(); + + Assert.Equal(large, firstParsed); + Assert.Equal(small, secondParsed); + } + + private static WorkerFrameProtocolOptions CreateOptions() + { + return new WorkerFrameProtocolOptions( + SessionId, + GatewayContractInfo.WorkerProtocolVersion, + Nonce); + } + + private static WorkerEnvelope CreateGatewayHelloEnvelope(ulong sequence = 1) + { + return new WorkerEnvelope + { + ProtocolVersion = GatewayContractInfo.WorkerProtocolVersion, + SessionId = SessionId, + Sequence = sequence, + GatewayHello = new GatewayHello + { + SupportedProtocolVersion = GatewayContractInfo.WorkerProtocolVersion, + Nonce = Nonce, + GatewayVersion = "test-gateway", + }, + }; + } + +} diff --git a/src/MxGateway.Worker.Tests/Ipc/WorkerPipeClientTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Ipc/WorkerPipeClientTests.cs similarity index 61% rename from src/MxGateway.Worker.Tests/Ipc/WorkerPipeClientTests.cs rename to src/ZB.MOM.WW.MxGateway.Worker.Tests/Ipc/WorkerPipeClientTests.cs index e3c45dd..5c6dc6f 100644 --- a/src/MxGateway.Worker.Tests/Ipc/WorkerPipeClientTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Ipc/WorkerPipeClientTests.cs @@ -1,18 +1,16 @@ using System; -using System.Collections.Generic; using System.IO; using System.IO.Pipes; using System.Threading; using System.Threading.Tasks; using Google.Protobuf.WellKnownTypes; -using MxGateway.Contracts; -using MxGateway.Contracts.Proto; -using MxGateway.Worker.Bootstrap; -using MxGateway.Worker.Ipc; -using MxGateway.Worker.MxAccess; -using MxGateway.Worker.Sta; +using ZB.MOM.WW.MxGateway.Contracts; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Worker.Bootstrap; +using ZB.MOM.WW.MxGateway.Worker.Ipc; +using ZB.MOM.WW.MxGateway.Worker.Tests.TestSupport; -namespace MxGateway.Worker.Tests.Ipc; +namespace ZB.MOM.WW.MxGateway.Worker.Tests.Ipc; public sealed class WorkerPipeClientTests { @@ -77,7 +75,9 @@ public sealed class WorkerPipeClientTests }, }); - WorkerEnvelope shutdownAck = await reader.ReadAsync(); + WorkerEnvelope shutdownAck = await ReadUntilAsync( + reader, + WorkerEnvelope.BodyOneofCase.WorkerShutdownAck); Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerShutdownAck, shutdownAck.BodyCase); await clientTask; } @@ -120,7 +120,9 @@ public sealed class WorkerPipeClientTests Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerReady, (await reader.ReadAsync()).BodyCase); await writer.WriteAsync(CreateShutdown()); - Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerShutdownAck, (await reader.ReadAsync()).BodyCase); + Assert.Equal( + WorkerEnvelope.BodyOneofCase.WorkerShutdownAck, + (await ReadUntilAsync(reader, WorkerEnvelope.BodyOneofCase.WorkerShutdownAck)).BodyCase); await clientTask; } @@ -143,6 +145,25 @@ public sealed class WorkerPipeClientTests await Assert.ThrowsAsync(async () => await client.RunAsync(workerOptions)); } + /// + /// Reads frames until one matching the expected body case is found, + /// skipping interleaved heartbeats (the first heartbeat is emitted + /// immediately on entering the heartbeat loop — see Worker-002). + /// + private static async Task ReadUntilAsync( + WorkerFrameReader reader, + WorkerEnvelope.BodyOneofCase expectedBody) + { + while (true) + { + WorkerEnvelope envelope = await reader.ReadAsync(); + if (envelope.BodyCase == expectedBody) + { + return envelope; + } + } + } + private static WorkerPipeSession CreateSession( Stream stream, WorkerFrameProtocolOptions options) @@ -190,100 +211,4 @@ public sealed class WorkerPipeClientTests }, }; } - - private sealed class FakeRuntimeSession : IWorkerRuntimeSession - { - /// Starts the worker session. - /// Session ID. - /// Worker process ID. - /// Cancellation token. - /// Worker ready response. - public Task StartAsync( - string sessionId, - int workerProcessId, - CancellationToken cancellationToken = default) - { - return Task.FromResult(new WorkerReady - { - WorkerProcessId = workerProcessId, - MxaccessProgid = MxGateway.Worker.MxAccess.MxAccessInteropInfo.ProgId, - MxaccessClsid = MxGateway.Worker.MxAccess.MxAccessInteropInfo.Clsid, - ReadyTimestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), - }); - } - - /// Dispatches a command to STA thread. - /// The command. - /// Command reply. - public Task DispatchAsync(StaCommand command) - { - return Task.FromResult(new MxCommandReply - { - SessionId = command.SessionId, - CorrelationId = command.CorrelationId, - Kind = command.Kind, - ProtocolStatus = new ProtocolStatus - { - Code = ProtocolStatusCode.Ok, - Message = "OK", - }, - }); - } - - /// Captures current runtime heartbeat snapshot. - /// Heartbeat snapshot. - public WorkerRuntimeHeartbeatSnapshot CaptureHeartbeat() - { - return new WorkerRuntimeHeartbeatSnapshot( - DateTimeOffset.UtcNow, - pendingCommandCount: 0, - outboundEventQueueDepth: 0, - lastEventSequence: 0, - currentCommandCorrelationId: string.Empty); - } - - /// Drains queued events. - /// Maximum events to drain. - /// Drained events. - public IReadOnlyList DrainEvents(uint maxEvents) - { - return Array.Empty(); - } - - /// Drains pending fault if any. - /// Fault or null. - public WorkerFault? DrainFault() - { - return null; - } - - /// Cancels a command by correlation ID. - /// Command correlation ID. - /// True if cancelled. - public bool CancelCommand(string correlationId) - { - return false; - } - - /// Requests graceful shutdown. - public void RequestShutdown() - { - } - - /// Shuts down gracefully within timeout. - /// Shutdown timeout. - /// Cancellation token. - /// Shutdown result. - public Task ShutdownGracefullyAsync( - TimeSpan timeout, - CancellationToken cancellationToken = default) - { - return Task.FromResult(new MxAccessShutdownResult(Array.Empty())); - } - - /// Disposes resources. - public void Dispose() - { - } - } } diff --git a/src/MxGateway.Worker.Tests/Ipc/WorkerPipeSessionTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Ipc/WorkerPipeSessionTests.cs similarity index 50% rename from src/MxGateway.Worker.Tests/Ipc/WorkerPipeSessionTests.cs rename to src/ZB.MOM.WW.MxGateway.Worker.Tests/Ipc/WorkerPipeSessionTests.cs index e65f1d1..9078270 100644 --- a/src/MxGateway.Worker.Tests/Ipc/WorkerPipeSessionTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Ipc/WorkerPipeSessionTests.cs @@ -6,13 +6,13 @@ using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; using Google.Protobuf.WellKnownTypes; -using MxGateway.Contracts; -using MxGateway.Contracts.Proto; -using MxGateway.Worker.Ipc; -using MxGateway.Worker.MxAccess; -using MxGateway.Worker.Sta; +using ZB.MOM.WW.MxGateway.Contracts; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Worker.Ipc; +using ZB.MOM.WW.MxGateway.Worker.MxAccess; +using ZB.MOM.WW.MxGateway.Worker.Tests.TestSupport; -namespace MxGateway.Worker.Tests.Ipc; +namespace ZB.MOM.WW.MxGateway.Worker.Tests.Ipc; public sealed class WorkerPipeSessionTests { @@ -24,10 +24,10 @@ public sealed class WorkerPipeSessionTests public async Task CompleteStartupHandshakeAsync_WithValidGatewayHello_SendsHelloThenReady() { WorkerFrameProtocolOptions options = CreateOptions(); - MemoryStream inbound = new(); + using MemoryStream inbound = new(); await new WorkerFrameWriter(inbound, options).WriteAsync(CreateGatewayHelloEnvelope()); inbound.Position = 0; - MemoryStream outbound = new(); + using MemoryStream outbound = new(); WorkerPipeSession session = CreateSession(inbound, outbound, options); bool initialized = false; @@ -45,8 +45,8 @@ public sealed class WorkerPipeSessionTests Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerReady, written[1].BodyCase); Assert.Equal(Nonce, written[0].WorkerHello.Nonce); Assert.Equal(1234, written[1].WorkerReady.WorkerProcessId); - Assert.Equal(MxGateway.Worker.MxAccess.MxAccessInteropInfo.ProgId, written[1].WorkerReady.MxaccessProgid); - Assert.Equal(MxGateway.Worker.MxAccess.MxAccessInteropInfo.Clsid, written[1].WorkerReady.MxaccessClsid); + Assert.Equal(ZB.MOM.WW.MxGateway.Worker.MxAccess.MxAccessInteropInfo.ProgId, written[1].WorkerReady.MxaccessProgid); + Assert.Equal(ZB.MOM.WW.MxGateway.Worker.MxAccess.MxAccessInteropInfo.Clsid, written[1].WorkerReady.MxaccessClsid); Assert.NotNull(written[1].WorkerReady.ReadyTimestamp); } @@ -55,10 +55,10 @@ public sealed class WorkerPipeSessionTests public async Task CompleteStartupHandshakeAsync_WithWrongNonce_FaultsBeforeInitialization() { WorkerFrameProtocolOptions options = CreateOptions(); - MemoryStream inbound = new(); + using MemoryStream inbound = new(); await new WorkerFrameWriter(inbound, options).WriteAsync(CreateGatewayHelloEnvelope(nonce: "wrong")); inbound.Position = 0; - MemoryStream outbound = new(); + using MemoryStream outbound = new(); WorkerPipeSession session = CreateSession(inbound, outbound, options); bool initialized = false; @@ -83,10 +83,10 @@ public sealed class WorkerPipeSessionTests public async Task CompleteStartupHandshakeAsync_WithWrongProtocol_FaultsBeforeInitialization() { WorkerFrameProtocolOptions options = CreateOptions(); - MemoryStream inbound = new(); + using MemoryStream inbound = new(); await new WorkerFrameWriter(inbound, options).WriteAsync(CreateGatewayHelloEnvelope(supportedProtocolVersion: 999)); inbound.Position = 0; - MemoryStream outbound = new(); + using MemoryStream outbound = new(); WorkerPipeSession session = CreateSession(inbound, outbound, options); bool initialized = false; @@ -110,8 +110,8 @@ public sealed class WorkerPipeSessionTests public async Task CompleteStartupHandshakeAsync_WithMalformedFrame_WritesWorkerFault() { WorkerFrameProtocolOptions options = CreateOptions(); - MemoryStream inbound = new(CreateFrame(new byte[] { 0x80 })); - MemoryStream outbound = new(); + using MemoryStream inbound = new(WorkerFrameTestHelpers.CreateFrame(new byte[] { 0x80 })); + using MemoryStream outbound = new(); WorkerPipeSession session = CreateSession(inbound, outbound, options); bool initialized = false; @@ -137,10 +137,10 @@ public sealed class WorkerPipeSessionTests { const int hresult = unchecked((int)0x80040154); WorkerFrameProtocolOptions options = CreateOptions(); - MemoryStream inbound = new(); + using MemoryStream inbound = new(); await new WorkerFrameWriter(inbound, options).WriteAsync(CreateGatewayHelloEnvelope()); inbound.Position = 0; - MemoryStream outbound = new(); + using MemoryStream outbound = new(); WorkerPipeSession session = CreateSession(inbound, outbound, options); await Assert.ThrowsAsync( @@ -181,12 +181,24 @@ public sealed class WorkerPipeSessionTests Task runTask = session.RunAsync(cancellation.Token); await CompleteGatewayHandshakeAsync(pipePair, cancellation.Token); - await ThrowIfCompletedAsync(runTask); - WorkerEnvelope heartbeat = await ReadUntilAsync( + // Deterministic race: read the first heartbeat while watching runTask. + // A faulted RunAsync would complete the run task first; if it wins the + // race the test fails immediately with the underlying fault instead of + // waiting out an arbitrary fixed delay. + Task heartbeatTask = ReadUntilAsync( pipePair.GatewayReader, WorkerEnvelope.BodyOneofCase.WorkerHeartbeat, cancellation.Token); + Task winner = await Task.WhenAny(runTask, heartbeatTask); + if (winner == runTask) + { + // Surface the RunAsync fault (or assert it did not exit early). + await runTask; + Assert.Fail("RunAsync completed before the first heartbeat was received."); + } + + WorkerEnvelope heartbeat = await heartbeatTask; Assert.Equal(WorkerState.ExecutingCommand, heartbeat.WorkerHeartbeat.State); Assert.Equal(1234, heartbeat.WorkerHeartbeat.WorkerProcessId); @@ -277,7 +289,14 @@ public sealed class WorkerPipeSessionTests } - /// Verifies that stale STA activity triggers watchdog fault. + /// + /// Verifies that stale STA activity with no command in flight triggers + /// the watchdog StaHung fault. Worker-017 changed the watchdog to skip + /// the fault while a command is in flight (the worker is busy + /// executing it, not hung), so this test deliberately leaves the + /// current-command correlation id empty to assert the genuine-hung + /// path still fires. + /// [Fact] public async Task RunAsync_WhenStaActivityIsStale_WritesWatchdogFault() { @@ -289,7 +308,7 @@ public sealed class WorkerPipeSessionTests pendingCommandCount: 0, outboundEventQueueDepth: 0, lastEventSequence: 0, - currentCommandCorrelationId: "stuck-command")); + currentCommandCorrelationId: string.Empty)); WorkerPipeSession session = CreatePipeSession( pipePair.WorkerStream, runtime, @@ -307,12 +326,262 @@ public sealed class WorkerPipeSessionTests cancellation.Token); Assert.Equal(WorkerFaultCategory.StaHung, fault.WorkerFault.Category); - Assert.Equal("stuck-command", fault.WorkerFault.CommandMethod); Assert.Contains("STA activity is stale", fault.WorkerFault.DiagnosticMessage); await SendShutdownAndWaitAsync(pipePair, runTask, cancellation.Token); } + /// + /// Worker-017 regression: while a command is in flight (snapshot's + /// current command correlation id is non-empty), stale STA activity + /// must NOT trigger the watchdog StaHung fault. The STA is busy + /// executing the command, not hung; StaRuntime.ProcessQueuedCommands + /// only calls MarkActivity() before and after each work item, + /// so a synchronously long-running command (e.g. ReadBulk + /// waiting timeout_ms for OnDataChange) legitimately freezes + /// LastActivityUtc. The heartbeat already advertises the + /// in-flight correlation id so the gateway can apply its own per-command + /// timeout. + /// + [Fact] + public async Task RunAsync_WhenStaActivityIsStaleWithCommandInFlight_DoesNotWriteWatchdogFault() + { + using CancellationTokenSource cancellation = new(TimeSpan.FromSeconds(10)); + using PipePair pipePair = await PipePair.CreateAsync(cancellation.Token); + FakeRuntimeSession runtime = new(); + runtime.SetSnapshot(new WorkerRuntimeHeartbeatSnapshot( + DateTimeOffset.UtcNow - TimeSpan.FromSeconds(5), + pendingCommandCount: 0, + outboundEventQueueDepth: 0, + lastEventSequence: 0, + currentCommandCorrelationId: "slow-bulk-read")); + WorkerPipeSession session = CreatePipeSession( + pipePair.WorkerStream, + runtime, + new WorkerPipeSessionOptions + { + HeartbeatInterval = TimeSpan.FromMilliseconds(20), + HeartbeatGrace = TimeSpan.FromMilliseconds(50), + }); + Task runTask = session.RunAsync(cancellation.Token); + await CompleteGatewayHandshakeAsync(pipePair, cancellation.Token); + + // Read several frames over a window much larger than HeartbeatGrace. + // None must be a WorkerFault; multiple heartbeats must all carry the + // in-flight correlation id. Reading a bounded count of frames keeps + // the pipe frame-aligned for the subsequent shutdown handshake. + const int framesToInspect = 6; + int heartbeatsObserved = 0; + for (int index = 0; index < framesToInspect; index++) + { + WorkerEnvelope envelope = await pipePair.GatewayReader + .ReadAsync(cancellation.Token); + Assert.NotEqual( + WorkerEnvelope.BodyOneofCase.WorkerFault, + envelope.BodyCase); + if (envelope.BodyCase == WorkerEnvelope.BodyOneofCase.WorkerHeartbeat) + { + Assert.Equal( + "slow-bulk-read", + envelope.WorkerHeartbeat.CurrentCommandCorrelationId); + heartbeatsObserved++; + } + } + + Assert.True( + heartbeatsObserved >= 2, + $"Expected multiple heartbeats during in-flight command window; observed {heartbeatsObserved}."); + + await SendShutdownAndWaitAsync(pipePair, runTask, cancellation.Token); + } + + /// + /// Worker-004 regression: once the watchdog reports an StaHung fault, + /// subsequent heartbeats must report + /// rather than a non-faulted state that contradicts the fault. The + /// snapshot uses an empty current-command correlation id so the + /// heartbeat State is derived from the session state, not forced to + /// ExecutingCommand. + /// + [Fact] + public async Task RunAsync_AfterWatchdogFault_HeartbeatReportsFaultedState() + { + using CancellationTokenSource cancellation = new(TimeSpan.FromSeconds(10)); + using PipePair pipePair = await PipePair.CreateAsync(cancellation.Token); + FakeRuntimeSession runtime = new(); + runtime.SetSnapshot(new WorkerRuntimeHeartbeatSnapshot( + DateTimeOffset.UtcNow - TimeSpan.FromSeconds(5), + pendingCommandCount: 0, + outboundEventQueueDepth: 0, + lastEventSequence: 0, + currentCommandCorrelationId: string.Empty)); + WorkerPipeSession session = CreatePipeSession( + pipePair.WorkerStream, + runtime, + new WorkerPipeSessionOptions + { + HeartbeatInterval = TimeSpan.FromMilliseconds(20), + HeartbeatGrace = TimeSpan.FromMilliseconds(50), + }); + Task runTask = session.RunAsync(cancellation.Token); + await CompleteGatewayHandshakeAsync(pipePair, cancellation.Token); + + WorkerEnvelope fault = await ReadUntilAsync( + pipePair.GatewayReader, + WorkerEnvelope.BodyOneofCase.WorkerFault, + cancellation.Token); + Assert.Equal(WorkerFaultCategory.StaHung, fault.WorkerFault.Category); + + // The next heartbeat after the fault must agree with it. + WorkerEnvelope heartbeat = await ReadUntilAsync( + pipePair.GatewayReader, + WorkerEnvelope.BodyOneofCase.WorkerHeartbeat, + cancellation.Token); + Assert.Equal(WorkerState.Faulted, heartbeat.WorkerHeartbeat.State); + + await SendShutdownAndWaitAsync(pipePair, runTask, cancellation.Token); + } + + /// + /// Worker-023 regression: the in-flight-command suppression on the + /// StaHung watchdog (Worker-017) is bounded by + /// WorkerPipeSessionOptions.HeartbeatStuckCeiling. A truly + /// stuck synchronous STA command (e.g. a dead MXAccess provider) would + /// otherwise keep CurrentCommandCorrelationId non-empty forever + /// and permanently defeat the watchdog. Once LastStaActivityUtc + /// has been stale for longer than HeartbeatStuckCeiling the + /// watchdog DOES fire StaHung even with a command in flight. + /// + [Fact] + public async Task RunAsync_WhenStaActivityIsStaleBeyondCeilingWithCommandInFlight_WritesWatchdogFault() + { + using CancellationTokenSource cancellation = new(TimeSpan.FromSeconds(5)); + using PipePair pipePair = await PipePair.CreateAsync(cancellation.Token); + FakeRuntimeSession runtime = new(); + // Stale by 5s, which exceeds the configured 200 ms ceiling — the + // watchdog must fire even with a command in flight. + runtime.SetSnapshot(new WorkerRuntimeHeartbeatSnapshot( + DateTimeOffset.UtcNow - TimeSpan.FromSeconds(5), + pendingCommandCount: 0, + outboundEventQueueDepth: 0, + lastEventSequence: 0, + currentCommandCorrelationId: "stuck-command")); + WorkerPipeSession session = CreatePipeSession( + pipePair.WorkerStream, + runtime, + new WorkerPipeSessionOptions + { + HeartbeatInterval = TimeSpan.FromMilliseconds(20), + HeartbeatGrace = TimeSpan.FromMilliseconds(50), + HeartbeatStuckCeiling = TimeSpan.FromMilliseconds(200), + }); + Task runTask = session.RunAsync(cancellation.Token); + await CompleteGatewayHandshakeAsync(pipePair, cancellation.Token); + + WorkerEnvelope fault = await ReadUntilAsync( + pipePair.GatewayReader, + WorkerEnvelope.BodyOneofCase.WorkerFault, + cancellation.Token); + + Assert.Equal(WorkerFaultCategory.StaHung, fault.WorkerFault.Category); + Assert.Contains("STA activity is stale", fault.WorkerFault.DiagnosticMessage); + + await SendShutdownAndWaitAsync(pipePair, runTask, cancellation.Token); + } + + /// + /// Worker-025 regression: RunAsync must throw a diagnostic + /// exception if the runtime-session factory returns null, rather than + /// deferring the failure to an NRE on the next dereference. + /// + [Fact] + public async Task RunAsync_WhenRuntimeSessionFactoryReturnsNull_ThrowsDiagnosticException() + { + using CancellationTokenSource cancellation = new(TimeSpan.FromSeconds(5)); + using PipePair pipePair = await PipePair.CreateAsync(cancellation.Token); + WorkerFrameProtocolOptions options = CreateOptions(); + WorkerPipeSession session = new( + new WorkerFrameReader(pipePair.WorkerStream, options), + new WorkerFrameWriter(pipePair.WorkerStream, options), + options, + () => 1234, + new WorkerPipeSessionOptions(), + () => null!); + + InvalidOperationException exception = await Assert.ThrowsAsync( + () => session.RunAsync(cancellation.Token)); + + Assert.Contains("factory returned null", exception.Message); + } + + /// + /// Worker-006 regression: when graceful shutdown times out, RunAsync + /// must still dispose the runtime session in its finally block. + /// Skipping disposal on the timed-out path leaked the STA thread and + /// the MXAccess COM object. + /// + [Fact] + public async Task RunAsync_WhenShutdownTimesOut_StillDisposesRuntimeSession() + { + using CancellationTokenSource cancellation = new(TimeSpan.FromSeconds(10)); + using PipePair pipePair = await PipePair.CreateAsync(cancellation.Token); + FakeRuntimeSession runtime = new() + { + ThrowTimeoutOnShutdown = true, + }; + WorkerPipeSession session = CreatePipeSession( + pipePair.WorkerStream, + runtime, + new WorkerPipeSessionOptions + { + HeartbeatInterval = TimeSpan.FromSeconds(1), + HeartbeatGrace = TimeSpan.FromSeconds(30), + }); + Task runTask = session.RunAsync(cancellation.Token); + await CompleteGatewayHandshakeAsync(pipePair, cancellation.Token); + + await pipePair.GatewayWriter + .WriteAsync(CreateShutdownEnvelope(), cancellation.Token); + + // Drain the gateway-side pipe (heartbeats + the shutdown-timeout + // fault) so the worker's writes never block on a full pipe buffer. + Task drainTask = DrainReaderUntilFaultedAsync(pipePair.GatewayReader, cancellation.Token); + + // RunAsync must rethrow the TimeoutException and still reach its + // finally block, which disposes the runtime session. + await Assert.ThrowsAsync(async () => await runTask); + Assert.True( + runtime.Disposed, + "RunAsync must dispose the runtime session even when shutdown times out."); + + await drainTask; + } + + private static async Task DrainReaderUntilFaultedAsync( + WorkerFrameReader reader, + CancellationToken cancellationToken) + { + try + { + while (!cancellationToken.IsCancellationRequested) + { + WorkerEnvelope envelope = await reader.ReadAsync(cancellationToken).ConfigureAwait(false); + if (envelope.BodyCase == WorkerEnvelope.BodyOneofCase.WorkerFault + && envelope.WorkerFault.Category == WorkerFaultCategory.ShutdownTimeout) + { + return; + } + } + } + catch (Exception exception) when ( + exception is OperationCanceledException + || exception is IOException + || exception is WorkerFrameProtocolException) + { + // The worker pipe closed once RunAsync completed — expected. + } + } + /// Verifies that shutdown drops late replies and sends shutdown ack. [Fact] public async Task RunAsync_WhenShutdownArrivesDuringCommand_DropsLateReplyAndWritesShutdownAck() @@ -383,16 +652,209 @@ public sealed class WorkerPipeSessionTests await pipePair.GatewayWriter .WriteAsync(CreateShutdownEnvelope(), cancellation.Token); - WorkerEnvelope firstEnvelopeAfterShutdown = await pipePair.GatewayReader - .ReadAsync(cancellation.Token); + // The first heartbeat is emitted immediately on entering the loop + // (Worker-002), so skip any interleaved heartbeats; the late fault + // must still be dropped — no WorkerFault may precede the ack. + WorkerEnvelope envelopeAfterShutdown; + do + { + envelopeAfterShutdown = await pipePair.GatewayReader.ReadAsync(cancellation.Token); + Assert.NotEqual( + WorkerEnvelope.BodyOneofCase.WorkerFault, + envelopeAfterShutdown.BodyCase); + } + while (envelopeAfterShutdown.BodyCase == WorkerEnvelope.BodyOneofCase.WorkerHeartbeat); - Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerShutdownAck, firstEnvelopeAfterShutdown.BodyCase); - Assert.Equal(ProtocolStatusCode.Ok, firstEnvelopeAfterShutdown.WorkerShutdownAck.Status.Code); + Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerShutdownAck, envelopeAfterShutdown.BodyCase); + Assert.Equal(ProtocolStatusCode.Ok, envelopeAfterShutdown.WorkerShutdownAck.Status.Code); Task completedTask = await Task.WhenAny(runTask, Task.Delay(TimeSpan.FromSeconds(2), cancellation.Token)); Assert.Same(runTask, completedTask); await runTask; } + /// + /// Worker.Tests-017 regression: the WorkerCancel branch of + /// must + /// forward the envelope's correlation id to the runtime session via + /// and keep the + /// message loop running (no fault, no exit). The handler dispatch + /// returns true (keep reading), so a subsequent + /// WorkerShutdown still produces the normal shutdown ack. + /// + [Fact] + public async Task RunAsync_WhenGatewaySendsWorkerCancel_ForwardsCorrelationIdToRuntimeSession() + { + using CancellationTokenSource cancellation = new(TimeSpan.FromSeconds(5)); + using PipePair pipePair = await PipePair.CreateAsync(cancellation.Token); + FakeRuntimeSession runtime = new(); + WorkerPipeSession session = CreatePipeSession( + pipePair.WorkerStream, + runtime, + new WorkerPipeSessionOptions + { + HeartbeatInterval = TimeSpan.FromSeconds(1), + HeartbeatGrace = TimeSpan.FromSeconds(5), + }); + Task runTask = session.RunAsync(cancellation.Token); + await CompleteGatewayHandshakeAsync(pipePair, cancellation.Token); + + await pipePair.GatewayWriter + .WriteAsync(CreateCancelEnvelope("cancel-correlation-1"), cancellation.Token); + + // The session must remain in its message loop: send a follow-up + // shutdown and observe the normal ack. If WorkerCancel had faulted + // the pipe or exited the loop, the ack would never arrive. + await SendShutdownAndWaitAsync(pipePair, runTask, cancellation.Token); + + Assert.Contains("cancel-correlation-1", runtime.CancelledCorrelationIds); + } + + /// + /// Worker.Tests-017 regression: the default: arm of + /// must + /// throw with + /// + /// when the gateway sends an envelope body that is invalid + /// post-handshake (here a second GatewayHello) and must exit + /// the message loop — + /// surfaces the exception to the caller. The message loop does not + /// emit a fault frame on this path (the handshake catch in + /// CompleteStartupHandshakeAsync is what writes faults for + /// pre-handshake protocol violations); the contract this test pins + /// is the exception type/error-code and message-loop exit. + /// + [Fact] + public async Task RunAsync_WhenGatewaySendsUnexpectedEnvelopeBodyAfterHandshake_ThrowsAndExitsMessageLoop() + { + using CancellationTokenSource cancellation = new(TimeSpan.FromSeconds(10)); + using PipePair pipePair = await PipePair.CreateAsync(cancellation.Token); + FakeRuntimeSession runtime = new(); + // Use a long heartbeat interval so no heartbeat frame fires during + // the test window. With no heartbeats and no fault frame written on + // the unexpected-body path, the gateway pipe receives nothing after + // the handshake — no drain task is needed. + WorkerPipeSession session = CreatePipeSession( + pipePair.WorkerStream, + runtime, + new WorkerPipeSessionOptions + { + HeartbeatInterval = TimeSpan.FromSeconds(30), + HeartbeatGrace = TimeSpan.FromSeconds(60), + }); + Task runTask = session.RunAsync(cancellation.Token); + await CompleteGatewayHandshakeAsync(pipePair, cancellation.Token); + + // Send a second GatewayHello — valid envelope, invalid for the + // post-handshake state, so DispatchGatewayEnvelopeAsync falls to + // the default arm. + await pipePair.GatewayWriter + .WriteAsync(CreateGatewayHelloEnvelope(), cancellation.Token); + + WorkerFrameProtocolException exception = + await Assert.ThrowsAsync(async () => await runTask); + Assert.Equal(WorkerFrameProtocolErrorCode.UnexpectedEnvelopeBody, exception.ErrorCode); + } + + /// + /// Worker-002 regression: the first heartbeat must be emitted + /// immediately on entering the heartbeat loop, not after a full + /// HeartbeatInterval. A long interval is configured so a delay-first + /// loop would fail to deliver a heartbeat inside the assertion window. + /// + [Fact] + public async Task RunAsync_SendsFirstHeartbeatImmediatelyOnEnteringLoop() + { + using CancellationTokenSource cancellation = new(TimeSpan.FromSeconds(10)); + using PipePair pipePair = await PipePair.CreateAsync(cancellation.Token); + FakeRuntimeSession runtime = new(); + WorkerPipeSession session = CreatePipeSession( + pipePair.WorkerStream, + runtime, + new WorkerPipeSessionOptions + { + // A deliberately long interval: a delay-before-first-beat + // loop would not produce a heartbeat for 30s. + HeartbeatInterval = TimeSpan.FromSeconds(30), + HeartbeatGrace = TimeSpan.FromSeconds(60), + }); + Task runTask = session.RunAsync(cancellation.Token); + await CompleteGatewayHandshakeAsync(pipePair, cancellation.Token); + + DateTimeOffset start = DateTimeOffset.UtcNow; + using CancellationTokenSource heartbeatWait = CancellationTokenSource + .CreateLinkedTokenSource(cancellation.Token); + heartbeatWait.CancelAfter(TimeSpan.FromSeconds(5)); + WorkerEnvelope heartbeat = await ReadUntilAsync( + pipePair.GatewayReader, + WorkerEnvelope.BodyOneofCase.WorkerHeartbeat, + heartbeatWait.Token); + TimeSpan elapsed = DateTimeOffset.UtcNow - start; + + Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerHeartbeat, heartbeat.BodyCase); + Assert.True( + elapsed < TimeSpan.FromSeconds(5), + $"First heartbeat took {elapsed}, expected well under the 30s interval."); + + await SendShutdownAndWaitAsync(pipePair, runTask, cancellation.Token); + } + + /// + /// Worker-003 regression: when a command completes after the worker + /// has transitioned out of a command-serving state, the dropped + /// reply must be logged with a diagnostic rather than discarded + /// silently, so a stuck gateway correlation wait can be traced. + /// + [Fact] + public async Task RunAsync_WhenReplyIsDroppedAfterShutdown_LogsDiagnostic() + { + using CancellationTokenSource cancellation = new(TimeSpan.FromSeconds(10)); + using PipePair pipePair = await PipePair.CreateAsync(cancellation.Token); + FakeRuntimeSession runtime = new() + { + BlockDispatch = true, + }; + RecordingWorkerLogger logger = new(); + WorkerFrameProtocolOptions options = CreateOptions(); + WorkerPipeSession session = new( + new WorkerFrameReader(pipePair.WorkerStream, options), + new WorkerFrameWriter(pipePair.WorkerStream, options), + options, + () => 1234, + new WorkerPipeSessionOptions + { + HeartbeatInterval = TimeSpan.FromSeconds(1), + HeartbeatGrace = TimeSpan.FromSeconds(5), + }, + () => runtime, + logger); + Task runTask = session.RunAsync(cancellation.Token); + await CompleteGatewayHandshakeAsync(pipePair, cancellation.Token); + + await pipePair.GatewayWriter.WriteAsync( + CreateCommandEnvelope("command-dropped-after-shutdown"), + cancellation.Token); + Assert.True(runtime.DispatchStarted.Wait(TimeSpan.FromSeconds(2))); + + await pipePair.GatewayWriter + .WriteAsync(CreateShutdownEnvelope(), cancellation.Token); + + WorkerEnvelope shutdownAck = await ReadUntilAsync( + pipePair.GatewayReader, + WorkerEnvelope.BodyOneofCase.WorkerShutdownAck, + cancellation.Token); + Assert.Equal(ProtocolStatusCode.Ok, shutdownAck.WorkerShutdownAck.Status.Code); + + Task completedTask = await Task.WhenAny(runTask, Task.Delay(TimeSpan.FromSeconds(3), cancellation.Token)); + Assert.Same(runTask, completedTask); + await runTask; + + Assert.Contains( + logger.Events, + entry => entry.EventName == "WorkerCommandResultDropped" + && entry.Fields.TryGetValue("correlation_id", out object? correlationId) + && (string?)correlationId == "command-dropped-after-shutdown"); + } + private static WorkerPipeSession CreateSession( Stream inbound, Stream outbound, @@ -428,15 +890,23 @@ public sealed class WorkerPipeSessionTests Nonce); } + // Inbound-envelope sequence numbers below are documentation-only: the + // worker has no inbound monotonicity check, so the literal values do + // not affect dispatch. Each helper exposes a sequence parameter + // (default = position in the typical Hello/Command/Cancel/Shutdown + // ordering) so a multi-frame test that interleaves the helpers can + // assign monotonically increasing values and produce a wire trace + // that reads in ascending order — see Worker.Tests-030. private static WorkerEnvelope CreateGatewayHelloEnvelope( string nonce = Nonce, - uint supportedProtocolVersion = GatewayContractInfo.WorkerProtocolVersion) + uint supportedProtocolVersion = GatewayContractInfo.WorkerProtocolVersion, + ulong sequence = 1) { return new WorkerEnvelope { ProtocolVersion = GatewayContractInfo.WorkerProtocolVersion, SessionId = SessionId, - Sequence = 1, + Sequence = sequence, GatewayHello = new GatewayHello { SupportedProtocolVersion = supportedProtocolVersion, @@ -446,13 +916,13 @@ public sealed class WorkerPipeSessionTests }; } - private static WorkerEnvelope CreateCommandEnvelope(string correlationId) + private static WorkerEnvelope CreateCommandEnvelope(string correlationId, ulong sequence = 2) { return new WorkerEnvelope { ProtocolVersion = GatewayContractInfo.WorkerProtocolVersion, SessionId = SessionId, - Sequence = 2, + Sequence = sequence, CorrelationId = correlationId, WorkerCommand = new WorkerCommand { @@ -469,13 +939,28 @@ public sealed class WorkerPipeSessionTests }; } - private static WorkerEnvelope CreateShutdownEnvelope() + private static WorkerEnvelope CreateCancelEnvelope(string correlationId, ulong sequence = 2) { return new WorkerEnvelope { ProtocolVersion = GatewayContractInfo.WorkerProtocolVersion, SessionId = SessionId, - Sequence = 3, + Sequence = sequence, + CorrelationId = correlationId, + WorkerCancel = new WorkerCancel + { + Reason = "test-cancel", + }, + }; + } + + private static WorkerEnvelope CreateShutdownEnvelope(ulong sequence = 3) + { + return new WorkerEnvelope + { + ProtocolVersion = GatewayContractInfo.WorkerProtocolVersion, + SessionId = SessionId, + Sequence = sequence, WorkerShutdown = new WorkerShutdown { GracePeriod = Duration.FromTimeSpan(TimeSpan.FromSeconds(1)), @@ -536,15 +1021,6 @@ public sealed class WorkerPipeSessionTests await runTask.ConfigureAwait(false); } - private static async Task ThrowIfCompletedAsync(Task task) - { - await Task.Delay(TimeSpan.FromMilliseconds(100)); - if (task.IsCompleted) - { - await task; - } - } - /// Reads frames until one matching the expected body type is found. /// Frame reader. /// Expected body case. @@ -600,207 +1076,66 @@ public sealed class WorkerPipeSessionTests return envelopes.ToArray(); } - private static byte[] CreateFrame(byte[] payload) + private sealed class RecordingWorkerLogger : ZB.MOM.WW.MxGateway.Worker.Bootstrap.IWorkerLogger { - byte[] frame = new byte[sizeof(uint) + payload.Length]; - WriteUInt32LittleEndian(frame, (uint)payload.Length); - payload.CopyTo(frame, sizeof(uint)); - - return frame; - } - - private static void WriteUInt32LittleEndian( - byte[] buffer, - uint value) - { - buffer[0] = (byte)value; - buffer[1] = (byte)(value >> 8); - buffer[2] = (byte)(value >> 16); - buffer[3] = (byte)(value >> 24); - } - - private sealed class FakeRuntimeSession : IWorkerRuntimeSession - { - private readonly ManualResetEventSlim releaseDispatch = new(false); private readonly object gate = new(); - private readonly Queue events = new(); - private WorkerRuntimeHeartbeatSnapshot snapshot = new( - DateTimeOffset.UtcNow, - pendingCommandCount: 0, - outboundEventQueueDepth: 0, - lastEventSequence: 0, - currentCommandCorrelationId: string.Empty); + private readonly List events = new(); - /// Gets the event signaled when dispatch begins. - public ManualResetEventSlim DispatchStarted { get; } = new(false); - - /// Blocks dispatch execution until explicitly released. - public bool BlockDispatch { get; set; } - - /// Gets or sets whether to throw an exception after dispatch is released. - public bool ThrowAfterDispatchReleased { get; set; } - - /// Starts the worker session with the given session ID and process ID. - /// The session identifier. - /// The worker process ID. - /// Cancellation token. - /// Worker ready response. - public Task StartAsync( - string sessionId, - int workerProcessId, - CancellationToken cancellationToken = default) + /// Gets a snapshot of the recorded log entries. + public IReadOnlyList Events { - return Task.FromResult(new WorkerReady + get { - WorkerProcessId = workerProcessId, - MxaccessProgid = MxGateway.Worker.MxAccess.MxAccessInteropInfo.ProgId, - MxaccessClsid = MxGateway.Worker.MxAccess.MxAccessInteropInfo.Clsid, - ReadyTimestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), - }); - } - - /// Dispatches a command to the STA thread. - /// The command to dispatch. - /// The command reply. - public Task DispatchAsync(StaCommand command) - { - return Task.Run( - () => + lock (gate) { - SetSnapshot(new WorkerRuntimeHeartbeatSnapshot( - DateTimeOffset.UtcNow, - pendingCommandCount: 0, - outboundEventQueueDepth: 0, - lastEventSequence: 0, - command.CorrelationId)); - DispatchStarted.Set(); - - if (BlockDispatch) - { - releaseDispatch.Wait(TimeSpan.FromSeconds(5)); - } - - SetSnapshot(new WorkerRuntimeHeartbeatSnapshot( - DateTimeOffset.UtcNow, - pendingCommandCount: 0, - outboundEventQueueDepth: 0, - lastEventSequence: 0, - currentCommandCorrelationId: string.Empty)); - - if (ThrowAfterDispatchReleased) - { - throw new InvalidOperationException("Command failed after shutdown started."); - } - - return new MxCommandReply - { - SessionId = command.SessionId, - CorrelationId = command.CorrelationId, - Kind = command.Kind, - ProtocolStatus = new ProtocolStatus - { - Code = ProtocolStatusCode.Ok, - Message = "OK", - }, - }; - }); - } - - /// Captures current heartbeat snapshot. - /// Current runtime heartbeat snapshot. - public WorkerRuntimeHeartbeatSnapshot CaptureHeartbeat() - { - lock (gate) - { - return snapshot; - } - } - - /// Drains queued events up to the specified limit. - /// Maximum events to drain; 0 drains all. - /// The drained events. - public IReadOnlyList DrainEvents(uint maxEvents) - { - lock (gate) - { - int drainCount = maxEvents == 0 - ? events.Count - : Math.Min(events.Count, checked((int)Math.Min(maxEvents, int.MaxValue))); - List drained = new(drainCount); - for (int index = 0; index < drainCount; index++) - { - drained.Add(events.Dequeue()); + return new List(events); } - - return drained; } } - /// Drains a pending fault if any. - /// Pending fault or null. - public WorkerFault? DrainFault() + /// Records an informational log event. + public void Information(string eventName, IReadOnlyDictionary fields) { - return null; + Record(eventName, fields); } - /// Cancels command by correlation ID. - /// The command correlation ID. - /// True if cancelled; false otherwise. - public bool CancelCommand(string correlationId) + /// Records an error log event. + public void Error(string eventName, IReadOnlyDictionary fields) { - return false; + Record(eventName, fields); } - /// Requests graceful shutdown. - public void RequestShutdown() + private void Record(string eventName, IReadOnlyDictionary fields) { - releaseDispatch.Set(); - } + Dictionary copy = new(); + foreach (KeyValuePair field in fields) + { + copy[field.Key] = field.Value; + } - /// Shuts down gracefully within the specified timeout. - /// Shutdown timeout period. - /// Cancellation token. - /// Shutdown result. - public Task ShutdownGracefullyAsync( - TimeSpan timeout, - CancellationToken cancellationToken = default) - { - releaseDispatch.Set(); - return Task.FromResult(new MxAccessShutdownResult(Array.Empty())); - } - - /// Releases a blocked dispatch. - public void ReleaseDispatch() - { - releaseDispatch.Set(); - } - - /// Sets the current heartbeat snapshot. - /// The snapshot to set. - public void SetSnapshot(WorkerRuntimeHeartbeatSnapshot value) - { lock (gate) { - snapshot = value; + events.Add(new LogEntry(eventName, copy)); } } - /// Enqueues a worker event to be drained. - /// The event to enqueue. - public void EnqueueEvent(WorkerEvent workerEvent) + /// A single recorded log entry. + public sealed class LogEntry { - lock (gate) + /// Initializes a recorded log entry. + /// The log event name. + /// The log event fields. + public LogEntry(string eventName, IReadOnlyDictionary fields) { - events.Enqueue(workerEvent); + EventName = eventName; + Fields = fields; } - } - /// Disposes resources. - public void Dispose() - { - releaseDispatch.Set(); - releaseDispatch.Dispose(); - DispatchStarted.Dispose(); + /// Gets the log event name. + public string EventName { get; } + + /// Gets the log event fields. + public IReadOnlyDictionary Fields { get; } } } diff --git a/src/MxGateway.Worker.Tests/MxAccess/AlarmCommandExecutorTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandExecutorTests.cs similarity index 73% rename from src/MxGateway.Worker.Tests/MxAccess/AlarmCommandExecutorTests.cs rename to src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandExecutorTests.cs index cd54467..b20fd66 100644 --- a/src/MxGateway.Worker.Tests/MxAccess/AlarmCommandExecutorTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandExecutorTests.cs @@ -1,10 +1,11 @@ using System; using System.Collections.Generic; -using MxGateway.Contracts.Proto; -using MxGateway.Worker.MxAccess; -using MxGateway.Worker.Sta; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Worker.MxAccess; +using ZB.MOM.WW.MxGateway.Worker.Sta; +using ZB.MOM.WW.MxGateway.Worker.Tests.TestSupport; -namespace MxGateway.Worker.Tests.MxAccess; +namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess; /// /// Verifies that the four new alarm values @@ -22,7 +23,7 @@ public sealed class AlarmCommandExecutorTests private const string CorrelationId = "C"; [Fact] - public void SubscribeAlarms_routes_to_handler_and_returns_ok() + public void SubscribeAlarms_WithHandler_RoutesToHandlerAndReturnsOk() { FakeAlarmHandler handler = new FakeAlarmHandler(); MxAccessCommandExecutor executor = NewExecutor(handler); @@ -46,7 +47,7 @@ public sealed class AlarmCommandExecutorTests } [Fact] - public void SubscribeAlarms_without_handler_returns_invalid_request() + public void SubscribeAlarms_WithoutHandler_ReturnsInvalidRequest() { MxAccessCommandExecutor executor = NewExecutor(alarmHandler: null); @@ -67,7 +68,7 @@ public sealed class AlarmCommandExecutorTests } [Fact] - public void SubscribeAlarms_with_empty_expression_returns_invalid_request() + public void SubscribeAlarms_WithEmptyExpression_ReturnsInvalidRequest() { MxAccessCommandExecutor executor = NewExecutor(new FakeAlarmHandler()); @@ -88,7 +89,7 @@ public sealed class AlarmCommandExecutorTests } [Fact] - public void AcknowledgeAlarm_routes_native_status_into_hresult_and_payload() + public void AcknowledgeAlarm_WithHandler_RoutesNativeStatusIntoHresultAndPayload() { FakeAlarmHandler handler = new FakeAlarmHandler { AcknowledgeReturn = 0 }; MxAccessCommandExecutor executor = NewExecutor(handler); @@ -121,7 +122,7 @@ public sealed class AlarmCommandExecutorTests } [Fact] - public void AcknowledgeAlarm_with_invalid_guid_returns_invalid_request() + public void AcknowledgeAlarm_WithInvalidGuid_ReturnsInvalidRequest() { MxAccessCommandExecutor executor = NewExecutor(new FakeAlarmHandler()); @@ -142,7 +143,7 @@ public sealed class AlarmCommandExecutorTests } [Fact] - public void AcknowledgeAlarm_with_nonzero_native_status_carries_diagnostic() + public void AcknowledgeAlarm_WithNonzeroNativeStatus_CarriesDiagnostic() { FakeAlarmHandler handler = new FakeAlarmHandler { AcknowledgeReturn = -123 }; MxAccessCommandExecutor executor = NewExecutor(handler); @@ -165,7 +166,7 @@ public sealed class AlarmCommandExecutorTests } [Fact] - public void AcknowledgeAlarmByName_routes_tuple_to_handler() + public void AcknowledgeAlarmByName_WithHandler_RoutesTupleToHandler() { FakeAlarmHandler handler = new FakeAlarmHandler { AcknowledgeReturn = 0 }; MxAccessCommandExecutor executor = NewExecutor(handler); @@ -198,7 +199,7 @@ public sealed class AlarmCommandExecutorTests } [Fact] - public void AcknowledgeAlarmByName_with_empty_name_returns_invalid_request() + public void AcknowledgeAlarmByName_WithEmptyName_ReturnsInvalidRequest() { MxAccessCommandExecutor executor = NewExecutor(new FakeAlarmHandler()); @@ -221,7 +222,7 @@ public sealed class AlarmCommandExecutorTests } [Fact] - public void QueryActiveAlarms_returns_payload_with_snapshots() + public void QueryActiveAlarms_WithHandler_ReturnsPayloadWithSnapshots() { FakeAlarmHandler handler = new FakeAlarmHandler { @@ -253,7 +254,7 @@ public sealed class AlarmCommandExecutorTests } [Fact] - public void UnsubscribeAlarms_routes_to_handler() + public void UnsubscribeAlarms_WithHandler_RoutesToHandler() { FakeAlarmHandler handler = new FakeAlarmHandler(); MxAccessCommandExecutor executor = NewExecutor(handler); @@ -273,7 +274,7 @@ public sealed class AlarmCommandExecutorTests } [Fact] - public void UnsubscribeAlarms_without_handler_is_ok_noop() + public void UnsubscribeAlarms_WithoutHandler_IsOkNoop() { MxAccessCommandExecutor executor = NewExecutor(alarmHandler: null); @@ -291,7 +292,7 @@ public sealed class AlarmCommandExecutorTests } [Fact] - public void Acknowledge_handler_throw_returns_mxaccess_failure() + public void AcknowledgeAlarm_WhenHandlerThrows_ReturnsMxaccessFailure() { FakeAlarmHandler handler = new FakeAlarmHandler { AcknowledgeThrow = true }; MxAccessCommandExecutor executor = NewExecutor(handler); @@ -316,82 +317,18 @@ public sealed class AlarmCommandExecutorTests private static MxAccessCommandExecutor NewExecutor(IAlarmCommandHandler? alarmHandler) { // Construct an executor with a no-op data session — we only exercise - // the alarm switch arms, which never touch the data session. + // the alarm switch arms, which never touch the data session. The + // session is built through the internal MxAccessSession.CreateForTesting + // factory (exposed via [assembly: InternalsVisibleTo("ZB.MOM.WW.MxGateway.Worker.Tests")] + // on ZB.MOM.WW.MxGateway.Worker), so no reflection is needed. return new MxAccessCommandExecutor( - session: NoopMxAccessSession.Create(), - variantConverter: new MxGateway.Worker.Conversion.VariantConverter(), + session: MxAccessSession.CreateForTesting( + mxAccessServer: new NoopMxAccessServer(), + eventSink: new NoopEventSink()), + variantConverter: new ZB.MOM.WW.MxGateway.Worker.Conversion.VariantConverter(), alarmCommandHandler: alarmHandler); } - /// - /// Reflection-based helper to construct an MxAccessSession without - /// a real COM object. Only the alarm-side code paths are exercised - /// in this test class, so the session reference is never - /// dereferenced. - /// - private static class NoopMxAccessSession - { - public static MxAccessSession Create() - { - // Walk to the private constructor via reflection — the public - // factory MxAccessSession.Create(...) requires a real COM object. - System.Reflection.ConstructorInfo? ctor = typeof(MxAccessSession) - .GetConstructor( - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance, - binder: null, - types: new[] - { - typeof(object), - typeof(IMxAccessServer), - typeof(IMxAccessEventSink), - typeof(MxAccessHandleRegistry), - typeof(int), - }, - modifiers: null); - if (ctor is null) - { - throw new InvalidOperationException( - "MxAccessSession private ctor signature changed; update the test seam."); - } - return (MxAccessSession)ctor.Invoke(new object[] - { - new object(), - new NullMxAccessServer(), - new NullEventSink(), - new MxAccessHandleRegistry(), - System.Environment.CurrentManagedThreadId, - }); - } - } - - private sealed class NullMxAccessServer : IMxAccessServer - { - public int Register(string clientName) => 0; - public void Unregister(int serverHandle) { } - public int AddItem(int serverHandle, string itemDefinition) => 0; - public int AddItem2(int serverHandle, string itemDefinition, string itemContext) => 0; - public void RemoveItem(int serverHandle, int itemHandle) { } - public void Advise(int serverHandle, int itemHandle) { } - public void UnAdvise(int serverHandle, int itemHandle) { } - public void AdviseSupervisory(int serverHandle, int itemHandle) { } - public int AddBufferedItem(int serverHandle, string itemDefinition, string itemContext) => 0; - public void SetBufferedUpdateInterval(int serverHandle, int updateIntervalMilliseconds) { } - public void Suspend(int serverHandle, int itemHandle) { } - public void Activate(int serverHandle, int itemHandle) { } - public void Write(int serverHandle, int itemHandle, object value, int userId) { } - public void Write2(int serverHandle, int itemHandle, object value, object timestampValue, int userId) { } - public void WriteSecured(int serverHandle, int itemHandle, int currentUserId, int verifierUserId, object value) { } - public void WriteSecured2(int serverHandle, int itemHandle, int currentUserId, int verifierUserId, object value, object timestampValue) { } - public int AuthenticateUser(string userName, string password) => 0; - public int ArchestrAUserToId(string userName) => 0; - } - - private sealed class NullEventSink : IMxAccessEventSink - { - public void Attach(object mxAccessComObject, string sessionId) { } - public void Detach() { } - } - private sealed class FakeAlarmHandler : IAlarmCommandHandler { public string? LastSubscription { get; private set; } @@ -447,6 +384,13 @@ public sealed class AlarmCommandExecutorTests return QueryResult; } + public int PollCount { get; private set; } + + public void PollOnce() + { + PollCount++; + } + public void Dispose() { } } } diff --git a/src/MxGateway.Worker.Tests/MxAccess/AlarmCommandHandlerTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandHandlerTests.cs similarity index 58% rename from src/MxGateway.Worker.Tests/MxAccess/AlarmCommandHandlerTests.cs rename to src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandHandlerTests.cs index decd4b8..7baff26 100644 --- a/src/MxGateway.Worker.Tests/MxAccess/AlarmCommandHandlerTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandHandlerTests.cs @@ -1,9 +1,9 @@ using System; using System.Collections.Generic; -using MxGateway.Contracts.Proto; -using MxGateway.Worker.MxAccess; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Worker.MxAccess; -namespace MxGateway.Worker.Tests.MxAccess; +namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess; /// /// Unit tests for the per-session alarm command router. Uses a fake @@ -13,7 +13,7 @@ namespace MxGateway.Worker.Tests.MxAccess; public sealed class AlarmCommandHandlerTests { [Fact] - public void Subscribe_creates_consumer_and_calls_subscribe() + public void Subscribe_WhenNotYetSubscribed_CreatesConsumerAndCallsSubscribe() { FakeConsumer consumer = new FakeConsumer(); AlarmCommandHandler handler = new AlarmCommandHandler( @@ -27,7 +27,7 @@ public sealed class AlarmCommandHandlerTests } [Fact] - public void Second_subscribe_without_unsubscribe_throws() + public void Subscribe_WhenAlreadySubscribed_Throws() { FakeConsumer consumer = new FakeConsumer(); AlarmCommandHandler handler = new AlarmCommandHandler( @@ -39,22 +39,36 @@ public sealed class AlarmCommandHandlerTests () => handler.Subscribe(@"\\HOST\Galaxy!B", "s1")); } + /// + /// Worker.Tests-024: pins both the disposal contract and the + /// origin of the propagated exception. The fake throws + /// InvalidOperationException("simulated wnwrap subscribe failure") + /// from Subscribe; the handler must propagate that exact + /// exception (not swallow it and rethrow its own) and dispose the + /// just-constructed consumer so a retry can build a fresh one. + /// Pinning the message guards against a regression where the + /// handler throws a different + /// (for example its own "already subscribed" guard) and the + /// disposal assertion alone would still pass while hiding the + /// real swallow. + /// [Fact] - public void Subscribe_disposes_consumer_when_underlying_subscribe_throws() + public void Subscribe_WhenUnderlyingSubscribeThrows_DisposesConsumer() { FakeConsumer consumer = new FakeConsumer { ThrowOnSubscribe = true }; AlarmCommandHandler handler = new AlarmCommandHandler( new MxAccessEventQueue(), () => consumer); - Assert.Throws( + InvalidOperationException exception = Assert.Throws( () => handler.Subscribe(@"\\HOST\Galaxy!A", "s1")); + Assert.Contains("simulated wnwrap subscribe failure", exception.Message); Assert.False(handler.IsSubscribed); Assert.True(consumer.Disposed); } [Fact] - public void Unsubscribe_disposes_consumer_and_clears_state() + public void Unsubscribe_WhenSubscribed_DisposesConsumerAndClearsState() { FakeConsumer consumer = new FakeConsumer(); AlarmCommandHandler handler = new AlarmCommandHandler( @@ -69,7 +83,7 @@ public sealed class AlarmCommandHandlerTests } [Fact] - public void Unsubscribe_without_prior_subscribe_is_noop() + public void Unsubscribe_WithoutPriorSubscribe_IsNoop() { AlarmCommandHandler handler = new AlarmCommandHandler( new MxAccessEventQueue(), @@ -79,7 +93,7 @@ public sealed class AlarmCommandHandlerTests } [Fact] - public void Acknowledge_forwards_to_consumer_with_full_operator_identity() + public void Acknowledge_WhenSubscribed_ForwardsToConsumerWithFullOperatorIdentity() { FakeConsumer consumer = new FakeConsumer { AcknowledgeReturn = 0 }; AlarmCommandHandler handler = new AlarmCommandHandler( @@ -96,7 +110,7 @@ public sealed class AlarmCommandHandlerTests } [Fact] - public void Acknowledge_before_subscribe_throws_invalid_op() + public void Acknowledge_BeforeSubscribe_ThrowsInvalidOperation() { AlarmCommandHandler handler = new AlarmCommandHandler( new MxAccessEventQueue(), @@ -107,7 +121,7 @@ public sealed class AlarmCommandHandlerTests } [Fact] - public void QueryActive_returns_mapped_proto_snapshots() + public void QueryActive_WhenConsumerHasAlarms_ReturnsMappedProtoSnapshots() { FakeConsumer consumer = new FakeConsumer { @@ -138,7 +152,7 @@ public sealed class AlarmCommandHandlerTests } [Fact] - public void QueryActive_filters_by_prefix() + public void QueryActive_WithPrefix_FiltersByPrefix() { FakeConsumer consumer = new FakeConsumer { @@ -160,7 +174,7 @@ public sealed class AlarmCommandHandlerTests } [Fact] - public void Dispose_unsubscribes_and_disposes_consumer() + public void Dispose_WhenSubscribed_UnsubscribesAndDisposesConsumer() { FakeConsumer consumer = new FakeConsumer(); AlarmCommandHandler handler = new AlarmCommandHandler( @@ -175,6 +189,81 @@ public sealed class AlarmCommandHandlerTests () => handler.Subscribe("x", "y")); } + /// + /// Worker-024 regression: every method that touches the underlying + /// must invoke the configured + /// STA-affinity guard. A guard that throws (simulating an off-STA + /// call) must propagate from every command-path entry point. + /// + [Fact] + public void EveryCommandPathEntry_InvokesThreadAffinityGuard() + { + FakeConsumer consumer = new FakeConsumer(); + int guardInvocations = 0; + AlarmCommandHandler handler = new AlarmCommandHandler( + new MxAccessEventQueue(), + () => consumer, + () => guardInvocations++); + + // Subscribe is the first call — guard must run before the consumer + // factory is invoked. We tally invocation counts after each call so + // that a missed guard surfaces as the diagnostic count, not a generic + // "Subscribe should have failed". + handler.Subscribe(@"\\HOST\Galaxy!A", "s1"); + Assert.Equal(1, guardInvocations); + + handler.Acknowledge(Guid.NewGuid(), "c", "u", "n", "d", "F"); + Assert.Equal(2, guardInvocations); + + handler.AcknowledgeByName("a", "p", "g", "c", "u", "n", "d", "F"); + Assert.Equal(3, guardInvocations); + + _ = handler.QueryActive(null); + Assert.Equal(4, guardInvocations); + + handler.PollOnce(); + Assert.Equal(5, guardInvocations); + + handler.Unsubscribe(); + Assert.Equal(6, guardInvocations); + } + + /// + /// Worker-024 regression: a guard that throws must propagate from + /// every command-path entry point — proving the guard is not + /// swallowed by an inner try/catch. + /// + [Fact] + public void EveryCommandPathEntry_PropagatesAffinityGuardException() + { + FakeConsumer consumer = new FakeConsumer(); + AlarmCommandHandler handler = new AlarmCommandHandler( + new MxAccessEventQueue(), + () => consumer, + threadAffinityCheck: () => + throw new InvalidOperationException("off-STA")); + + // Subscribe: guard runs before the dispatcher is constructed. + Assert.Throws( + () => handler.Subscribe(@"\\HOST\Galaxy!A", "s1")); + + // To exercise the other entry points we need a subscribed handler. + // Construct a parallel handler with a passing guard, then swap in a + // throwing one — but the existing handler is the simpler vehicle: + // re-build the handler with the guard initially silent, subscribe, + // then verify each remaining entry by passing a guard that throws + // through a second handler instance — actually the cleaner way is to + // assert each independently with a fresh handler. Below we reuse + // the same throwing handler for the not-subscribed-yet entries: + Assert.Throws( + () => handler.Acknowledge(Guid.Empty, "", "", "", "", "")); + Assert.Throws( + () => handler.AcknowledgeByName("", "", "", "", "", "", "", "")); + Assert.Throws(() => handler.QueryActive(null)); + Assert.Throws(() => handler.PollOnce()); + Assert.Throws(() => handler.Unsubscribe()); + } + private static MxAlarmSnapshotRecord NewRecord(string provider, string group, string tag) { return new MxAlarmSnapshotRecord @@ -236,6 +325,13 @@ public sealed class AlarmCommandHandlerTests public IReadOnlyList SnapshotActiveAlarms() => SnapshotResult; + public int PollCount { get; private set; } + + public void PollOnce() + { + PollCount++; + } + public void Dispose() { Disposed = true; diff --git a/src/MxGateway.Worker.Tests/MxAccess/AlarmDispatcherTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmDispatcherTests.cs similarity index 93% rename from src/MxGateway.Worker.Tests/MxAccess/AlarmDispatcherTests.cs rename to src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmDispatcherTests.cs index 6b3e03d..55a0b65 100644 --- a/src/MxGateway.Worker.Tests/MxAccess/AlarmDispatcherTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmDispatcherTests.cs @@ -1,9 +1,9 @@ using System; using System.Collections.Generic; -using MxGateway.Contracts.Proto; -using MxGateway.Worker.MxAccess; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Worker.MxAccess; -namespace MxGateway.Worker.Tests.MxAccess; +namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess; /// /// Unit tests for the in-process A.3 dispatcher: prove that @@ -18,7 +18,7 @@ public sealed class AlarmDispatcherTests private const string SessionId = "session-001"; [Fact] - public void TransitionEvent_lands_in_queue_with_mapped_fields() + public void OnTransition_WhenAlarmTransitionRaised_LandsInQueueWithMappedFields() { FakeAlarmConsumer consumer = new FakeAlarmConsumer(); MxAccessEventQueue queue = new MxAccessEventQueue(); @@ -64,7 +64,7 @@ public sealed class AlarmDispatcherTests } [Fact] - public void Consecutive_unchanged_state_does_not_emit_a_transition() + public void OnTransition_WithConsecutiveUnchangedState_DoesNotEmitTransition() { // Mapper.MapTransition returns Unspecified when the state didn't // change; the dispatcher should drop the event before queueing. @@ -94,7 +94,7 @@ public sealed class AlarmDispatcherTests [InlineData(MxAlarmStateKind.UnackAlm, MxAlarmStateKind.AckAlm, AlarmTransitionKind.Acknowledge)] [InlineData(MxAlarmStateKind.UnackAlm, MxAlarmStateKind.UnackRtn, AlarmTransitionKind.Clear)] [InlineData(MxAlarmStateKind.UnackRtn, MxAlarmStateKind.UnackAlm, AlarmTransitionKind.Raise)] - public void Transition_kind_follows_state_table( + public void MapTransition_ForEachStatePair_FollowsStateTable( MxAlarmStateKind previous, MxAlarmStateKind current, AlarmTransitionKind expected) @@ -123,7 +123,7 @@ public sealed class AlarmDispatcherTests } [Fact] - public void Subscribe_forwards_to_consumer() + public void Subscribe_WhenInvoked_ForwardsToConsumer() { FakeAlarmConsumer consumer = new FakeAlarmConsumer(); using AlarmDispatcher dispatcher = new AlarmDispatcher( @@ -136,7 +136,7 @@ public sealed class AlarmDispatcherTests } [Fact] - public void Acknowledge_forwards_to_consumer_with_full_operator_identity() + public void Acknowledge_WhenInvoked_ForwardsToConsumerWithFullOperatorIdentity() { FakeAlarmConsumer consumer = new FakeAlarmConsumer(); consumer.AcknowledgeReturn = 0; @@ -159,7 +159,7 @@ public sealed class AlarmDispatcherTests } [Fact] - public void AcknowledgeByName_forwards_to_consumer_with_full_tuple() + public void AcknowledgeByName_WhenInvoked_ForwardsToConsumerWithFullTuple() { FakeAlarmConsumer consumer = new FakeAlarmConsumer { AcknowledgeReturn = 0 }; using AlarmDispatcher dispatcher = new AlarmDispatcher( @@ -185,7 +185,7 @@ public sealed class AlarmDispatcherTests } [Fact] - public void SnapshotActiveAlarms_maps_records_to_protos() + public void SnapshotActiveAlarms_WhenConsumerHasRecords_MapsRecordsToProtos() { FakeAlarmConsumer consumer = new FakeAlarmConsumer(); DateTime ts = new DateTime(2026, 5, 1, 17, 26, 14, 709, DateTimeKind.Utc); @@ -233,7 +233,7 @@ public sealed class AlarmDispatcherTests } [Fact] - public void Dispose_unsubscribes_handler_and_disposes_consumer() + public void Dispose_WhenSubscribed_UnsubscribesHandlerAndDisposesConsumer() { FakeAlarmConsumer consumer = new FakeAlarmConsumer(); MxAccessEventQueue queue = new MxAccessEventQueue(); @@ -318,6 +318,13 @@ public sealed class AlarmDispatcherTests return SnapshotResult; } + public int PollCount { get; private set; } + + public void PollOnce() + { + PollCount++; + } + public void Dispose() { Disposed = true; diff --git a/src/MxGateway.Worker.Tests/MxAccess/AlarmRecordTransitionMapperTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmRecordTransitionMapperTests.cs similarity index 86% rename from src/MxGateway.Worker.Tests/MxAccess/AlarmRecordTransitionMapperTests.cs rename to src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmRecordTransitionMapperTests.cs index 8e108e4..1f223c8 100644 --- a/src/MxGateway.Worker.Tests/MxAccess/AlarmRecordTransitionMapperTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmRecordTransitionMapperTests.cs @@ -1,8 +1,8 @@ using System; -using MxGateway.Contracts.Proto; -using MxGateway.Worker.MxAccess; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Worker.MxAccess; -namespace MxGateway.Worker.Tests.MxAccess; +namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess; /// /// Pins the pure helpers used to translate AVEVA's wnwrapConsumer XML @@ -15,7 +15,7 @@ namespace MxGateway.Worker.Tests.MxAccess; public sealed class AlarmRecordTransitionMapperTests { [Fact] - public void ComposeFullReference_uses_provider_bang_group_dot_name_format() + public void ComposeFullReference_WithProviderAndGroup_UsesProviderBangGroupDotNameFormat() { string reference = AlarmRecordTransitionMapper.ComposeFullReference( providerName: "GalaxyAlarmProvider", @@ -25,7 +25,7 @@ public sealed class AlarmRecordTransitionMapperTests } [Fact] - public void ComposeFullReference_drops_provider_when_empty() + public void ComposeFullReference_WithEmptyProvider_DropsProvider() { string reference = AlarmRecordTransitionMapper.ComposeFullReference( providerName: null, groupName: "Tank01", alarmName: "Level.HiHi"); @@ -33,7 +33,7 @@ public sealed class AlarmRecordTransitionMapperTests } [Fact] - public void ComposeFullReference_drops_group_when_empty() + public void ComposeFullReference_WithEmptyGroup_DropsGroup() { string reference = AlarmRecordTransitionMapper.ComposeFullReference( providerName: "GalaxyAlarmProvider", groupName: null, alarmName: "GlobalAlarm"); @@ -41,7 +41,7 @@ public sealed class AlarmRecordTransitionMapperTests } [Fact] - public void ComposeFullReference_returns_alarm_name_when_provider_and_group_empty() + public void ComposeFullReference_WithEmptyProviderAndGroup_ReturnsAlarmName() { string reference = AlarmRecordTransitionMapper.ComposeFullReference( providerName: null, groupName: null, alarmName: "Bare"); @@ -58,7 +58,7 @@ public sealed class AlarmRecordTransitionMapperTests [InlineData("UNKNOWN", MxAlarmStateKind.Unspecified)] [InlineData("", MxAlarmStateKind.Unspecified)] [InlineData(null, MxAlarmStateKind.Unspecified)] - public void ParseStateKind_decodes_state_strings(string? input, MxAlarmStateKind expected) + public void ParseStateKind_ForEachStateString_DecodesStateKind(string? input, MxAlarmStateKind expected) { Assert.Equal(expected, AlarmRecordTransitionMapper.ParseStateKind(input)); } @@ -83,7 +83,7 @@ public sealed class AlarmRecordTransitionMapperTests [InlineData(MxAlarmStateKind.UnackAlm, MxAlarmStateKind.UnackAlm, AlarmTransitionKind.Unspecified)] // Current=Unspecified → Unspecified. [InlineData(MxAlarmStateKind.UnackAlm, MxAlarmStateKind.Unspecified, AlarmTransitionKind.Unspecified)] - public void MapTransition_decides_proto_kind( + public void MapTransition_ForEachStatePair_DecidesProtoKind( MxAlarmStateKind previous, MxAlarmStateKind current, AlarmTransitionKind expected) @@ -92,7 +92,7 @@ public sealed class AlarmRecordTransitionMapperTests } [Fact] - public void ParseTransitionTimestampUtc_assembles_utc_from_xml_fields() + public void ParseTransitionTimestampUtc_WithValidXmlFields_AssemblesUtc() { // Captured payload from probe (2026-05-01): EDT producer, GMTOFFSET=240, DSTADJUST=0. // Local 13:26:14.709 + 240 minutes (4h) = 17:26:14.709 UTC. @@ -110,7 +110,7 @@ public sealed class AlarmRecordTransitionMapperTests } [Fact] - public void ParseTransitionTimestampUtc_returns_min_value_on_unparseable_inputs() + public void ParseTransitionTimestampUtc_WithUnparseableInputs_ReturnsMinValue() { Assert.Equal(DateTime.MinValue, AlarmRecordTransitionMapper.ParseTransitionTimestampUtc(null, null, 0, 0)); diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessBaseEventSinkTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessBaseEventSinkTests.cs new file mode 100644 index 0000000..935e9d2 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessBaseEventSinkTests.cs @@ -0,0 +1,214 @@ +using System; +using ArchestrA.MxAccess; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Worker.MxAccess; +using ComMxDataType = ArchestrA.MxAccess.MxDataType; + +namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess; + +/// +/// Integrated tests for : drive an MXAccess COM +/// event through the real sink → → +/// pipeline and assert a correctly-converted +/// protobuf lands in the queue. +/// +/// +/// Boundary: the COM-side += subscription performed in +/// casts the supplied object to the +/// sealed LMXProxyServerClass RCW and cannot run without a live MXAccess COM +/// object, so Attach/Detach are not exercised here. The event +/// handlers themselves (OnDataChange, OnWriteComplete, +/// OperationComplete, OnBufferedDataChange) are the exact delegate +/// targets the COM runtime invokes; calling them directly reproduces an STA-thread +/// COM callback and exercises the genuine conversion + enqueue path. The +/// sessionId normally set by Attach defaults to empty here, which the +/// assertions account for. The COM-event-conversion fault branch is left to +/// and the queue's own fault tests. +/// +public sealed class MxAccessBaseEventSinkTests +{ + /// + /// Verifies that an OnDataChange COM callback converts to a protobuf event and lands in the queue. + /// + [Fact] + public void OnDataChange_ComCallback_ConvertedEventLandsInQueue() + { + MxAccessEventQueue queue = new(); + MxAccessBaseEventSink sink = new(queue, new MxAccessEventMapper()); + DateTime timestamp = new(2026, 5, 18, 9, 15, 0, DateTimeKind.Utc); + MXSTATUS_PROXY[] statuses = Array.Empty(); + + sink.OnDataChange( + hLMXServerHandle: 7, + phItemHandle: 21, + pvItemValue: 1234, + pwItemQuality: 192, + pftItemTimeStamp: timestamp, + ref statuses); + + Assert.Equal(1, queue.Count); + Assert.Equal(1UL, queue.LastEventSequence); + Assert.True(queue.TryDequeue(out WorkerEvent? workerEvent)); + Assert.NotNull(workerEvent); + + MxEvent mxEvent = workerEvent!.Event; + Assert.Equal(MxEventFamily.OnDataChange, mxEvent.Family); + Assert.Equal(MxEvent.BodyOneofCase.OnDataChange, mxEvent.BodyCase); + Assert.Equal(7, mxEvent.ServerHandle); + Assert.Equal(21, mxEvent.ItemHandle); + Assert.Equal(1234, mxEvent.Value.Int32Value); + Assert.Equal(192, mxEvent.Quality); + Assert.Equal(timestamp, mxEvent.SourceTimestamp.ToDateTime()); + Assert.Equal(1UL, mxEvent.WorkerSequence); + Assert.NotNull(mxEvent.WorkerTimestamp); + } + + /// + /// Verifies that an OnDataChange COM callback also writes the value into the + /// per-session value cache, so a later ReadBulk on an already-advised + /// tag can serve the cached value without re-advising. The cache update must + /// fire after the event has cleared the outbound queue — verified here by + /// checking the cache only after the queue confirms the enqueue succeeded. + /// + [Fact] + public void OnDataChange_ComCallback_PopulatesValueCache() + { + MxAccessEventQueue queue = new(); + MxAccessValueCache cache = new(); + MxAccessBaseEventSink sink = new(queue, new MxAccessEventMapper(), cache); + DateTime timestamp = new(2026, 5, 18, 9, 15, 0, DateTimeKind.Utc); + MXSTATUS_PROXY[] statuses = Array.Empty(); + + sink.OnDataChange( + hLMXServerHandle: 7, + phItemHandle: 21, + pvItemValue: 1234, + pwItemQuality: 192, + pftItemTimeStamp: timestamp, + ref statuses); + + Assert.Equal(1, queue.Count); + Assert.True(cache.TryGet(7, 21, out MxAccessValueCache.CachedValue cached)); + Assert.Equal(1UL, cached.Version); + Assert.Equal(1234, cached.Value.Int32Value); + Assert.Equal(192, cached.Quality); + Assert.Equal(timestamp, cached.SourceTimestamp.ToDateTime()); + } + + /// + /// Verifies that the sink-bound ValueCache is exposed for sharing with + /// the owning so writes and reads see the same + /// instance. + /// + [Fact] + public void ValueCache_ReturnsTheInstanceBoundAtConstruction() + { + MxAccessEventQueue queue = new(); + MxAccessValueCache cache = new(); + MxAccessBaseEventSink sink = new(queue, new MxAccessEventMapper(), cache); + + Assert.Same(cache, sink.ValueCache); + } + + /// + /// Verifies that consecutive OnDataChange callbacks land in the queue with monotonic sequences. + /// + [Fact] + public void OnDataChange_MultipleComCallbacks_QueueAssignsMonotonicSequences() + { + MxAccessEventQueue queue = new(); + MxAccessBaseEventSink sink = new(queue, new MxAccessEventMapper()); + MXSTATUS_PROXY[] statuses = Array.Empty(); + + sink.OnDataChange(1, 10, 100, 192, DateTime.UtcNow, ref statuses); + sink.OnDataChange(1, 11, 200, 192, DateTime.UtcNow, ref statuses); + sink.OnDataChange(1, 12, 300, 192, DateTime.UtcNow, ref statuses); + + Assert.Equal(3, queue.Count); + Assert.Equal(3UL, queue.LastEventSequence); + + Assert.True(queue.TryDequeue(out WorkerEvent? first)); + Assert.True(queue.TryDequeue(out WorkerEvent? second)); + Assert.True(queue.TryDequeue(out WorkerEvent? third)); + Assert.Equal(1UL, first!.Event.WorkerSequence); + Assert.Equal(2UL, second!.Event.WorkerSequence); + Assert.Equal(3UL, third!.Event.WorkerSequence); + Assert.Equal(10, first.Event.ItemHandle); + Assert.Equal(12, third.Event.ItemHandle); + } + + /// + /// Verifies that an OnWriteComplete COM callback lands in the queue with the correct family. + /// + [Fact] + public void OnWriteComplete_ComCallback_ConvertedEventLandsInQueue() + { + MxAccessEventQueue queue = new(); + MxAccessBaseEventSink sink = new(queue, new MxAccessEventMapper()); + MXSTATUS_PROXY[] statuses = Array.Empty(); + + sink.OnWriteComplete(hLMXServerHandle: 3, phItemHandle: 9, ref statuses); + + Assert.Equal(1, queue.Count); + Assert.True(queue.TryDequeue(out WorkerEvent? workerEvent)); + MxEvent mxEvent = workerEvent!.Event; + Assert.Equal(MxEventFamily.OnWriteComplete, mxEvent.Family); + Assert.Equal(MxEvent.BodyOneofCase.OnWriteComplete, mxEvent.BodyCase); + Assert.Equal(3, mxEvent.ServerHandle); + Assert.Equal(9, mxEvent.ItemHandle); + Assert.Equal(1UL, mxEvent.WorkerSequence); + } + + /// + /// Verifies that an OperationComplete COM callback lands in the queue with the correct family. + /// + [Fact] + public void OperationComplete_ComCallback_ConvertedEventLandsInQueue() + { + MxAccessEventQueue queue = new(); + MxAccessBaseEventSink sink = new(queue, new MxAccessEventMapper()); + MXSTATUS_PROXY[] statuses = Array.Empty(); + + sink.OperationComplete(hLMXServerHandle: 4, phItemHandle: 8, ref statuses); + + Assert.Equal(1, queue.Count); + Assert.True(queue.TryDequeue(out WorkerEvent? workerEvent)); + MxEvent mxEvent = workerEvent!.Event; + Assert.Equal(MxEventFamily.OperationComplete, mxEvent.Family); + Assert.Equal(MxEvent.BodyOneofCase.OperationComplete, mxEvent.BodyCase); + Assert.Equal(4, mxEvent.ServerHandle); + Assert.Equal(8, mxEvent.ItemHandle); + } + + /// + /// Verifies that an OnBufferedDataChange COM callback converts the value and lands in the queue. + /// + [Fact] + public void OnBufferedDataChange_ComCallback_ConvertedEventLandsInQueue() + { + MxAccessEventQueue queue = new(); + MxAccessBaseEventSink sink = new(queue, new MxAccessEventMapper()); + MXSTATUS_PROXY[] statuses = Array.Empty(); + + // Raw MXAccess data-type code 2 == Integer (see MxAccessEventMapper.MapMxDataType). + const int integerDataTypeCode = 2; + + sink.OnBufferedDataChange( + hLMXServerHandle: 5, + phItemHandle: 13, + dtDataType: (ComMxDataType)integerDataTypeCode, + pvItemValue: 77, + pwItemQuality: 192, + pftItemTimeStamp: DateTime.UtcNow, + ref statuses); + + Assert.Equal(1, queue.Count); + Assert.True(queue.TryDequeue(out WorkerEvent? workerEvent)); + MxEvent mxEvent = workerEvent!.Event; + Assert.Equal(MxEventFamily.OnBufferedDataChange, mxEvent.Family); + Assert.Equal(MxEvent.BodyOneofCase.OnBufferedDataChange, mxEvent.BodyCase); + Assert.Equal(5, mxEvent.ServerHandle); + Assert.Equal(13, mxEvent.ItemHandle); + Assert.Equal(integerDataTypeCode, mxEvent.OnBufferedDataChange.RawDataType); + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessComServerTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessComServerTests.cs new file mode 100644 index 0000000..ebc26c4 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessComServerTests.cs @@ -0,0 +1,159 @@ +using System; +using System.Collections.Generic; +using ZB.MOM.WW.MxGateway.Worker.MxAccess; + +namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess; + +/// +/// Worker-007 regression tests for . The +/// adapter no longer falls back to late-bound Type.InvokeMember +/// reflection: a COM object must implement either the typed +/// ILMXProxyServer COM interface family (production) or +/// directly (test fakes). +/// +public sealed class MxAccessComServerTests +{ + /// + /// A COM object implementing is routed + /// through the typed interface — no reflection — preserving arguments + /// and return values. + /// + [Fact] + public void Methods_WithTypedServer_RouteThroughTypedInterface() + { + RecordingMxAccessServer typed = new(registerHandle: 77); + MxAccessComServer adapter = new(typed); + + int serverHandle = adapter.Register("client-a"); + adapter.Advise(serverHandle, itemHandle: 9); + adapter.Unregister(serverHandle); + + Assert.Equal(77, serverHandle); + Assert.Equal("client-a", typed.RegisteredClientName); + Assert.Equal(new[] { "Register:client-a", "Advise:77:9", "Unregister:77" }, typed.Calls); + } + + /// + /// A COM object that implements neither the typed COM interface family + /// nor fails fast with a clear + /// instead of a late-bound + /// reflection call. + /// + [Fact] + public void Methods_WithUntypedObject_ThrowInvalidOperation() + { + MxAccessComServer adapter = new(new object()); + + InvalidOperationException exception = + Assert.Throws(() => adapter.Register("client")); + + Assert.Contains("does not implement", exception.Message, StringComparison.Ordinal); + Assert.Contains(nameof(IMxAccessServer), exception.Message, StringComparison.Ordinal); + } + + /// + /// Exceptions thrown by the typed server propagate unchanged — no + /// TargetInvocationException wrapping (reflection is gone). + /// + [Fact] + public void Methods_WhenTypedServerThrows_PropagateOriginalException() + { + RecordingMxAccessServer typed = new(registerHandle: 1) + { + ThrowOnRegister = new InvalidOperationException("register failed"), + }; + MxAccessComServer adapter = new(typed); + + InvalidOperationException exception = + Assert.Throws(() => adapter.Register("client")); + + Assert.Equal("register failed", exception.Message); + } + + private sealed class RecordingMxAccessServer : IMxAccessServer + { + private readonly int registerHandle; + private readonly List calls = new(); + + public RecordingMxAccessServer(int registerHandle) + { + this.registerHandle = registerHandle; + } + + public string? RegisteredClientName { get; private set; } + + public Exception? ThrowOnRegister { get; set; } + + public IReadOnlyList Calls => calls.ToArray(); + + public int Register(string clientName) + { + calls.Add($"Register:{clientName}"); + RegisteredClientName = clientName; + if (ThrowOnRegister is not null) + { + throw ThrowOnRegister; + } + + return registerHandle; + } + + public void Unregister(int serverHandle) + { + calls.Add($"Unregister:{serverHandle}"); + } + + public int AddItem(int serverHandle, string itemDefinition) + { + calls.Add($"AddItem:{serverHandle}:{itemDefinition}"); + return 0; + } + + public int AddItem2(int serverHandle, string itemDefinition, string itemContext) + { + calls.Add($"AddItem2:{serverHandle}:{itemDefinition}:{itemContext}"); + return 0; + } + + public void RemoveItem(int serverHandle, int itemHandle) + { + calls.Add($"RemoveItem:{serverHandle}:{itemHandle}"); + } + + public void Advise(int serverHandle, int itemHandle) + { + calls.Add($"Advise:{serverHandle}:{itemHandle}"); + } + + public void UnAdvise(int serverHandle, int itemHandle) + { + calls.Add($"UnAdvise:{serverHandle}:{itemHandle}"); + } + + public void AdviseSupervisory(int serverHandle, int itemHandle) + { + calls.Add($"AdviseSupervisory:{serverHandle}:{itemHandle}"); + } + + public void Write(int serverHandle, int itemHandle, object? value, int userId) + { + calls.Add($"Write:{serverHandle}:{itemHandle}:{value}:{userId}"); + } + + public void Write2(int serverHandle, int itemHandle, object? value, object? timestamp, int userId) + { + calls.Add($"Write2:{serverHandle}:{itemHandle}:{value}:{timestamp}:{userId}"); + } + + public void WriteSecured(int serverHandle, int itemHandle, int currentUserId, int verifierUserId, object? value) + { + calls.Add($"WriteSecured:{serverHandle}:{itemHandle}:{currentUserId}:{verifierUserId}:{value}"); + } + + public void WriteSecured2( + int serverHandle, int itemHandle, int currentUserId, int verifierUserId, object? value, object? timestamp) + { + calls.Add($"WriteSecured2:{serverHandle}:{itemHandle}:{currentUserId}:{verifierUserId}:{value}:{timestamp}"); + } + } +} diff --git a/src/MxGateway.Worker.Tests/MxAccess/MxAccessCommandExecutorTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessCommandExecutorTests.cs similarity index 61% rename from src/MxGateway.Worker.Tests/MxAccess/MxAccessCommandExecutorTests.cs rename to src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessCommandExecutorTests.cs index d98082c..1b39f88 100644 --- a/src/MxGateway.Worker.Tests/MxAccess/MxAccessCommandExecutorTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessCommandExecutorTests.cs @@ -3,11 +3,13 @@ using System.Collections.Generic; using System.Linq; using System.Runtime.InteropServices; using System.Threading.Tasks; -using MxGateway.Contracts.Proto; -using MxGateway.Worker.MxAccess; -using MxGateway.Worker.Sta; +using Google.Protobuf.WellKnownTypes; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Worker.MxAccess; +using ZB.MOM.WW.MxGateway.Worker.Sta; +using ZB.MOM.WW.MxGateway.Worker.Tests.TestSupport; -namespace MxGateway.Worker.Tests.MxAccess; +namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess; public sealed class MxAccessCommandExecutorTests { @@ -471,6 +473,203 @@ public sealed class MxAccessCommandExecutorTests Assert.Equal(runtime.StaThreadId, fakeComObject.AdviseThreadId); } + /// + /// Verifies that WriteBulk runs MXAccess Write per entry on the STA and returns + /// one BulkWriteResult per entry in input order, including a per-entry COM + /// failure surfaced as WasSuccessful=false with the underlying HRESULT. + /// + [Fact] + public async Task DispatchAsync_WriteBulk_RunsSequentialWritesAndReturnsPerEntryResults() + { + const int hresult = unchecked((int)0x80070057); + FakeMxAccessComObject fakeComObject = new( + registerHandle: 80, + writeExceptionByItemHandle: new Dictionary + { + [802] = new COMException("Invalid item handle.", hresult), + }); + FakeMxAccessComObjectFactory factory = new(fakeComObject); + using StaRuntime runtime = CreateRuntime(); + using MxAccessStaSession session = new(runtime, factory, new NoopEventSink()); + await session.StartAsync(workerProcessId: 1234); + + MxCommandReply reply = await session.DispatchAsync(CreateWriteBulkCommand( + "write-bulk", + serverHandle: 80, + new[] + { + (itemHandle: 801, value: 11, userId: 5), + (itemHandle: 802, value: 22, userId: 5), + (itemHandle: 803, value: 33, userId: 5), + })); + + Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); + Assert.Equal(MxCommandKind.WriteBulk, reply.Kind); + Assert.Equal(3, reply.WriteBulk.Results.Count); + + BulkWriteResult success1 = reply.WriteBulk.Results[0]; + Assert.True(success1.WasSuccessful); + Assert.Equal(801, success1.ItemHandle); + Assert.Equal(string.Empty, success1.ErrorMessage); + + BulkWriteResult failure = reply.WriteBulk.Results[1]; + Assert.False(failure.WasSuccessful); + Assert.Equal(802, failure.ItemHandle); + Assert.True(failure.HasHresult); + Assert.Equal(hresult, failure.Hresult); + + BulkWriteResult success3 = reply.WriteBulk.Results[2]; + Assert.True(success3.WasSuccessful); + Assert.Equal(803, success3.ItemHandle); + + // Each Write hit the fake COM object on the STA thread. + Assert.Equal(runtime.StaThreadId, fakeComObject.WriteThreadId); + Assert.Contains("Write:80:801", fakeComObject.OperationNames); + Assert.Contains("Write:80:802", fakeComObject.OperationNames); + Assert.Contains("Write:80:803", fakeComObject.OperationNames); + } + + /// Verifies that Write2Bulk forwards value AND timestamp to each per-entry Write2. + [Fact] + public async Task DispatchAsync_Write2Bulk_ForwardsValueAndTimestampPerEntry() + { + FakeMxAccessComObject fakeComObject = new(registerHandle: 81); + FakeMxAccessComObjectFactory factory = new(fakeComObject); + using StaRuntime runtime = CreateRuntime(); + using MxAccessStaSession session = new(runtime, factory, new NoopEventSink()); + await session.StartAsync(workerProcessId: 1234); + DateTime timestamp = new(2026, 5, 19, 12, 0, 0, DateTimeKind.Utc); + + MxCommandReply reply = await session.DispatchAsync(CreateWrite2BulkCommand( + "write2-bulk", + serverHandle: 81, + new[] { (itemHandle: 811, value: 100, timestamp, userId: 7) })); + + Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); + BulkWriteResult result = Assert.Single(reply.Write2Bulk.Results); + Assert.True(result.WasSuccessful); + Assert.Equal(811, result.ItemHandle); + Assert.Equal(100, fakeComObject.WriteValue); + Assert.Equal(timestamp, fakeComObject.WriteTimestamp); + Assert.Equal(7, fakeComObject.WriteUserId); + Assert.Contains("Write2:81:811", fakeComObject.OperationNames); + } + + /// Verifies that WriteSecuredBulk forwards both user ids per entry. + [Fact] + public async Task DispatchAsync_WriteSecuredBulk_ForwardsUserIdsPerEntry() + { + FakeMxAccessComObject fakeComObject = new(registerHandle: 82); + FakeMxAccessComObjectFactory factory = new(fakeComObject); + using StaRuntime runtime = CreateRuntime(); + using MxAccessStaSession session = new(runtime, factory, new NoopEventSink()); + await session.StartAsync(workerProcessId: 1234); + + MxCommandReply reply = await session.DispatchAsync(CreateWriteSecuredBulkCommand( + "write-secured-bulk", + serverHandle: 82, + new[] { (itemHandle: 821, currentUserId: 11, verifierUserId: 22, value: 555) })); + + Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); + BulkWriteResult result = Assert.Single(reply.WriteSecuredBulk.Results); + Assert.True(result.WasSuccessful); + Assert.Equal(11, fakeComObject.WriteCurrentUserId); + Assert.Equal(22, fakeComObject.WriteVerifierUserId); + Assert.Equal(555, fakeComObject.WriteValue); + Assert.Contains("WriteSecured:82:821", fakeComObject.OperationNames); + } + + /// Verifies that WriteSecured2Bulk forwards user ids, value, and timestamp per entry. + [Fact] + public async Task DispatchAsync_WriteSecured2Bulk_ForwardsUserIdsValueAndTimestampPerEntry() + { + FakeMxAccessComObject fakeComObject = new(registerHandle: 83); + FakeMxAccessComObjectFactory factory = new(fakeComObject); + using StaRuntime runtime = CreateRuntime(); + using MxAccessStaSession session = new(runtime, factory, new NoopEventSink()); + await session.StartAsync(workerProcessId: 1234); + DateTime timestamp = new(2026, 5, 19, 13, 30, 0, DateTimeKind.Utc); + + MxCommandReply reply = await session.DispatchAsync(CreateWriteSecured2BulkCommand( + "write-secured2-bulk", + serverHandle: 83, + new[] { (itemHandle: 831, currentUserId: 33, verifierUserId: 44, value: 999, timestamp) })); + + Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); + BulkWriteResult result = Assert.Single(reply.WriteSecured2Bulk.Results); + Assert.True(result.WasSuccessful); + Assert.Equal(33, fakeComObject.WriteCurrentUserId); + Assert.Equal(44, fakeComObject.WriteVerifierUserId); + Assert.Equal(999, fakeComObject.WriteValue); + Assert.Equal(timestamp, fakeComObject.WriteTimestamp); + Assert.Contains("WriteSecured2:83:831", fakeComObject.OperationNames); + } + + /// + /// Verifies ReadBulk's snapshot path: with no cached value, the worker takes + /// the AddItem + Advise + wait + UnAdvise + RemoveItem lifecycle itself, and + /// surfaces a timeout as a per-tag failure when no OnDataChange arrives. + /// The fake COM object never fires events so the wait always times out — but + /// the lifecycle calls must still happen, in order, on the STA. + /// + [Fact] + public async Task DispatchAsync_ReadBulk_WhenTagNotCached_TakesSnapshotLifecycleAndTimesOut() + { + FakeMxAccessComObject fakeComObject = new( + registerHandle: 90, + addItemHandle: 900); + FakeMxAccessComObjectFactory factory = new(fakeComObject); + using StaRuntime runtime = CreateRuntime(); + using MxAccessStaSession session = new(runtime, factory, new NoopEventSink()); + await session.StartAsync(workerProcessId: 1234); + await session.DispatchAsync(CreateRegisterCommand("register-before-read-bulk", "client-a")); + + MxCommandReply reply = await session.DispatchAsync(CreateReadBulkCommand( + "read-bulk-snapshot", + serverHandle: 90, + tagAddresses: new[] { "Galaxy.Tag.Value" }, + timeoutMs: 80)); + + Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); + Assert.Equal(MxCommandKind.ReadBulk, reply.Kind); + BulkReadResult result = Assert.Single(reply.ReadBulk.Results); + Assert.False(result.WasSuccessful); + Assert.False(result.WasCached); + Assert.Equal("Galaxy.Tag.Value", result.TagAddress); + Assert.Equal(900, result.ItemHandle); + Assert.Contains("timed out", result.ErrorMessage, StringComparison.OrdinalIgnoreCase); + + // The snapshot lifecycle must call AddItem → Advise → UnAdvise → RemoveItem + // in order on the STA. We don't assert exact ordering of UnAdvise vs. + // RemoveItem here because both are best-effort cleanup in a finally + // block; the operation list confirms both happened. + Assert.Contains("AddItem:90:Galaxy.Tag.Value", fakeComObject.OperationNames); + Assert.Contains("Advise:90:900", fakeComObject.OperationNames); + Assert.Contains("UnAdvise:90:900", fakeComObject.OperationNames); + Assert.Contains("RemoveItem:90:900", fakeComObject.OperationNames); + } + + /// Verifies that ReadBulk with no payload returns an invalid request error. + [Fact] + public async Task DispatchAsync_ReadBulkWithoutPayload_ReturnsInvalidRequest() + { + FakeMxAccessComObject fakeComObject = new(registerHandle: 91); + FakeMxAccessComObjectFactory factory = new(fakeComObject); + using StaRuntime runtime = CreateRuntime(); + using MxAccessStaSession session = new(runtime, factory, new NoopEventSink()); + await session.StartAsync(workerProcessId: 1234); + + MxCommandReply reply = await session.DispatchAsync(new StaCommand( + "session-1", + "missing-read-bulk-payload", + new MxCommand + { + Kind = MxCommandKind.ReadBulk, + })); + + Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code); + } + /// Verifies that UnsubscribeBulk removes items after UnAdvise failure. [Fact] public async Task DispatchAsync_UnsubscribeBulk_RemovesItemAfterUnAdviseFailure() @@ -616,6 +815,143 @@ public sealed class MxAccessCommandExecutorTests Assert.Null(factory.FakeComObject.AdviseServerHandle); } + /// Verifies that Write dispatches the converted value to MXAccess on the STA thread. + [Fact] + public async Task DispatchAsync_Write_CallsMxAccessOnStaWithConvertedValue() + { + FakeMxAccessComObject fakeComObject = new(registerHandle: 70); + FakeMxAccessComObjectFactory factory = new(fakeComObject); + using StaRuntime runtime = CreateRuntime(); + using MxAccessStaSession session = new(runtime, factory, new NoopEventSink()); + await session.StartAsync(workerProcessId: 1234); + + MxCommandReply reply = await session.DispatchAsync(CreateWriteCommand( + "write", serverHandle: 70, itemHandle: 700, value: 123, userId: 5)); + + Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); + Assert.Equal(MxCommandKind.Write, reply.Kind); + Assert.Equal(70, fakeComObject.WriteServerHandle); + Assert.Equal(700, fakeComObject.WriteItemHandle); + Assert.Equal(123, fakeComObject.WriteValue); + Assert.Equal(5, fakeComObject.WriteUserId); + Assert.Equal(runtime.StaThreadId, fakeComObject.WriteThreadId); + } + + /// Verifies that Write2 forwards the converted value and timestamp to MXAccess. + [Fact] + public async Task DispatchAsync_Write2_ForwardsValueAndTimestamp() + { + FakeMxAccessComObject fakeComObject = new(registerHandle: 71); + FakeMxAccessComObjectFactory factory = new(fakeComObject); + using StaRuntime runtime = CreateRuntime(); + using MxAccessStaSession session = new(runtime, factory, new NoopEventSink()); + await session.StartAsync(workerProcessId: 1234); + DateTime timestamp = new(2026, 5, 19, 12, 0, 0, DateTimeKind.Utc); + + MxCommandReply reply = await session.DispatchAsync(CreateWrite2Command( + "write2", serverHandle: 71, itemHandle: 710, value: 456, timestamp: timestamp, userId: 6)); + + Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); + Assert.Equal(MxCommandKind.Write2, reply.Kind); + Assert.Equal(710, fakeComObject.WriteItemHandle); + Assert.Equal(456, fakeComObject.WriteValue); + Assert.Equal(timestamp, fakeComObject.WriteTimestamp); + Assert.Equal(6, fakeComObject.WriteUserId); + } + + /// Verifies that WriteSecured forwards the operator and verifier user ids to MXAccess. + [Fact] + public async Task DispatchAsync_WriteSecured_ForwardsUserIds() + { + FakeMxAccessComObject fakeComObject = new(registerHandle: 72); + FakeMxAccessComObjectFactory factory = new(fakeComObject); + using StaRuntime runtime = CreateRuntime(); + using MxAccessStaSession session = new(runtime, factory, new NoopEventSink()); + await session.StartAsync(workerProcessId: 1234); + + MxCommandReply reply = await session.DispatchAsync(CreateWriteSecuredCommand( + "write-secured", serverHandle: 72, itemHandle: 720, value: 789, currentUserId: 11, verifierUserId: 22)); + + Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); + Assert.Equal(MxCommandKind.WriteSecured, reply.Kind); + Assert.Equal(720, fakeComObject.WriteItemHandle); + Assert.Equal(789, fakeComObject.WriteValue); + Assert.Equal(11, fakeComObject.WriteCurrentUserId); + Assert.Equal(22, fakeComObject.WriteVerifierUserId); + } + + /// Verifies that WriteSecured2 forwards user ids, value, and timestamp to MXAccess. + [Fact] + public async Task DispatchAsync_WriteSecured2_ForwardsUserIdsValueAndTimestamp() + { + FakeMxAccessComObject fakeComObject = new(registerHandle: 73); + FakeMxAccessComObjectFactory factory = new(fakeComObject); + using StaRuntime runtime = CreateRuntime(); + using MxAccessStaSession session = new(runtime, factory, new NoopEventSink()); + await session.StartAsync(workerProcessId: 1234); + DateTime timestamp = new(2026, 5, 19, 13, 30, 0, DateTimeKind.Utc); + + MxCommandReply reply = await session.DispatchAsync(CreateWriteSecured2Command( + "write-secured2", serverHandle: 73, itemHandle: 730, value: 1011, + timestamp: timestamp, currentUserId: 33, verifierUserId: 44)); + + Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); + Assert.Equal(MxCommandKind.WriteSecured2, reply.Kind); + Assert.Equal(1011, fakeComObject.WriteValue); + Assert.Equal(timestamp, fakeComObject.WriteTimestamp); + Assert.Equal(33, fakeComObject.WriteCurrentUserId); + Assert.Equal(44, fakeComObject.WriteVerifierUserId); + } + + /// Verifies that Write without a payload returns an invalid request error. + [Fact] + public async Task DispatchAsync_WriteWithoutPayload_ReturnsInvalidRequest() + { + FakeMxAccessComObject fakeComObject = new(registerHandle: 74); + FakeMxAccessComObjectFactory factory = new(fakeComObject); + using StaRuntime runtime = CreateRuntime(); + using MxAccessStaSession session = new(runtime, factory, new NoopEventSink()); + await session.StartAsync(workerProcessId: 1234); + + MxCommandReply reply = await session.DispatchAsync(new StaCommand( + "session-1", + "missing-write-payload", + new MxCommand + { + Kind = MxCommandKind.Write, + })); + + Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code); + Assert.Null(fakeComObject.WriteServerHandle); + } + + /// Verifies that Write without a value returns an invalid request error. + [Fact] + public async Task DispatchAsync_WriteWithoutValue_ReturnsInvalidRequest() + { + FakeMxAccessComObject fakeComObject = new(registerHandle: 75); + FakeMxAccessComObjectFactory factory = new(fakeComObject); + using StaRuntime runtime = CreateRuntime(); + using MxAccessStaSession session = new(runtime, factory, new NoopEventSink()); + await session.StartAsync(workerProcessId: 1234); + + MxCommandReply reply = await session.DispatchAsync(new StaCommand( + "session-1", + "missing-write-value", + new MxCommand + { + Kind = MxCommandKind.Write, + Write = new WriteCommand + { + ServerHandle = 75, + ItemHandle = 750, + }, + })); + + Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code); + Assert.Null(fakeComObject.WriteServerHandle); + } + private static StaCommand CreateRegisterCommand( string correlationId, string clientName) @@ -728,6 +1064,126 @@ public sealed class MxAccessCommandExecutorTests }); } + private static MxValue CreateIntegerValue(int value) + { + return new MxValue + { + DataType = MxDataType.Integer, + VariantType = "VT_I4", + Int32Value = value, + }; + } + + private static MxValue CreateTimestampValue(DateTime timestamp) + { + return new MxValue + { + DataType = MxDataType.Time, + VariantType = "VT_DATE", + TimestampValue = Timestamp.FromDateTime(timestamp), + }; + } + + private static StaCommand CreateWriteCommand( + string correlationId, + int serverHandle, + int itemHandle, + int value, + int userId) + { + return new StaCommand( + "session-1", + correlationId, + new MxCommand + { + Kind = MxCommandKind.Write, + Write = new WriteCommand + { + ServerHandle = serverHandle, + ItemHandle = itemHandle, + Value = CreateIntegerValue(value), + UserId = userId, + }, + }); + } + + private static StaCommand CreateWrite2Command( + string correlationId, + int serverHandle, + int itemHandle, + int value, + DateTime timestamp, + int userId) + { + return new StaCommand( + "session-1", + correlationId, + new MxCommand + { + Kind = MxCommandKind.Write2, + Write2 = new Write2Command + { + ServerHandle = serverHandle, + ItemHandle = itemHandle, + Value = CreateIntegerValue(value), + TimestampValue = CreateTimestampValue(timestamp), + UserId = userId, + }, + }); + } + + private static StaCommand CreateWriteSecuredCommand( + string correlationId, + int serverHandle, + int itemHandle, + int value, + int currentUserId, + int verifierUserId) + { + return new StaCommand( + "session-1", + correlationId, + new MxCommand + { + Kind = MxCommandKind.WriteSecured, + WriteSecured = new WriteSecuredCommand + { + ServerHandle = serverHandle, + ItemHandle = itemHandle, + CurrentUserId = currentUserId, + VerifierUserId = verifierUserId, + Value = CreateIntegerValue(value), + }, + }); + } + + private static StaCommand CreateWriteSecured2Command( + string correlationId, + int serverHandle, + int itemHandle, + int value, + DateTime timestamp, + int currentUserId, + int verifierUserId) + { + return new StaCommand( + "session-1", + correlationId, + new MxCommand + { + Kind = MxCommandKind.WriteSecured2, + WriteSecured2 = new WriteSecured2Command + { + ServerHandle = serverHandle, + ItemHandle = itemHandle, + CurrentUserId = currentUserId, + VerifierUserId = verifierUserId, + Value = CreateIntegerValue(value), + TimestampValue = CreateTimestampValue(timestamp), + }, + }); + } + private static StaCommand CreateUnAdviseCommand( string correlationId, int serverHandle, @@ -789,6 +1245,149 @@ public sealed class MxAccessCommandExecutorTests }); } + private static StaCommand CreateWriteBulkCommand( + string correlationId, + int serverHandle, + IEnumerable<(int itemHandle, int value, int userId)> entries) + { + WriteBulkCommand command = new() + { + ServerHandle = serverHandle, + }; + foreach ((int itemHandle, int value, int userId) in entries) + { + command.Entries.Add(new WriteBulkEntry + { + ItemHandle = itemHandle, + Value = CreateIntegerValue(value), + UserId = userId, + }); + } + + return new StaCommand( + "session-1", + correlationId, + new MxCommand + { + Kind = MxCommandKind.WriteBulk, + WriteBulk = command, + }); + } + + private static StaCommand CreateWrite2BulkCommand( + string correlationId, + int serverHandle, + IEnumerable<(int itemHandle, int value, DateTime timestamp, int userId)> entries) + { + Write2BulkCommand command = new() + { + ServerHandle = serverHandle, + }; + foreach ((int itemHandle, int value, DateTime timestamp, int userId) in entries) + { + command.Entries.Add(new Write2BulkEntry + { + ItemHandle = itemHandle, + Value = CreateIntegerValue(value), + TimestampValue = CreateTimestampValue(timestamp), + UserId = userId, + }); + } + + return new StaCommand( + "session-1", + correlationId, + new MxCommand + { + Kind = MxCommandKind.Write2Bulk, + Write2Bulk = command, + }); + } + + private static StaCommand CreateWriteSecuredBulkCommand( + string correlationId, + int serverHandle, + IEnumerable<(int itemHandle, int currentUserId, int verifierUserId, int value)> entries) + { + WriteSecuredBulkCommand command = new() + { + ServerHandle = serverHandle, + }; + foreach ((int itemHandle, int currentUserId, int verifierUserId, int value) in entries) + { + command.Entries.Add(new WriteSecuredBulkEntry + { + ItemHandle = itemHandle, + CurrentUserId = currentUserId, + VerifierUserId = verifierUserId, + Value = CreateIntegerValue(value), + }); + } + + return new StaCommand( + "session-1", + correlationId, + new MxCommand + { + Kind = MxCommandKind.WriteSecuredBulk, + WriteSecuredBulk = command, + }); + } + + private static StaCommand CreateWriteSecured2BulkCommand( + string correlationId, + int serverHandle, + IEnumerable<(int itemHandle, int currentUserId, int verifierUserId, int value, DateTime timestamp)> entries) + { + WriteSecured2BulkCommand command = new() + { + ServerHandle = serverHandle, + }; + foreach ((int itemHandle, int currentUserId, int verifierUserId, int value, DateTime timestamp) in entries) + { + command.Entries.Add(new WriteSecured2BulkEntry + { + ItemHandle = itemHandle, + CurrentUserId = currentUserId, + VerifierUserId = verifierUserId, + Value = CreateIntegerValue(value), + TimestampValue = CreateTimestampValue(timestamp), + }); + } + + return new StaCommand( + "session-1", + correlationId, + new MxCommand + { + Kind = MxCommandKind.WriteSecured2Bulk, + WriteSecured2Bulk = command, + }); + } + + private static StaCommand CreateReadBulkCommand( + string correlationId, + int serverHandle, + IEnumerable tagAddresses, + uint timeoutMs) + { + ReadBulkCommand command = new() + { + ServerHandle = serverHandle, + TimeoutMs = timeoutMs, + }; + command.TagAddresses.Add(tagAddresses); + + return new StaCommand( + "session-1", + correlationId, + new MxCommand + { + Kind = MxCommandKind.ReadBulk, + ReadBulk = command, + }); + } + private static StaCommand CreateAdviseSupervisoryCommand( string correlationId, int serverHandle, @@ -816,7 +1415,7 @@ public sealed class MxAccessCommandExecutorTests TimeSpan.FromMilliseconds(25)); } - private sealed class FakeMxAccessComObject + private sealed class FakeMxAccessComObject : IMxAccessServer { private readonly int registerHandle; private readonly int addItemHandle; @@ -828,6 +1427,7 @@ public sealed class MxAccessCommandExecutorTests private readonly Exception? adviseException; private readonly Exception? unAdviseException; private readonly Exception? adviseSupervisoryException; + private readonly IReadOnlyDictionary writeExceptionByItemHandle; private readonly List operationNames = new(); /// Initializes a fake MXAccess COM object with the given handles and optional exceptions. @@ -851,7 +1451,8 @@ public sealed class MxAccessCommandExecutorTests Exception? removeItemException = null, Exception? adviseException = null, Exception? unAdviseException = null, - Exception? adviseSupervisoryException = null) + Exception? adviseSupervisoryException = null, + IReadOnlyDictionary? writeExceptionByItemHandle = null) { this.registerHandle = registerHandle; this.addItemHandle = addItemHandle; @@ -863,6 +1464,8 @@ public sealed class MxAccessCommandExecutorTests this.adviseException = adviseException; this.unAdviseException = unAdviseException; this.adviseSupervisoryException = adviseSupervisoryException; + this.writeExceptionByItemHandle = writeExceptionByItemHandle + ?? new Dictionary(); } /// Gets the client name passed to Register, if called. @@ -1079,6 +1682,133 @@ public sealed class MxAccessCommandExecutorTests throw adviseSupervisoryException; } } + + /// Gets the server handle passed to the most recent write, if called. + public int? WriteServerHandle { get; private set; } + + /// Gets the item handle passed to the most recent write, if called. + public int? WriteItemHandle { get; private set; } + + /// Gets the value passed to the most recent write, if called. + public object? WriteValue { get; private set; } + + /// Gets the timestamp passed to the most recent timestamped write, if called. + public object? WriteTimestamp { get; private set; } + + /// Gets the user id passed to the most recent Write/Write2, if called. + public int? WriteUserId { get; private set; } + + /// Gets the current user id passed to the most recent secured write, if called. + public int? WriteCurrentUserId { get; private set; } + + /// Gets the verifier user id passed to the most recent secured write, if called. + public int? WriteVerifierUserId { get; private set; } + + /// Gets the thread ID on which the most recent write was called. + public int? WriteThreadId { get; private set; } + + /// Writes a value to an item and tracks the operation. + /// Server handle for the write. + /// Item handle to write to. + /// Value to write. + /// MXAccess user id for the write. + public void Write( + int serverHandle, + int itemHandle, + object? value, + int userId) + { + operationNames.Add($"Write:{serverHandle}:{itemHandle}"); + WriteServerHandle = serverHandle; + WriteItemHandle = itemHandle; + WriteValue = value; + WriteUserId = userId; + WriteThreadId = Environment.CurrentManagedThreadId; + ThrowIfWriteFailureConfigured(itemHandle); + } + + /// Writes a timestamped value to an item and tracks the operation. + /// Server handle for the write. + /// Item handle to write to. + /// Value to write. + /// Source timestamp for the write. + /// MXAccess user id for the write. + public void Write2( + int serverHandle, + int itemHandle, + object? value, + object? timestamp, + int userId) + { + operationNames.Add($"Write2:{serverHandle}:{itemHandle}"); + WriteServerHandle = serverHandle; + WriteItemHandle = itemHandle; + WriteValue = value; + WriteTimestamp = timestamp; + WriteUserId = userId; + WriteThreadId = Environment.CurrentManagedThreadId; + ThrowIfWriteFailureConfigured(itemHandle); + } + + /// Performs a secured write to an item and tracks the operation. + /// Server handle for the write. + /// Item handle to write to. + /// Operator user id. + /// Verifier user id. + /// Value to write. + public void WriteSecured( + int serverHandle, + int itemHandle, + int currentUserId, + int verifierUserId, + object? value) + { + operationNames.Add($"WriteSecured:{serverHandle}:{itemHandle}"); + WriteServerHandle = serverHandle; + WriteItemHandle = itemHandle; + WriteCurrentUserId = currentUserId; + WriteVerifierUserId = verifierUserId; + WriteValue = value; + WriteThreadId = Environment.CurrentManagedThreadId; + ThrowIfWriteFailureConfigured(itemHandle); + } + + /// Performs a secured timestamped write to an item and tracks the operation. + /// Server handle for the write. + /// Item handle to write to. + /// Operator user id. + /// Verifier user id. + /// Value to write. + /// Source timestamp for the write. + public void WriteSecured2( + int serverHandle, + int itemHandle, + int currentUserId, + int verifierUserId, + object? value, + object? timestamp) + { + operationNames.Add($"WriteSecured2:{serverHandle}:{itemHandle}"); + WriteServerHandle = serverHandle; + WriteItemHandle = itemHandle; + WriteCurrentUserId = currentUserId; + WriteVerifierUserId = verifierUserId; + WriteValue = value; + WriteTimestamp = timestamp; + WriteThreadId = Environment.CurrentManagedThreadId; + ThrowIfWriteFailureConfigured(itemHandle); + } + + private void ThrowIfWriteFailureConfigured(int itemHandle) + { + // Per-item write-failure injection — used by the bulk-write tests to + // exercise the "one bad entry surfaces as was_successful=false but + // the loop keeps going" contract on BulkWriteResult. + if (writeExceptionByItemHandle.TryGetValue(itemHandle, out Exception? exception)) + { + throw exception; + } + } } /// Factory for creating fake MXAccess COM objects in tests. @@ -1102,35 +1832,4 @@ public sealed class MxAccessCommandExecutorTests } } - /// No-operation event sink for testing. - private sealed class NoopEventSink : IMxAccessEventSink - { - /// Attaches to a MXAccess COM object (no-op in test). - /// The MXAccess COM object to attach to. - /// Identifier of the session. - public void Attach( - object mxAccessComObject, - string sessionId) - { - } - - /// Detaches from the MXAccess COM object (no-op in test). - public void Detach() - { - } - } - - /// No-operation STA apartment initializer for testing. - private sealed class NoopComApartmentInitializer : IStaComApartmentInitializer - { - /// Initializes the STA apartment (no-op in test). - public void Initialize() - { - } - - /// Uninitializes the STA apartment (no-op in test). - public void Uninitialize() - { - } - } } diff --git a/src/MxGateway.Worker.Tests/MxAccess/MxAccessEventMapperTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessEventMapperTests.cs similarity index 76% rename from src/MxGateway.Worker.Tests/MxAccess/MxAccessEventMapperTests.cs rename to src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessEventMapperTests.cs index 0f2e524..94b9cbc 100644 --- a/src/MxGateway.Worker.Tests/MxAccess/MxAccessEventMapperTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessEventMapperTests.cs @@ -1,8 +1,9 @@ using System; -using MxGateway.Contracts.Proto; -using MxGateway.Worker.MxAccess; +using System.Globalization; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Worker.MxAccess; -namespace MxGateway.Worker.Tests.MxAccess; +namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess; public sealed class MxAccessEventMapperTests { @@ -179,6 +180,63 @@ public sealed class MxAccessEventMapperTests Assert.Null(mxEvent.OnAlarmTransition.OriginalRaiseTimestamp); } + /// + /// Verifies that an OnDataChange whose timestamp arrives as the + /// VT_BSTR string MXAccess actually delivers still populates + /// — the string is parsed as + /// local time and converted to UTC. + /// + [Fact] + public void CreateOnDataChange_WithMxAccessStringTimestamp_SetsSourceTimestamp() + { + // The exact shape MXAccess fires (see captures/003-subscribe-scalars). + const string mxAccessTimestamp = "3/26/2026 1:38:22.907 PM"; + + MxEvent mxEvent = mapper.CreateOnDataChange( + "session-1", + serverHandle: 1, + itemHandle: 1, + value: 99, + quality: 192, + timestamp: mxAccessTimestamp, + statuses: null); + + Assert.NotNull(mxEvent.SourceTimestamp); + + DateTime localWall = new(2026, 3, 26, 13, 38, 22, 907, DateTimeKind.Unspecified); + DateTime expectedUtc = DateTime.SpecifyKind(localWall, DateTimeKind.Local).ToUniversalTime(); + Assert.Equal(expectedUtc, mxEvent.SourceTimestamp.ToDateTime()); + } + + /// + /// Verifies the MXAccess timestamp string is interpreted as the host's + /// local time and returned as UTC. Written timezone-independently by + /// round-tripping a local wall-clock time. + /// + [Fact] + public void TryParseSourceTimestamp_InterpretsStringAsLocalTime() + { + DateTime localWall = new(2026, 5, 21, 13, 43, 26, DateTimeKind.Unspecified); + string text = localWall.ToString(CultureInfo.CurrentCulture); + + Assert.True(MxAccessEventMapper.TryParseSourceTimestamp(text, out DateTime utc)); + Assert.Equal(DateTimeKind.Utc, utc.Kind); + + DateTime expectedUtc = DateTime.SpecifyKind(localWall, DateTimeKind.Local).ToUniversalTime(); + Assert.Equal(expectedUtc, utc); + } + + /// Verifies unparseable or empty timestamp input is rejected without throwing. + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("not a timestamp")] + public void TryParseSourceTimestamp_RejectsUnparseableInput(string? text) + { + Assert.False(MxAccessEventMapper.TryParseSourceTimestamp(text, out _)); + } + private sealed class FakeStatus { public int success; diff --git a/src/MxGateway.Worker.Tests/MxAccess/MxAccessEventQueueTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessEventQueueTests.cs similarity index 68% rename from src/MxGateway.Worker.Tests/MxAccess/MxAccessEventQueueTests.cs rename to src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessEventQueueTests.cs index ee2bfb5..27d684b 100644 --- a/src/MxGateway.Worker.Tests/MxAccess/MxAccessEventQueueTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessEventQueueTests.cs @@ -1,9 +1,9 @@ using System; using System.Collections.Generic; -using MxGateway.Contracts.Proto; -using MxGateway.Worker.MxAccess; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Worker.MxAccess; -namespace MxGateway.Worker.Tests.MxAccess; +namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess; public sealed class MxAccessEventQueueTests { @@ -46,6 +46,53 @@ public sealed class MxAccessEventQueueTests Assert.Equal(1, queue.Count); } + /// Verifies that Drain with maxEvents 0 drains every queued event. + [Fact] + public void Drain_WithZeroMaxEvents_DrainsAllEvents() + { + MxAccessEventQueue queue = new(capacity: 4); + queue.Enqueue(CreateEvent(MxEventFamily.OnDataChange, itemHandle: 10)); + queue.Enqueue(CreateEvent(MxEventFamily.OnDataChange, itemHandle: 11)); + queue.Enqueue(CreateEvent(MxEventFamily.OnDataChange, itemHandle: 12)); + + IReadOnlyList drained = queue.Drain(maxEvents: 0); + + Assert.Equal(3, drained.Count); + Assert.Equal(new[] { 10, 11, 12 }, new[] + { + drained[0].Event.ItemHandle, + drained[1].Event.ItemHandle, + drained[2].Event.ItemHandle, + }); + Assert.Equal(0, queue.Count); + } + + /// Verifies that draining an empty queue returns an empty list. + [Fact] + public void Drain_WhenQueueIsEmpty_ReturnsEmptyList() + { + MxAccessEventQueue queue = new(capacity: 4); + + Assert.Empty(queue.Drain(maxEvents: 0)); + Assert.Empty(queue.Drain(maxEvents: 5)); + Assert.Equal(0, queue.Count); + } + + /// Verifies that Enqueue is rejected after a fault is recorded manually. + [Fact] + public void Enqueue_AfterRecordFault_ThrowsInvalidOperationException() + { + MxAccessEventQueue queue = new(capacity: 4); + queue.RecordFault(new WorkerFault + { + Category = WorkerFaultCategory.MxaccessEventConversionFailed, + }); + + Assert.Throws( + () => queue.Enqueue(CreateEvent(MxEventFamily.OnDataChange, itemHandle: 10))); + Assert.Equal(0, queue.Count); + } + /// Verifies that Enqueue records an overflow fault and rejects new events when capacity is exceeded. [Fact] public void Enqueue_WhenCapacityIsExceeded_RecordsOverflowFaultAndRejectsNewEvents() diff --git a/src/MxGateway.Worker.Tests/MxAccess/MxAccessInteropInfoTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessInteropInfoTests.cs similarity index 91% rename from src/MxGateway.Worker.Tests/MxAccess/MxAccessInteropInfoTests.cs rename to src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessInteropInfoTests.cs index 746a131..c2dd4b9 100644 --- a/src/MxGateway.Worker.Tests/MxAccess/MxAccessInteropInfoTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessInteropInfoTests.cs @@ -1,6 +1,6 @@ -using MxGateway.Worker.MxAccess; +using ZB.MOM.WW.MxGateway.Worker.MxAccess; -namespace MxGateway.Worker.Tests.MxAccess; +namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess; public sealed class MxAccessInteropInfoTests { diff --git a/src/MxGateway.Worker.Tests/MxAccess/MxAccessLiveComCreationTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessLiveComCreationTests.cs similarity index 92% rename from src/MxGateway.Worker.Tests/MxAccess/MxAccessLiveComCreationTests.cs rename to src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessLiveComCreationTests.cs index 0a6d56b..9479fb1 100644 --- a/src/MxGateway.Worker.Tests/MxAccess/MxAccessLiveComCreationTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessLiveComCreationTests.cs @@ -1,44 +1,32 @@ using System; using System.Threading.Tasks; -using MxGateway.Contracts.Proto; -using MxGateway.Worker.MxAccess; -using MxGateway.Worker.Sta; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Worker.MxAccess; +using ZB.MOM.WW.MxGateway.Worker.Sta; +using ZB.MOM.WW.MxGateway.Worker.Tests.TestSupport; -namespace MxGateway.Worker.Tests.MxAccess; +namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess; public sealed class MxAccessLiveComCreationTests { - private const string LiveClientName = "MxGateway.Worker.Tests"; + private const string LiveClientName = "ZB.MOM.WW.MxGateway.Worker.Tests"; private const string DefaultLiveAddItemReference = "TestChildObject.TestInt"; private const string DefaultLiveAddItem2Definition = "TestInt"; private const string DefaultLiveAddItem2Context = "TestChildObject"; /// Verifies that StartAsync creates the installed MXAccess COM object on the STA thread when opted in. - [Fact] + [LiveMxAccessFact] public async Task StartAsync_WhenOptedIn_CreatesInstalledMxAccessComObjectOnSta() { - if (!string.Equals( - Environment.GetEnvironmentVariable("MXGATEWAY_RUN_LIVE_MXACCESS_TESTS"), - "1", - StringComparison.Ordinal)) - { - return; - } - using MxAccessStaSession session = new(); await session.StartAsync(workerProcessId: 1234); } /// Verifies that Register and Unregister round-trip server handles with installed MXAccess. - [Fact] + [LiveMxAccessFact] public async Task RegisterAndUnregister_WhenOptedIn_RoundTripsInstalledMxAccessServerHandle() { - if (!RunLiveMxAccessTests()) - { - return; - } - using MxAccessStaSession session = new(); await session.StartAsync(workerProcessId: 1234); @@ -73,14 +61,9 @@ public sealed class MxAccessLiveComCreationTests } /// Verifies that AddItem and RemoveItem round-trip item handles with installed MXAccess. - [Fact] + [LiveMxAccessFact] public async Task AddItemAndRemoveItem_WhenOptedIn_RoundTripsInstalledMxAccessItemHandle() { - if (!RunLiveMxAccessTests()) - { - return; - } - using MxAccessStaSession session = new(); await session.StartAsync(workerProcessId: 1234); @@ -146,14 +129,9 @@ public sealed class MxAccessLiveComCreationTests } /// Verifies that AddItem2 and RemoveItem preserve item context with installed MXAccess. - [Fact] + [LiveMxAccessFact] public async Task AddItem2AndRemoveItem_WhenOptedIn_PreservesContextForInstalledMxAccess() { - if (!RunLiveMxAccessTests()) - { - return; - } - using MxAccessStaSession session = new(); await session.StartAsync(workerProcessId: 1234); @@ -220,14 +198,9 @@ public sealed class MxAccessLiveComCreationTests } /// Verifies that Advise and UnAdvise round-trip subscriptions with installed MXAccess. - [Fact] + [LiveMxAccessFact] public async Task AdviseAndUnAdvise_WhenOptedIn_RoundTripsInstalledMxAccessSubscription() { - if (!RunLiveMxAccessTests()) - { - return; - } - using MxAccessStaSession session = new(); await session.StartAsync(workerProcessId: 1234); @@ -341,14 +314,6 @@ public sealed class MxAccessLiveComCreationTests } } - private static bool RunLiveMxAccessTests() - { - return string.Equals( - Environment.GetEnvironmentVariable("MXGATEWAY_RUN_LIVE_MXACCESS_TESTS"), - "1", - StringComparison.Ordinal); - } - private static string GetLiveAddItemReference() { string itemReference = Environment.GetEnvironmentVariable("MXGATEWAY_LIVE_MXACCESS_ITEM"); diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessStaSessionTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessStaSessionTests.cs new file mode 100644 index 0000000..88c6868 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessStaSessionTests.cs @@ -0,0 +1,532 @@ +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Worker.MxAccess; +using ZB.MOM.WW.MxGateway.Worker.Sta; +using ZB.MOM.WW.MxGateway.Worker.Tests.TestSupport; + +namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess; + +/// +/// Tests for . +/// +public sealed class MxAccessStaSessionTests +{ + /// + /// Verifies that StartAsync creates the MXAccess COM object and attaches the event sink on the STA thread. + /// + [Fact] + public async Task StartAsync_CreatesComObjectAndAttachesEventSinkOnStaThread() + { + FakeMxAccessComObjectFactory factory = new(); + FakeMxAccessEventSink eventSink = new(); + using StaRuntime runtime = CreateRuntime(); + using MxAccessStaSession session = new(runtime, factory, eventSink); + + WorkerReady ready = await session.StartAsync("session-1", workerProcessId: 1234); + + Assert.Equal(1234, ready.WorkerProcessId); + Assert.Equal(MxAccessInteropInfo.ProgId, ready.MxaccessProgid); + Assert.Equal(MxAccessInteropInfo.Clsid, ready.MxaccessClsid); + Assert.NotNull(ready.ReadyTimestamp); + Assert.Equal(runtime.StaThreadId, factory.CreateThreadId); + Assert.Equal(runtime.StaThreadId, eventSink.AttachThreadId); + Assert.Equal(ApartmentState.STA, factory.CreateApartmentState); + Assert.Same(factory.CreatedObject, eventSink.AttachedObject); + Assert.Equal("session-1", eventSink.SessionId); + } + + /// + /// Verifies that StartAsync maps creation exceptions with HResult when the factory fails. + /// + [Fact] + public async Task StartAsync_WhenFactoryFails_MapsCreationExceptionWithHResult() + { + const int hresult = unchecked((int)0x80040154); + FakeMxAccessComObjectFactory factory = new(new COMException("Class not registered.", hresult)); + FakeMxAccessEventSink eventSink = new(); + using StaRuntime runtime = CreateRuntime(); + using MxAccessStaSession session = new(runtime, factory, eventSink); + + MxAccessCreationException exception = await Assert.ThrowsAsync( + () => session.StartAsync(workerProcessId: 1234)); + + Assert.Equal(hresult, exception.CapturedHResult); + Assert.Equal(MxAccessInteropInfo.ProgId, exception.AttemptedProgId); + Assert.Equal(MxAccessInteropInfo.Clsid, exception.AttemptedClsid); + Assert.Null(eventSink.AttachedObject); + } + + /// + /// Verifies that Dispose detaches the event sink on the STA thread. + /// + [Fact] + public async Task Dispose_DetachesEventSinkOnStaThread() + { + FakeMxAccessComObjectFactory factory = new(); + FakeMxAccessEventSink eventSink = new(); + using StaRuntime runtime = CreateRuntime(); + MxAccessStaSession session = new(runtime, factory, eventSink); + await session.StartAsync(workerProcessId: 1234); + + session.Dispose(); + + Assert.Equal(runtime.StaThreadId, eventSink.DetachThreadId); + } + + private static StaRuntime CreateRuntime() + { + return new StaRuntime( + new NoopComApartmentInitializer(), + new StaMessagePump(), + TimeSpan.FromMilliseconds(25)); + } + + /// + /// Fake MXAccess COM object factory for testing. + /// + private sealed class FakeMxAccessComObjectFactory : IMxAccessComObjectFactory + { + private readonly Exception? exception; + + /// + /// Initializes a fake factory that optionally throws an exception. + /// + /// Exception to throw when Create is called; null to succeed. + public FakeMxAccessComObjectFactory(Exception? exception = null) + { + this.exception = exception; + } + + /// + /// Gets the COM object created by this factory. + /// + public object CreatedObject { get; } = new(); + + /// + /// Gets the managed thread ID when Create was called. + /// + public int? CreateThreadId { get; private set; } + + /// + /// Gets the apartment state when Create was called. + /// + public ApartmentState? CreateApartmentState { get; private set; } + + /// + /// Creates the COM object or throws the configured exception. + /// + public object Create() + { + CreateThreadId = Thread.CurrentThread.ManagedThreadId; + CreateApartmentState = Thread.CurrentThread.GetApartmentState(); + + if (exception is not null) + { + throw exception; + } + + return CreatedObject; + } + } + + /// + /// Fake MXAccess event sink for testing. + /// + private sealed class FakeMxAccessEventSink : IMxAccessEventSink + { + /// + /// Gets the attached MXAccess COM object. + /// + public object? AttachedObject { get; private set; } + + /// + /// Gets the managed thread ID when Attach was called. + /// + public int? AttachThreadId { get; private set; } + + /// + /// Gets the managed thread ID when Detach was called. + /// + public int? DetachThreadId { get; private set; } + + /// + /// Gets the session identifier. + /// + public string? SessionId { get; private set; } + + /// + /// Attaches the MXAccess COM object and records thread context. + /// + /// MXAccess COM object to attach. + /// Identifier of the session. + public void Attach( + object mxAccessComObject, + string sessionId) + { + AttachedObject = mxAccessComObject; + AttachThreadId = Thread.CurrentThread.ManagedThreadId; + SessionId = sessionId; + } + + /// + /// Detaches the MXAccess COM object and records thread context. + /// + public void Detach() + { + DetachThreadId = Thread.CurrentThread.ManagedThreadId; + AttachedObject = null; + } + } + + /// + /// Gap 1: Verifies that when MxAccessStaSession is created with an alarm handler factory, + /// a SubscribeAlarms command dispatched through the session reaches the handler. + /// This proves the fix in WorkerPipeSession (and the new internal constructor) correctly + /// wires the factory rather than leaving alarmCommandHandler null. + /// + [Fact] + public async Task StartAsync_WithAlarmCommandHandlerFactory_SubscribeAlarmsCommandReachesHandler() + { + FakeAlarmCommandHandler handler = new(); + FakeMxAccessComObjectFactory factory = new(); + FakeMxAccessEventSink eventSink = new(); + using StaRuntime runtime = CreateRuntime(); + using MxAccessStaSession session = new( + runtime, + factory, + eventSink, + new MxAccessEventQueue(), + (_eq, _affinity) => handler); + + await session.StartAsync("session-1", workerProcessId: 1); + + StaCommand subscribeCommand = new StaCommand( + "session-1", + "corr-1", + new MxCommand + { + Kind = MxCommandKind.SubscribeAlarms, + SubscribeAlarms = new SubscribeAlarmsCommand + { + SubscriptionExpression = @"\\HOST\Galaxy!Area", + }, + }); + + MxCommandReply reply = await session.DispatchAsync(subscribeCommand); + + Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); + Assert.True(handler.IsSubscribed); + Assert.Equal(@"\\HOST\Galaxy!Area", handler.LastSubscription); + } + + /// + /// Gap 1: Verifies that when MxAccessStaSession is created without an alarm + /// command handler factory, SubscribeAlarms returns InvalidRequest with the + /// exact "SubscribeAlarms requires an alarm command handler; the worker was + /// constructed without one." diagnostic. The full phrase is asserted so the + /// test fails if the diagnostic regresses to a misleading message that still + /// happens to contain the word "alarm". + /// + [Fact] + public async Task StartAsync_WithoutAlarmCommandHandlerFactory_SubscribeAlarmsReturnsInvalidRequest() + { + FakeMxAccessComObjectFactory factory = new(); + FakeMxAccessEventSink eventSink = new(); + using StaRuntime runtime = CreateRuntime(); + // Use the 4-arg (no factory) constructor — equivalent to the old MxAccessStaSession() + using MxAccessStaSession session = new(runtime, factory, eventSink); + + await session.StartAsync("session-1", workerProcessId: 1); + + StaCommand subscribeCommand = new StaCommand( + "session-1", + "corr-1", + new MxCommand + { + Kind = MxCommandKind.SubscribeAlarms, + SubscribeAlarms = new SubscribeAlarmsCommand + { + SubscriptionExpression = @"\\HOST\Galaxy!Area", + }, + }); + + MxCommandReply reply = await session.DispatchAsync(subscribeCommand); + + Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code); + Assert.Equal( + "SubscribeAlarms requires an alarm command handler; the worker was constructed without one.", + reply.DiagnosticMessage); + } + + /// + /// Gap 2: Verifies that after StartAsync with an alarm handler factory, the STA poll + /// loop calls PollOnce on the handler via the STA within a reasonable timeout. + /// This proves polling is driven by the STA rather than the consumer's internal timer. + /// + [Fact] + public async Task StartAsync_WithAlarmCommandHandlerFactory_PollOnceCalledViaSta() + { + FakeAlarmCommandHandler handler = new(); + FakeMxAccessComObjectFactory factory = new(); + FakeMxAccessEventSink eventSink = new(); + using StaRuntime runtime = CreateRuntime(); + using MxAccessStaSession session = new( + runtime, + factory, + eventSink, + new MxAccessEventQueue(), + (_eq, _affinity) => handler); + + await session.StartAsync("session-1", workerProcessId: 1); + + // Wait up to 3s for at least one PollOnce call from the STA poll loop. + using CancellationTokenSource timeout = new CancellationTokenSource(TimeSpan.FromSeconds(3)); + while (handler.PollCount == 0 && !timeout.IsCancellationRequested) + { + await Task.Delay(50, CancellationToken.None); + } + + Assert.True(handler.PollCount > 0, + "Expected PollOnce to be called at least once by the STA poll loop within 3 seconds."); + Assert.NotNull(handler.LastPollThreadId); + Assert.Equal(runtime.StaThreadId, handler.LastPollThreadId); + } + + /// + /// Gap 2: Verifies that the STA poll loop stops when the session is disposed — + /// no further PollOnce calls after disposal. + /// joins the poll task before returning, so once Dispose returns no PollOnce + /// call can still be in flight. The test asserts the poll count is frozen + /// immediately after Dispose and stays frozen — deterministic, with no + /// elapsed-time "no further polls" window that a slow agent could race. + /// + [Fact] + public async Task Dispose_StopsAlarmPollLoop() + { + FakeAlarmCommandHandler handler = new(); + FakeMxAccessComObjectFactory factory = new(); + FakeMxAccessEventSink eventSink = new(); + using StaRuntime runtime = CreateRuntime(); + // using declaration: if an assertion below throws before the explicit + // Dispose, the session (its STA poll loop and alarm handler) is still + // torn down. Dispose is idempotent, so the explicit call mid-test and + // the using-scope call do not conflict. + using MxAccessStaSession session = new( + runtime, + factory, + eventSink, + new MxAccessEventQueue(), + (_eq, _affinity) => handler); + + await session.StartAsync("session-1", workerProcessId: 1); + + // Wait for at least one poll to occur, then dispose. + using CancellationTokenSource initTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(3)); + while (handler.PollCount == 0 && !initTimeout.IsCancellationRequested) + { + await Task.Delay(50, CancellationToken.None); + } + + Assert.True(handler.PollCount > 0, "Prerequisite: poll loop must have fired before dispose."); + + // Dispose joins the poll task; when it returns the loop has stopped + // and no PollOnce call is still running. + session.Dispose(); + int pollCountAtDispose = handler.PollCount; + + // The count is already frozen — re-reading after a yield must not + // observe any further poll. This is a deterministic check, not a + // timing window: a poll cannot start once the joined loop has exited. + await Task.Yield(); + Assert.Equal(pollCountAtDispose, handler.PollCount); + } + + /// + /// Worker-005 regression: when the alarm poll loop's PollOnce throws a + /// real failure (e.g. a COMException from GetXmlCurrentAlarms2), the + /// failure must be recorded as a fault on the event queue so a broken + /// alarm subscription becomes observable on the IPC fault path instead + /// of silently faulting the never-awaited poll task. + /// + [Fact] + public async Task RunAlarmPollLoop_WhenPollOnceThrows_RecordsFaultOnEventQueue() + { + FakeAlarmCommandHandler handler = new() + { + PollException = new System.Runtime.InteropServices.COMException( + "GetXmlCurrentAlarms2 failed.", unchecked((int)0x80004005)), + }; + FakeMxAccessComObjectFactory factory = new(); + FakeMxAccessEventSink eventSink = new(); + using StaRuntime runtime = CreateRuntime(); + MxAccessEventQueue eventQueue = new(); + using MxAccessStaSession session = new( + runtime, + factory, + eventSink, + eventQueue, + (_eq, _affinity) => handler); + + await session.StartAsync("session-1", workerProcessId: 1); + + // Wait up to 5s for the poll loop to fault the queue. + using CancellationTokenSource timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + while (!eventQueue.IsFaulted && !timeout.IsCancellationRequested) + { + await Task.Delay(50, CancellationToken.None); + } + + Assert.True(eventQueue.IsFaulted, "Expected the alarm poll failure to fault the event queue."); + WorkerFault? fault = session.DrainFault(); + Assert.NotNull(fault); + Assert.Equal(WorkerFaultCategory.MxaccessEventConversionFailed, fault!.Category); + Assert.Contains("alarm poll failed", fault.DiagnosticMessage, StringComparison.OrdinalIgnoreCase); + Assert.Equal(typeof(System.Runtime.InteropServices.COMException).FullName, fault.ExceptionType); + } + + /// + /// Worker-016 regression: the alarm poll loop's catch for the graceful + /// STA-runtime-shutdown signal must NOT also swallow a vanilla + /// raised from inside the marshalled + /// poll lambda — for example the STA-affinity assertion thrown by + /// EnsureOnAlarmConsumerThread if a regression ever caused the poll + /// to run off the alarm-consumer thread. The runtime-shutdown signal is now + /// the dedicated ; a plain + /// from PollOnce must reach + /// the fault-recording arm and become observable on the event queue. + /// + [Fact] + public async Task RunAlarmPollLoop_WhenPollOnceThrowsInvalidOperation_RecordsFaultOnEventQueue() + { + FakeAlarmCommandHandler handler = new() + { + PollException = new InvalidOperationException( + "Alarm consumer accessed off its owning STA thread."), + }; + FakeMxAccessComObjectFactory factory = new(); + FakeMxAccessEventSink eventSink = new(); + using StaRuntime runtime = CreateRuntime(); + MxAccessEventQueue eventQueue = new(); + using MxAccessStaSession session = new( + runtime, + factory, + eventSink, + eventQueue, + (_eq, _affinity) => handler); + + await session.StartAsync("session-1", workerProcessId: 1); + + using CancellationTokenSource timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + while (!eventQueue.IsFaulted && !timeout.IsCancellationRequested) + { + await Task.Delay(50, CancellationToken.None); + } + + Assert.True( + eventQueue.IsFaulted, + "Expected the alarm poll InvalidOperationException to fault the event queue, " + + "not be silently swallowed as a shutdown signal."); + WorkerFault? fault = session.DrainFault(); + Assert.NotNull(fault); + Assert.Equal(WorkerFaultCategory.MxaccessEventConversionFailed, fault!.Category); + Assert.Equal(typeof(InvalidOperationException).FullName, fault.ExceptionType); + Assert.Contains("alarm poll failed", fault.DiagnosticMessage, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Worker-008 regression: the STA-affinity guard throws when an + /// IMxAccessAlarmConsumer call is attempted off the thread that created + /// the consumer, mirroring the MxAccessSession.CreationThreadId invariant. + /// + [Fact] + public void AssertOnAlarmConsumerThread_WhenOffOwningThread_Throws() + { + const int owningThread = 7; + const int otherThread = 99; + + InvalidOperationException exception = Assert.Throws( + () => MxAccessStaSession.AssertOnAlarmConsumerThread(owningThread, otherThread)); + + Assert.Contains("off its owning STA thread", exception.Message, StringComparison.Ordinal); + } + + /// + /// Worker-008: the STA-affinity guard is a no-op on the owning thread and + /// when no alarm consumer is configured (expected thread id null). + /// + [Fact] + public void AssertOnAlarmConsumerThread_OnOwningThreadOrUnset_DoesNotThrow() + { + MxAccessStaSession.AssertOnAlarmConsumerThread(expectedThreadId: 42, actualThreadId: 42); + MxAccessStaSession.AssertOnAlarmConsumerThread(expectedThreadId: null, actualThreadId: 123); + } + + /// + /// Fake alarm command handler that records calls and tracks poll thread. + /// + private sealed class FakeAlarmCommandHandler : IAlarmCommandHandler + { + private readonly object gate = new object(); + private int pollCount; + private int? lastPollThreadId; + + public bool IsSubscribed { get; private set; } + public string? LastSubscription { get; private set; } + + /// Exception thrown by PollOnce; null to succeed. + public Exception? PollException { get; set; } + + public int PollCount + { + get { lock (gate) return pollCount; } + } + + public int? LastPollThreadId + { + get { lock (gate) return lastPollThreadId; } + } + + public void Subscribe(string subscription, string sessionId) + { + IsSubscribed = true; + LastSubscription = subscription; + } + + public void Unsubscribe() + { + IsSubscribed = false; + } + + public int Acknowledge(Guid alarmGuid, string comment, string operatorUser, + string operatorNode, string operatorDomain, string operatorFullName) + => 0; + + public int AcknowledgeByName(string alarmName, string providerName, string groupName, + string comment, string operatorUser, string operatorNode, + string operatorDomain, string operatorFullName) + => 0; + + public IReadOnlyList QueryActive(string? alarmFilterPrefix) + => Array.Empty(); + + public void PollOnce() + { + lock (gate) + { + pollCount++; + lastPollThreadId = Thread.CurrentThread.ManagedThreadId; + } + + if (PollException is not null) + { + throw PollException; + } + } + + public void Dispose() { } + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessValueCacheTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessValueCacheTests.cs new file mode 100644 index 0000000..4064883 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessValueCacheTests.cs @@ -0,0 +1,187 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Google.Protobuf.WellKnownTypes; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Worker.MxAccess; + +namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess; + +/// +/// Unit tests for . The cache is consumed by +/// to satisfy "current value" +/// requests for already-advised tags without touching the existing +/// subscription, so its contract is exercised in isolation here before any +/// STA / COM plumbing gets layered on top. +/// +public sealed class MxAccessValueCacheTests +{ + [Fact] + public void Set_ThenTryGet_ReturnsLastValueWithIncrementingVersion() + { + MxAccessValueCache cache = new(); + Timestamp sourceTimestamp = Timestamp.FromDateTime(new(2026, 5, 19, 9, 0, 0, DateTimeKind.Utc)); + + cache.Set(serverHandle: 7, itemHandle: 21, BuildEvent(serverHandle: 7, itemHandle: 21, intValue: 100, quality: 192, sourceTimestamp)); + + Assert.True(cache.TryGet(7, 21, out MxAccessValueCache.CachedValue first)); + Assert.Equal(1UL, first.Version); + Assert.Equal(100, first.Value.Int32Value); + Assert.Equal(192, first.Quality); + Assert.Equal(sourceTimestamp, first.SourceTimestamp); + + // A second Set on the same key bumps the version and overwrites the + // payload. Different keys remain isolated. + cache.Set(7, 21, BuildEvent(7, 21, intValue: 200, quality: 192, sourceTimestamp)); + cache.Set(7, 22, BuildEvent(7, 22, intValue: 999, quality: 192, sourceTimestamp)); + + Assert.True(cache.TryGet(7, 21, out MxAccessValueCache.CachedValue second)); + Assert.Equal(2UL, second.Version); + Assert.Equal(200, second.Value.Int32Value); + + Assert.True(cache.TryGet(7, 22, out MxAccessValueCache.CachedValue other)); + Assert.Equal(1UL, other.Version); + Assert.Equal(999, other.Value.Int32Value); + } + + [Fact] + public void TryGet_WithUnknownHandle_ReturnsFalse() + { + MxAccessValueCache cache = new(); + + Assert.False(cache.TryGet(serverHandle: 7, itemHandle: 21, out _)); + } + + [Fact] + public void Remove_DropsEntryAndResetsVersion() + { + MxAccessValueCache cache = new(); + cache.Set(7, 21, BuildEvent(7, 21, intValue: 1, quality: 192, Timestamp.FromDateTime(DateTime.UtcNow))); + cache.Set(7, 21, BuildEvent(7, 21, intValue: 2, quality: 192, Timestamp.FromDateTime(DateTime.UtcNow))); + + cache.Remove(7, 21); + Assert.False(cache.TryGet(7, 21, out _)); + + // After Remove, a subsequent Set restarts the per-handle version from 1 + // — the cache must not serve a stale "version 3" entry that would race + // against a reused MXAccess item handle. + cache.Set(7, 21, BuildEvent(7, 21, intValue: 3, quality: 192, Timestamp.FromDateTime(DateTime.UtcNow))); + Assert.True(cache.TryGet(7, 21, out MxAccessValueCache.CachedValue reset)); + Assert.Equal(1UL, reset.Version); + } + + [Fact] + public void CurrentVersion_ReturnsZeroForUnknown_AndLatestForKnown() + { + MxAccessValueCache cache = new(); + Assert.Equal(0UL, cache.CurrentVersion(7, 21)); + + cache.Set(7, 21, BuildEvent(7, 21, intValue: 1, quality: 192, Timestamp.FromDateTime(DateTime.UtcNow))); + cache.Set(7, 21, BuildEvent(7, 21, intValue: 2, quality: 192, Timestamp.FromDateTime(DateTime.UtcNow))); + + Assert.Equal(2UL, cache.CurrentVersion(7, 21)); + } + + /// + /// Worker.Tests-020: pins the contract that TryWaitForUpdate + /// returns false when the deadline has elapsed with no + /// Set, yields a default CachedValue, and invokes + /// pumpStep at least once so MXAccess Windows messages can + /// be dispatched. Earlier revisions of this test asserted both an + /// elapsed-time floor (stopwatch.ElapsedMilliseconds >= 60) + /// and pumpCalls > 1 — the same wall-clock-floor race + /// pattern Worker.Tests-003/004/013 corrected. To eliminate the + /// timing dependency entirely (the equivalent of a manual time + /// source for a DateTime.UtcNow-based deadline), the test + /// now supplies a deadline already in the past: the loop pumps + /// once, observes the passed deadline, and returns false + /// deterministically without any Thread.Sleep. The + /// deadline-honouring contract is what this test exists to pin; + /// elapsed time and pump-iteration count are incidental. + /// + [Fact] + public void TryWaitForUpdate_ReturnsFalseAfterDeadline_WhenNoSetOccurs() + { + MxAccessValueCache cache = new(); + int pumpCalls = 0; + + // Deadline already in the past — eliminates the wall-clock-floor + // race. The loop must pump once (so MXAccess messages can dispatch + // on the calling thread even when the deadline has just expired) + // and then immediately observe the passed deadline. + DateTime expiredDeadlineUtc = DateTime.UtcNow.AddMilliseconds(-1); + + bool result = cache.TryWaitForUpdate( + serverHandle: 7, + itemHandle: 21, + sinceVersion: 0, + deadlineUtc: expiredDeadlineUtc, + pumpStep: () => Interlocked.Increment(ref pumpCalls), + out MxAccessValueCache.CachedValue value, + pollIntervalMs: 5); + + Assert.False(result); + Assert.Equal(default, value.Value); + Assert.Equal(1, pumpCalls); + } + + [Fact] + public async Task TryWaitForUpdate_ReturnsTrue_WhenSetFiresAfterBaselineVersion() + { + MxAccessValueCache cache = new(); + Timestamp sourceTimestamp = Timestamp.FromDateTime(DateTime.UtcNow); + // Baseline is "no entry yet" → wait for the first Set to land. + Task<(bool ok, MxAccessValueCache.CachedValue value)> waitTask = Task.Run(() => + { + bool ok = cache.TryWaitForUpdate( + serverHandle: 7, + itemHandle: 21, + sinceVersion: 0, + deadlineUtc: DateTime.UtcNow.AddSeconds(2), + pumpStep: () => { }, + out MxAccessValueCache.CachedValue v, + pollIntervalMs: 5); + return (ok, v); + }); + + // Race a Set against the wait loop. The cache's lock guarantees the + // wait observes the new version before TryGet returns it. + await Task.Delay(20); + cache.Set(7, 21, BuildEvent(7, 21, intValue: 4242, quality: 192, sourceTimestamp)); + + (bool ok, MxAccessValueCache.CachedValue value) = await waitTask; + Assert.True(ok); + Assert.Equal(4242, value.Value.Int32Value); + Assert.Equal(1UL, value.Version); + } + + private static MxEvent BuildEvent( + int serverHandle, + int itemHandle, + int intValue, + int quality, + Timestamp sourceTimestamp) + { + MxEvent mxEvent = new() + { + Family = MxEventFamily.OnDataChange, + ServerHandle = serverHandle, + ItemHandle = itemHandle, + Quality = quality, + SourceTimestamp = sourceTimestamp, + Value = new MxValue + { + DataType = MxDataType.Integer, + VariantType = "VT_I4", + Int32Value = intValue, + }, + OnDataChange = new OnDataChangeEvent(), + }; + mxEvent.Statuses.Add(new MxStatusProxy + { + Category = MxStatusCategory.Ok, + }); + return mxEvent; + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/WnWrapAlarmConsumerXmlTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/WnWrapAlarmConsumerXmlTests.cs new file mode 100644 index 0000000..062fefc --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/WnWrapAlarmConsumerXmlTests.cs @@ -0,0 +1,320 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading; +using ZB.MOM.WW.MxGateway.Worker.MxAccess; + +namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess; + +/// +/// Unit-test coverage for 's pure +/// parsing helpers — XML payload → +/// dictionary, and the 32-char-hex GUID round-trip. The COM-side +/// polling loop is verified separately by the Skip-gated +/// WnWrapConsumerProbeTests on a live AVEVA install. +/// +public sealed class WnWrapAlarmConsumerXmlTests +{ + /// Captured XML from the dev rig (probe run 2026-05-01). + private const string SingleAlarmActiveXml = + "" + + "BCC4705395424D65BDAABCDEA6A32A73" + + "2026/5/1" + + "2400" + + "DESKTOP-6JL3KKO" + + "Galaxy" + + "TestArea" + + "TestMachine_001.TestAlarm001" + + "DSCtruetrue" + + "500UNACK_ALM" + + "" + + "Test alarm #1" + + ""; + + private const string EmptyXml = + ""; + + [Fact] + public void ParseSnapshotXml_WithEmptyPayload_ReturnsEmptyDictionary() + { + var records = WnWrapAlarmConsumer.ParseSnapshotXml(EmptyXml); + Assert.Empty(records); + } + + [Fact] + public void ParseSnapshotXml_WithNullOrWhitespace_ReturnsEmptyDictionary() + { + Assert.Empty(WnWrapAlarmConsumer.ParseSnapshotXml("")); + Assert.Empty(WnWrapAlarmConsumer.ParseSnapshotXml(" ")); + } + + [Fact] + public void ParseSnapshotXml_WithSingleActiveAlarm_DecodesRecord() + { + var records = WnWrapAlarmConsumer.ParseSnapshotXml(SingleAlarmActiveXml); + + Assert.Single(records); + Guid expectedGuid = new Guid("BCC47053-9542-4D65-BDAA-BCDEA6A32A73"); + var record = records[expectedGuid]; + Assert.Equal(expectedGuid, record.AlarmGuid); + Assert.Equal("DESKTOP-6JL3KKO", record.ProviderNode); + Assert.Equal("Galaxy", record.ProviderName); + Assert.Equal("TestArea", record.Group); + Assert.Equal("TestMachine_001.TestAlarm001", record.TagName); + Assert.Equal("DSC", record.Type); + Assert.Equal("true", record.Value); + Assert.Equal("true", record.Limit); + Assert.Equal(500, record.Priority); + Assert.Equal(MxAlarmStateKind.UnackAlm, record.State); + Assert.Equal("Test alarm #1", record.AlarmComment); + Assert.Equal(DateTimeKind.Utc, record.TransitionTimestampUtc.Kind); + // 13:26:14.709 EDT (UTC-4, DSTADJUST=0) + 240 minutes = 17:26:14.709 UTC. + Assert.Equal(17, record.TransitionTimestampUtc.Hour); + Assert.Equal(26, record.TransitionTimestampUtc.Minute); + } + + [Fact] + public void ParseSnapshotXml_WithInvalidGuids_SilentlyDropsRecords() + { + string xml = SingleAlarmActiveXml.Replace( + "BCC4705395424D65BDAABCDEA6A32A73", + "not-a-guid"); + Assert.Empty(WnWrapAlarmConsumer.ParseSnapshotXml(xml)); + } + + [Theory] + [InlineData("BCC4705395424D65BDAABCDEA6A32A73", "BCC47053-9542-4D65-BDAA-BCDEA6A32A73")] + [InlineData("00000000000000000000000000000000", "00000000-0000-0000-0000-000000000000")] + public void TryParseHexGuid_WithDashless32CharHex_Parses(string hex, string expected) + { + Assert.True(WnWrapAlarmConsumer.TryParseHexGuid(hex, out Guid guid)); + Assert.Equal(new Guid(expected), guid); + } + + [Theory] + [InlineData("BCC47053-9542-4D65-BDAA-BCDEA6A32A73")] + public void TryParseHexGuid_WithCanonicalDashedForm_Accepts(string canonical) + { + Assert.True(WnWrapAlarmConsumer.TryParseHexGuid(canonical, out Guid guid)); + Assert.Equal(new Guid(canonical), guid); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("nope")] + [InlineData("0123456789ABCDEF")] // too short + [InlineData("BCC4705395424D65BDAABCDEA6A32A73XX")] // too long + public void TryParseHexGuid_WithInvalidInput_Rejects(string? hex) + { + Assert.False(WnWrapAlarmConsumer.TryParseHexGuid(hex, out Guid guid)); + Assert.Equal(Guid.Empty, guid); + } + + /// + /// Worker-001 regression: the consumer must own no internal + /// . A thread-pool timer calling the + /// apartment-threaded wnwrap COM object off its owning STA can + /// deadlock on cross-apartment marshaling, so the timer field and + /// callback must not exist on the type. + /// + [Fact] + public void WnWrapAlarmConsumer_ByReflection_HasNoInternalTimerField() + { + FieldInfo[] fields = typeof(WnWrapAlarmConsumer) + .GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + + Assert.DoesNotContain(fields, field => field.FieldType == typeof(Timer)); + Assert.Null(typeof(WnWrapAlarmConsumer).GetMethod( + "OnPoll", + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)); + } + + /// + /// Worker-001 regression: no public constructor may accept a + /// poll-interval parameter. A non-zero poll interval was the only + /// way to arm the off-STA timer; removing the parameter makes the + /// footgun structurally unreachable. + /// + [Fact] + public void WnWrapAlarmConsumer_ByReflection_ExposesNoPollIntervalConstructorParameter() + { + foreach (ConstructorInfo constructor in typeof(WnWrapAlarmConsumer) + .GetConstructors(BindingFlags.Instance | BindingFlags.Public)) + { + Assert.DoesNotContain( + constructor.GetParameters(), + parameter => parameter.Name is not null + && parameter.Name.IndexOf("poll", StringComparison.OrdinalIgnoreCase) >= 0); + } + } + + /// + /// Worker.Tests-022: pins the "new alarm sighting" branch of + /// . A GUID + /// that appears in next but not in previous must + /// produce exactly one transition with + /// as the previous + /// state — the proto layer relies on this sentinel to map a + /// first sighting to a Raise. + /// + [Fact] + public void ComputeTransitions_WhenAlarmIsNewInNextSnapshot_EmitsTransitionWithUnspecifiedPreviousState() + { + Guid alarmGuid = new Guid("BCC47053-9542-4D65-BDAA-BCDEA6A32A73"); + Dictionary previous = new(); + Dictionary next = new() + { + [alarmGuid] = NewRecord(alarmGuid, MxAlarmStateKind.UnackAlm), + }; + + IReadOnlyList transitions = + WnWrapAlarmConsumer.ComputeTransitions(previous, next); + + MxAlarmTransitionEvent single = Assert.Single(transitions); + Assert.Equal(alarmGuid, single.Record.AlarmGuid); + Assert.Equal(MxAlarmStateKind.UnackAlm, single.Record.State); + Assert.Equal(MxAlarmStateKind.Unspecified, single.PreviousState); + } + + /// + /// Worker.Tests-022: pins the "state unchanged" branch. A GUID + /// present in both snapshots with identical + /// must produce no + /// transition — a regression that emits a transition every poll + /// regardless of state change would slip through without this + /// test. + /// + [Fact] + public void ComputeTransitions_WhenAlarmStateUnchanged_EmitsNoTransition() + { + Guid alarmGuid = Guid.NewGuid(); + Dictionary previous = new() + { + [alarmGuid] = NewRecord(alarmGuid, MxAlarmStateKind.UnackAlm), + }; + Dictionary next = new() + { + [alarmGuid] = NewRecord(alarmGuid, MxAlarmStateKind.UnackAlm), + }; + + IReadOnlyList transitions = + WnWrapAlarmConsumer.ComputeTransitions(previous, next); + + Assert.Empty(transitions); + } + + /// + /// Worker.Tests-022: pins the "state changed" branch. A GUID + /// present in both snapshots with a different state must produce + /// one transition carrying the prior state so the proto layer + /// can distinguish e.g. UnackAlmAckAlm + /// (Acknowledge) from UnspecifiedUnackAlm (Raise). + /// + [Fact] + public void ComputeTransitions_WhenAlarmStateChanged_EmitsTransitionWithPriorState() + { + Guid alarmGuid = Guid.NewGuid(); + Dictionary previous = new() + { + [alarmGuid] = NewRecord(alarmGuid, MxAlarmStateKind.UnackAlm), + }; + Dictionary next = new() + { + [alarmGuid] = NewRecord(alarmGuid, MxAlarmStateKind.AckAlm), + }; + + IReadOnlyList transitions = + WnWrapAlarmConsumer.ComputeTransitions(previous, next); + + MxAlarmTransitionEvent single = Assert.Single(transitions); + Assert.Equal(alarmGuid, single.Record.AlarmGuid); + Assert.Equal(MxAlarmStateKind.AckAlm, single.Record.State); + Assert.Equal(MxAlarmStateKind.UnackAlm, single.PreviousState); + } + + /// + /// Worker.Tests-022: pins the "alarm cleared from the active set" + /// branch. AVEVA drops cleared alarms from + /// GetXmlCurrentAlarms2's active set rather than emitting a + /// transition record. A GUID present in + /// previous but absent from next must therefore + /// produce no transition; the diff treats disappearance as an + /// implicit clear that the proto layer recognises by the missing + /// GUID, not by an emitted event. + /// + [Fact] + public void ComputeTransitions_WhenAlarmDroppedFromActiveSet_EmitsNoTransition() + { + Guid alarmGuid = Guid.NewGuid(); + Dictionary previous = new() + { + [alarmGuid] = NewRecord(alarmGuid, MxAlarmStateKind.UnackAlm), + }; + Dictionary next = new(); + + IReadOnlyList transitions = + WnWrapAlarmConsumer.ComputeTransitions(previous, next); + + Assert.Empty(transitions); + } + + /// + /// Worker.Tests-022: pins the multi-alarm fan-out. Multiple + /// simultaneous transitions (new + changed + unchanged + dropped) + /// in one snapshot must produce exactly the changed and new + /// entries — not the unchanged and not the dropped. + /// + [Fact] + public void ComputeTransitions_WithMixedDelta_EmitsOnlyNewAndChangedTransitions() + { + Guid newGuid = Guid.NewGuid(); + Guid changedGuid = Guid.NewGuid(); + Guid unchangedGuid = Guid.NewGuid(); + Guid droppedGuid = Guid.NewGuid(); + + Dictionary previous = new() + { + [changedGuid] = NewRecord(changedGuid, MxAlarmStateKind.UnackAlm), + [unchangedGuid] = NewRecord(unchangedGuid, MxAlarmStateKind.AckAlm), + [droppedGuid] = NewRecord(droppedGuid, MxAlarmStateKind.UnackAlm), + }; + Dictionary next = new() + { + [newGuid] = NewRecord(newGuid, MxAlarmStateKind.UnackAlm), + [changedGuid] = NewRecord(changedGuid, MxAlarmStateKind.AckAlm), + [unchangedGuid] = NewRecord(unchangedGuid, MxAlarmStateKind.AckAlm), + }; + + IReadOnlyList transitions = + WnWrapAlarmConsumer.ComputeTransitions(previous, next); + + Assert.Equal(2, transitions.Count); + + MxAlarmTransitionEvent newTransition = Assert.Single( + transitions, + t => t.Record.AlarmGuid == newGuid); + Assert.Equal(MxAlarmStateKind.Unspecified, newTransition.PreviousState); + Assert.Equal(MxAlarmStateKind.UnackAlm, newTransition.Record.State); + + MxAlarmTransitionEvent changedTransition = Assert.Single( + transitions, + t => t.Record.AlarmGuid == changedGuid); + Assert.Equal(MxAlarmStateKind.UnackAlm, changedTransition.PreviousState); + Assert.Equal(MxAlarmStateKind.AckAlm, changedTransition.Record.State); + } + + private static MxAlarmSnapshotRecord NewRecord(Guid guid, MxAlarmStateKind state) + { + return new MxAlarmSnapshotRecord + { + AlarmGuid = guid, + State = state, + TagName = "TestMachine.TestAlarm", + ProviderNode = "TEST-NODE", + ProviderName = "Galaxy", + }; + } +} diff --git a/src/MxGateway.Worker.Tests/AlarmClientWmProbeTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmClientWmProbeTests.cs similarity index 99% rename from src/MxGateway.Worker.Tests/AlarmClientWmProbeTests.cs rename to src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmClientWmProbeTests.cs index ad716de..8f7fbb6 100644 --- a/src/MxGateway.Worker.Tests/AlarmClientWmProbeTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmClientWmProbeTests.cs @@ -11,7 +11,7 @@ using aaAlarmManagedClient; using ArchestrA.MxAccess; using Xunit.Abstractions; -namespace MxGateway.Worker.Tests; +namespace ZB.MOM.WW.MxGateway.Worker.Tests.Probes; /// /// Runtime probe — registers as an AlarmClient consumer with a real @@ -141,7 +141,7 @@ public sealed class AlarmClientWmProbeTests : IDisposable } [Fact(Skip = "Runtime probe — flip Skip=null on the dev rig (AVEVA installed) to capture alarm-path behavior")] - public void ProbeAlarmClientWmMessages() + public void ProbeAlarmClient_OnDevRig_LogsAlarmWindowMessages() { // 1. Pre-resolve a few candidate RegisterWindowMessage strings so any // matches in the captured log can be labeled. None of these is diff --git a/src/MxGateway.Worker.Tests/AlarmsLiveSmokeTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmsLiveSmokeTests.cs similarity index 92% rename from src/MxGateway.Worker.Tests/AlarmsLiveSmokeTests.cs rename to src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmsLiveSmokeTests.cs index a99e3d6..d2cf2ff 100644 --- a/src/MxGateway.Worker.Tests/AlarmsLiveSmokeTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmsLiveSmokeTests.cs @@ -1,13 +1,12 @@ using System; using System.Collections.Concurrent; using System.Diagnostics; -using System.Linq; using System.Threading; -using MxGateway.Contracts.Proto; -using MxGateway.Worker.MxAccess; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Worker.MxAccess; using Xunit.Abstractions; -namespace MxGateway.Worker.Tests; +namespace ZB.MOM.WW.MxGateway.Worker.Tests.Probes; /// /// Live dev-rig smoke test for the alarms-over-gateway pipeline. @@ -43,7 +42,7 @@ public sealed class AlarmsLiveSmokeTests } [Fact(Skip = "Live dev-rig smoke test — flip Skip=null with AVEVA + the alarm flip script running. Verified working 2026-05-01.")] - public void Alarms_full_pipeline_round_trip() + public void Alarms_FullPipelineRoundTrip_RaisesAndAcknowledges() { Exception? threadException = null; var done = new ManualResetEventSlim(false); @@ -77,13 +76,11 @@ public sealed class AlarmsLiveSmokeTests Log($"Pump duration: {PumpDuration.TotalSeconds:F0}s; transition wait timeout: {TransitionWaitTimeout.TotalSeconds:F0}s"); MxAccessEventQueue queue = new MxAccessEventQueue(); - // pollIntervalMs=0 disables the internal Timer; we drive PollOnce - // manually from the STA below to avoid threadpool→STA marshaling - // (the wnwrap COM is ThreadingModel=Apartment, and this test - // doesn't run a Win32 message pump on its STA). + // The consumer owns no internal timer; we drive PollOnce manually + // from the STA below (the wnwrap COM is ThreadingModel=Apartment, + // and this test doesn't run a Win32 message pump on its STA). WnWrapAlarmConsumer consumer = new WnWrapAlarmConsumer( new WNWRAPCONSUMERLib.wwAlarmConsumerClass(), - pollIntervalMilliseconds: 0, maxAlarmsPerFetch: 1024); MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper()); using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId); @@ -92,13 +89,10 @@ public sealed class AlarmsLiveSmokeTests dispatcher.Subscribe(SubscriptionExpression); Log("Subscribe -> ok. Driving PollOnce manually from this STA..."); - // The wnwrap COM object is ThreadingModel=Apartment. The consumer's - // internal Timer would fire on a threadpool thread and deadlock on - // cross-apartment marshaling without a Win32 message pump. For the - // smoke test we constructed the consumer with pollIntervalMs=0 - // (Timer disabled) and drive PollOnce manually here on the STA. - // Production hosting will route polls through the worker's - // StaRuntime in a follow-up PR. + // The wnwrap COM object is ThreadingModel=Apartment. The consumer + // owns no internal timer, so we drive PollOnce manually here on the + // STA. Production hosting routes polls through the worker's + // StaRuntime. // 1. Wait for the first transition (any kind), then keep waiting // for one with kind=Raise so the alarm is currently Active when diff --git a/src/MxGateway.Worker.Tests/WnWrapConsumerProbeTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/WnWrapConsumerProbeTests.cs similarity index 99% rename from src/MxGateway.Worker.Tests/WnWrapConsumerProbeTests.cs rename to src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/WnWrapConsumerProbeTests.cs index 3840e7d..ea585f4 100644 --- a/src/MxGateway.Worker.Tests/WnWrapConsumerProbeTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/WnWrapConsumerProbeTests.cs @@ -7,7 +7,7 @@ using System.Threading; using WNWRAPCONSUMERLib; using Xunit.Abstractions; -namespace MxGateway.Worker.Tests; +namespace ZB.MOM.WW.MxGateway.Worker.Tests.Probes; /// /// Runtime probe — instantiate AVEVA's standalone wnwrapConsumer COM @@ -52,7 +52,7 @@ public sealed class WnWrapConsumerProbeTests } [Fact(Skip = "Runtime probe — flip Skip=null on the dev rig (AVEVA installed) to capture wnwrapConsumer XML alarm output. Verified working 2026-05-01.")] - public void ProbeWnWrapConsumer() + public void ProbeWnWrapConsumer_OnDevRig_LogsXmlAlarmStream() { Exception? threadException = null; var done = new ManualResetEventSlim(false); diff --git a/src/MxGateway.Worker.Tests/ProjectStructure/WorkerProjectReferenceTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/ProjectStructure/WorkerProjectReferenceTests.cs similarity index 74% rename from src/MxGateway.Worker.Tests/ProjectStructure/WorkerProjectReferenceTests.cs rename to src/ZB.MOM.WW.MxGateway.Worker.Tests/ProjectStructure/WorkerProjectReferenceTests.cs index fb0dc1a..96d3622 100644 --- a/src/MxGateway.Worker.Tests/ProjectStructure/WorkerProjectReferenceTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/ProjectStructure/WorkerProjectReferenceTests.cs @@ -4,7 +4,7 @@ using System.IO; using System.Linq; using System.Xml.Linq; -namespace MxGateway.Worker.Tests.ProjectStructure; +namespace ZB.MOM.WW.MxGateway.Worker.Tests.ProjectStructure; public sealed class WorkerProjectReferenceTests { @@ -12,7 +12,7 @@ public sealed class WorkerProjectReferenceTests [Fact] public void WorkerProject_TargetsNet48AndX86() { - XDocument project = LoadProject("MxGateway.Worker"); + XDocument project = LoadProject("ZB.MOM.WW.MxGateway.Worker"); Assert.Equal("net48", ElementValue(project, "TargetFramework")); Assert.Equal("x86", ElementValue(project, "PlatformTarget")); @@ -23,15 +23,22 @@ public sealed class WorkerProjectReferenceTests [Fact] public void WorkerTestProject_TargetsNet48AndX86() { - XDocument project = LoadProject("MxGateway.Worker.Tests"); + XDocument project = LoadProject("ZB.MOM.WW.MxGateway.Worker.Tests"); Assert.Equal("net48", ElementValue(project, "TargetFramework")); Assert.Equal("x86", ElementValue(project, "PlatformTarget")); } - /// Verifies that MXAccess interop reference exists only in the worker project. + /// + /// Verifies that the MXAccess COM interop is referenced only by the + /// worker project and its test project — never by the gateway server + /// or the contracts project. The gateway must never load MXAccess COM + /// directly (see gateway.md); the worker test project + /// legitimately references the interop so it can exercise the + /// COM-facing worker code (e.g. WnWrapAlarmConsumer). + /// [Fact] - public void MxAccessInteropReference_ExistsOnlyInWorkerProject() + public void MxAccessInteropReference_ExistsOnlyInWorkerAndWorkerTestProjects() { DirectoryInfo repositoryRoot = FindRepositoryRoot(); string[] projectFiles = Directory.GetFiles(repositoryRoot.FullName, "*.csproj", SearchOption.AllDirectories) @@ -42,9 +49,12 @@ public sealed class WorkerProjectReferenceTests IReadOnlyList projectsWithMxAccessReference = projectFiles .Where(ProjectReferencesMxAccess) .Select(path => Path.GetFileNameWithoutExtension(path)) + .OrderBy(name => name, StringComparer.Ordinal) .ToArray(); - Assert.Equal(["MxGateway.Worker"], projectsWithMxAccessReference); + Assert.Equal( + ["ZB.MOM.WW.MxGateway.Worker", "ZB.MOM.WW.MxGateway.Worker.Tests"], + projectsWithMxAccessReference); } private static bool ProjectReferencesMxAccess(string projectPath) @@ -84,7 +94,7 @@ public sealed class WorkerProjectReferenceTests while (current is not null) { - if (File.Exists(Path.Combine(current.FullName, "MxGateway.sln"))) + if (File.Exists(Path.Combine(current.FullName, "ZB.MOM.WW.MxGateway.slnx"))) { return current; } @@ -92,6 +102,6 @@ public sealed class WorkerProjectReferenceTests current = current.Parent; } - throw new DirectoryNotFoundException("Could not locate src/MxGateway.sln from the test output directory."); + throw new DirectoryNotFoundException("Could not locate src/ZB.MOM.WW.MxGateway.slnx from the test output directory."); } } diff --git a/src/MxGateway.Worker.Tests/Sta/StaCommandDispatcherTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Sta/StaCommandDispatcherTests.cs similarity index 93% rename from src/MxGateway.Worker.Tests/Sta/StaCommandDispatcherTests.cs rename to src/ZB.MOM.WW.MxGateway.Worker.Tests/Sta/StaCommandDispatcherTests.cs index c81edda..7987190 100644 --- a/src/MxGateway.Worker.Tests/Sta/StaCommandDispatcherTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Sta/StaCommandDispatcherTests.cs @@ -4,10 +4,11 @@ using System.Linq; using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; -using MxGateway.Contracts.Proto; -using MxGateway.Worker.Sta; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Worker.Sta; +using ZB.MOM.WW.MxGateway.Worker.Tests.TestSupport; -namespace MxGateway.Worker.Tests.Sta; +namespace ZB.MOM.WW.MxGateway.Worker.Tests.Sta; /// /// Tests for StaCommandDispatcher command queueing and execution. @@ -87,10 +88,16 @@ public sealed class StaCommandDispatcherTests } /// - /// Verifies cancellation after execution starts still returns the reply once execution completes. + /// Verifies cancellation cannot abort a command already executing on the STA: + /// once the executor has started, cancelling the token is a no-op and the + /// command still runs to completion and returns its normal reply. This + /// matches gateway.md: cancellation "cannot safely abort an in-flight + /// COM call on the STA". The test does not — and cannot — distinguish "cancel + /// observed and ignored" from "cancel never checked"; it only proves the + /// in-flight command is not aborted. /// [Fact] - public async Task DispatchAsync_WhenCanceledAfterExecutionStarts_StillReturnsLateReply() + public async Task DispatchAsync_WhenCanceledWhileExecuting_DoesNotAbortInFlightCommand() { using StaRuntime runtime = CreateRuntime(); runtime.Start(); @@ -341,20 +348,4 @@ public sealed class StaCommandDispatcherTests throw exception; } } - - /// - /// No-op COM apartment initializer for testing. - /// - private sealed class NoopComApartmentInitializer : IStaComApartmentInitializer - { - /// - public void Initialize() - { - } - - /// - public void Uninitialize() - { - } - } } diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/Sta/StaMessagePumpTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Sta/StaMessagePumpTests.cs new file mode 100644 index 0000000..b9eeba8 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Sta/StaMessagePumpTests.cs @@ -0,0 +1,260 @@ +using System; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; +using ZB.MOM.WW.MxGateway.Worker.Sta; + +namespace ZB.MOM.WW.MxGateway.Worker.Tests.Sta; + +/// +/// Tests for . +/// +/// +/// Boundary: the MsgWaitFailed failure branch of WaitForWorkOrMessages +/// is not exercised. Forcing MsgWaitForMultipleObjectsEx to fail requires +/// passing a deliberately invalid native handle, which is unsafe to construct in a +/// managed test and can corrupt the thread's wait state. The other behavior — null +/// argument validation, waking on a signalled event, returning on timeout, the +/// timeout conversion edge cases observable through wait latency, and the +/// pump's drain count — is covered directly here. +/// +public sealed class StaMessagePumpTests +{ + /// + /// Verifies that WaitForWorkOrMessages throws ArgumentNullException for a null wake event. + /// + [Fact] + public void WaitForWorkOrMessages_NullWakeEvent_ThrowsArgumentNullException() + { + StaMessagePump pump = new(); + + ArgumentNullException exception = Assert.Throws( + () => pump.WaitForWorkOrMessages(null!, TimeSpan.FromMilliseconds(10))); + + Assert.Equal("commandWakeEvent", exception.ParamName); + } + + /// + /// Verifies that WaitForWorkOrMessages returns promptly when the wake event is already signalled. + /// + [Fact] + public async Task WaitForWorkOrMessages_WakeEventAlreadySignalled_ReturnsImmediately() + { + StaMessagePump pump = new(); + using ManualResetEventSlim wakeEvent = new(initialState: true); + + await RunOnStaThreadAsync(() => + { + Stopwatch stopwatch = Stopwatch.StartNew(); + pump.WaitForWorkOrMessages(wakeEvent.WaitHandle, TimeSpan.FromSeconds(30)); + stopwatch.Stop(); + + // A 30s timeout was supplied; returning quickly proves the signalled + // wake handle — not the timeout — ended the wait. + Assert.True( + stopwatch.Elapsed < TimeSpan.FromSeconds(5), + $"Wait took {stopwatch.Elapsed}; a pre-signalled wake event should return immediately."); + }); + } + + /// + /// Verifies that WaitForWorkOrMessages wakes when the wake event is signalled from another thread. + /// + [Fact] + public async Task WaitForWorkOrMessages_WakeEventSignalledDuringWait_Returns() + { + StaMessagePump pump = new(); + using ManualResetEventSlim wakeEvent = new(initialState: false); + + Task signalTask = Task.Run(async () => + { + await Task.Delay(150, CancellationToken.None); + wakeEvent.Set(); + }); + + await RunOnStaThreadAsync(() => + { + Stopwatch stopwatch = Stopwatch.StartNew(); + pump.WaitForWorkOrMessages(wakeEvent.WaitHandle, TimeSpan.FromSeconds(30)); + stopwatch.Stop(); + + Assert.True( + stopwatch.Elapsed < TimeSpan.FromSeconds(10), + $"Wait took {stopwatch.Elapsed}; signalling the wake event should end the 30s wait early."); + }); + + await signalTask; + } + + /// + /// Verifies that WaitForWorkOrMessages returns on timeout when the wake event is never signalled. + /// + [Fact] + public async Task WaitForWorkOrMessages_WakeEventNeverSignalled_ReturnsAfterTimeout() + { + StaMessagePump pump = new(); + using ManualResetEventSlim wakeEvent = new(initialState: false); + + await RunOnStaThreadAsync(() => + { + Stopwatch stopwatch = Stopwatch.StartNew(); + pump.WaitForWorkOrMessages(wakeEvent.WaitHandle, TimeSpan.FromMilliseconds(150)); + stopwatch.Stop(); + + // The wait must end of its own accord (timeout). Lower bound is loose + // because the timeout converts via Math.Ceiling and the OS scheduler + // adds slack; upper bound proves it is not waiting indefinitely. + Assert.True( + stopwatch.Elapsed < TimeSpan.FromSeconds(10), + $"Wait took {stopwatch.Elapsed}; a 150ms timeout should end the wait without a signal."); + }); + } + + /// + /// Verifies that a zero timeout (the TimeSpan.Zero conversion branch) returns without blocking. + /// + [Fact] + public async Task WaitForWorkOrMessages_ZeroTimeout_ReturnsWithoutBlocking() + { + StaMessagePump pump = new(); + using ManualResetEventSlim wakeEvent = new(initialState: false); + + await RunOnStaThreadAsync(() => + { + Stopwatch stopwatch = Stopwatch.StartNew(); + + // TimeSpan.Zero exercises the "<= Zero -> 0 ms" conversion branch: + // MsgWaitForMultipleObjectsEx polls and returns immediately. + pump.WaitForWorkOrMessages(wakeEvent.WaitHandle, TimeSpan.Zero); + stopwatch.Stop(); + + Assert.True( + stopwatch.Elapsed < TimeSpan.FromSeconds(2), + $"Wait took {stopwatch.Elapsed}; a zero timeout must not block."); + }); + } + + /// + /// Verifies that PumpPendingMessages returns zero when the STA thread message queue is empty. + /// + [Fact] + public async Task PumpPendingMessages_NoMessagesPosted_ReturnsZero() + { + StaMessagePump pump = new(); + + int pumped = await RunOnStaThreadAsync(() => + { + // Drain anything the apartment/thread start posted, then measure a clean queue. + pump.PumpPendingMessages(); + return pump.PumpPendingMessages(); + }); + + Assert.Equal(0, pumped); + } + + /// + /// Verifies that PumpPendingMessages dispatches and counts messages posted to the STA thread. + /// + [Fact] + public async Task PumpPendingMessages_MessagesPostedToStaThread_ReturnsCountProcessed() + { + StaMessagePump pump = new(); + + int pumped = await RunOnStaThreadAsync(() => + { + // Clear any startup messages so the count reflects only what we post. + pump.PumpPendingMessages(); + + uint threadId = GetCurrentThreadId(); + Assert.True(PostThreadMessage(threadId, WmNull, UIntPtr.Zero, IntPtr.Zero)); + Assert.True(PostThreadMessage(threadId, WmNull, UIntPtr.Zero, IntPtr.Zero)); + Assert.True(PostThreadMessage(threadId, WmNull, UIntPtr.Zero, IntPtr.Zero)); + + return pump.PumpPendingMessages(); + }); + + Assert.Equal(3, pumped); + } + + /// + /// Verifies that WaitForWorkOrMessages returns once a Windows message is posted to the STA thread. + /// + [Fact] + public async Task WaitForWorkOrMessages_WindowsMessagePosted_ReturnsForInputAvailable() + { + StaMessagePump pump = new(); + using ManualResetEventSlim wakeEvent = new(initialState: false); + using ManualResetEventSlim threadReady = new(initialState: false); + uint staThreadId = 0; + + Task staTask = RunOnStaThreadAsync(() => + { + staThreadId = GetCurrentThreadId(); + pump.PumpPendingMessages(); + threadReady.Set(); + + Stopwatch stopwatch = Stopwatch.StartNew(); + // The wake event is never signalled. Only the posted Windows message + // (QS_ALLINPUT wake mask) can end this 30s wait early. + pump.WaitForWorkOrMessages(wakeEvent.WaitHandle, TimeSpan.FromSeconds(30)); + stopwatch.Stop(); + + Assert.True( + stopwatch.Elapsed < TimeSpan.FromSeconds(10), + $"Wait took {stopwatch.Elapsed}; a posted Windows message should wake the pump."); + }); + + Assert.True(threadReady.Wait(TimeSpan.FromSeconds(5)), "STA thread did not start."); + await Task.Delay(100, CancellationToken.None); + Assert.True( + PostThreadMessage(staThreadId, WmNull, UIntPtr.Zero, IntPtr.Zero), + "Failed to post a Windows message to the STA thread."); + + await staTask; + } + + private const uint WmNull = 0x0000; + + /// Runs an action on a dedicated STA thread and returns when it completes. + private static Task RunOnStaThreadAsync(Action action) + { + return RunOnStaThreadAsync(() => + { + action(); + return 0; + }); + } + + /// Runs a function on a dedicated STA thread and returns its result. + private static Task RunOnStaThreadAsync(Func function) + { + TaskCompletionSource completion = new(); + Thread thread = new(() => + { + try + { + completion.SetResult(function()); + } + catch (Exception exception) + { + completion.SetException(exception); + } + }) + { + IsBackground = true, + }; + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + return completion.Task; + } + + [System.Runtime.InteropServices.DllImport("kernel32.dll")] + private static extern uint GetCurrentThreadId(); + + [System.Runtime.InteropServices.DllImport("user32.dll", SetLastError = true)] + private static extern bool PostThreadMessage( + uint threadId, + uint message, + UIntPtr wParam, + IntPtr lParam); +} diff --git a/src/MxGateway.Worker.Tests/Sta/StaRuntimeTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Sta/StaRuntimeTests.cs similarity index 81% rename from src/MxGateway.Worker.Tests/Sta/StaRuntimeTests.cs rename to src/ZB.MOM.WW.MxGateway.Worker.Tests/Sta/StaRuntimeTests.cs index f068b80..ebccf33 100644 --- a/src/MxGateway.Worker.Tests/Sta/StaRuntimeTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Sta/StaRuntimeTests.cs @@ -1,10 +1,9 @@ using System; -using System.Diagnostics; using System.Threading; using System.Threading.Tasks; -using MxGateway.Worker.Sta; +using ZB.MOM.WW.MxGateway.Worker.Sta; -namespace MxGateway.Worker.Tests.Sta; +namespace ZB.MOM.WW.MxGateway.Worker.Tests.Sta; public sealed class StaRuntimeTests { @@ -27,7 +26,15 @@ public sealed class StaRuntimeTests Assert.Equal(ApartmentState.STA, observation.ApartmentState); } - /// Verifies that InvokeAsync wakes the idle pump when a command is queued. + /// + /// Verifies that InvokeAsync wakes the idle pump when a command is queued. + /// The pump is configured with a 30-second idle period — far longer than + /// any reasonable test run — so the awaited command completing at all proves + /// the command wake event (not the idle pump tick) drove the dispatch. No + /// wall-clock assertion is used: a loaded CI agent can stall an otherwise + /// correct dispatch past an arbitrary millisecond budget, which would be a + /// false failure. + /// [Fact] public async Task InvokeAsync_WakesIdlePumpForQueuedCommand() { @@ -37,15 +44,10 @@ public sealed class StaRuntimeTests new StaMessagePump(), TimeSpan.FromSeconds(30)); runtime.Start(); - Stopwatch stopwatch = Stopwatch.StartNew(); int threadId = await runtime.InvokeAsync(() => Thread.CurrentThread.ManagedThreadId); - stopwatch.Stop(); Assert.Equal(runtime.StaThreadId, threadId); - Assert.True( - stopwatch.Elapsed < TimeSpan.FromSeconds(2), - $"Command took {stopwatch.Elapsed} to execute, so the command wake event did not wake the STA promptly."); } /// Verifies that Shutdown stops the thread and uninitializes the COM apartment. @@ -97,7 +99,16 @@ public sealed class StaRuntimeTests Assert.Equal(runtime.StaThreadId, threadId); } - /// Verifies that InvokeAsync returns a faulted task when called after Shutdown. + /// + /// Verifies that InvokeAsync returns a faulted task when called after + /// Shutdown. Worker-016 introduced + /// (a dedicated subtype of ) so + /// callers — notably MxAccessStaSession.RunAlarmPollLoopAsync — + /// can distinguish the graceful shutdown signal from a vanilla + /// such as an STA-affinity + /// assertion. The test pins the exact type so a regression that + /// reverts to a plain InvalidOperationException fails here. + /// [Fact] public async Task InvokeAsync_AfterShutdown_ReturnsFaultedTask() { @@ -106,7 +117,7 @@ public sealed class StaRuntimeTests runtime.Start(); runtime.Shutdown(TimeSpan.FromSeconds(2)); - InvalidOperationException exception = await Assert.ThrowsAsync( + StaRuntimeShutdownException exception = await Assert.ThrowsAsync( () => runtime.InvokeAsync(() => Thread.CurrentThread.ManagedThreadId)); Assert.Contains("shutting down", exception.Message); diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/TestSupport/FakeRuntimeSession.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/TestSupport/FakeRuntimeSession.cs new file mode 100644 index 0000000..e361819 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/TestSupport/FakeRuntimeSession.cs @@ -0,0 +1,268 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Google.Protobuf.WellKnownTypes; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Worker.Ipc; +using ZB.MOM.WW.MxGateway.Worker.MxAccess; +using ZB.MOM.WW.MxGateway.Worker.Sta; + +namespace ZB.MOM.WW.MxGateway.Worker.Tests.TestSupport; + +/// +/// Single configurable test double shared by +/// the IPC tests. Replaces the two independent (and previously diverged) +/// FakeRuntimeSession copies in WorkerPipeSessionTests and +/// WorkerPipeClientTests: one supported dispatch blocking and event enqueue, the +/// other did not. This consolidated double supports every configuration both +/// call sites needed, so a minimal caller simply leaves the options unset. +/// +internal sealed class FakeRuntimeSession : IWorkerRuntimeSession +{ + private readonly ManualResetEventSlim releaseDispatch = new(false); + private readonly object gate = new(); + private readonly Queue events = new(); + private readonly List cancelledCorrelationIds = new(); + private WorkerRuntimeHeartbeatSnapshot snapshot = new( + DateTimeOffset.UtcNow, + pendingCommandCount: 0, + outboundEventQueueDepth: 0, + lastEventSequence: 0, + currentCommandCorrelationId: string.Empty); + + /// Gets the event signaled when dispatch begins. + public ManualResetEventSlim DispatchStarted { get; } = new(false); + + /// Blocks dispatch execution until explicitly released. + public bool BlockDispatch { get; set; } + + /// Gets or sets whether to throw an exception after dispatch is released. + public bool ThrowAfterDispatchReleased { get; set; } + + /// Gets or sets whether ShutdownGracefullyAsync throws a TimeoutException. + public bool ThrowTimeoutOnShutdown { get; set; } + + /// Gets a value indicating whether Dispose was called. + public bool Disposed { get; private set; } + + /// Starts the worker session with the given session ID and process ID. + /// The session identifier. + /// The worker process ID. + /// Cancellation token. + /// Worker ready response. + public Task StartAsync( + string sessionId, + int workerProcessId, + CancellationToken cancellationToken = default) + { + return Task.FromResult(new WorkerReady + { + WorkerProcessId = workerProcessId, + MxaccessProgid = MxAccessInteropInfo.ProgId, + MxaccessClsid = MxAccessInteropInfo.Clsid, + ReadyTimestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), + }); + } + + /// Dispatches a command to the STA thread. + /// The command to dispatch. + /// The command reply. + public Task DispatchAsync(StaCommand command) + { + return Task.Run( + () => + { + SetSnapshot(new WorkerRuntimeHeartbeatSnapshot( + DateTimeOffset.UtcNow, + pendingCommandCount: 0, + outboundEventQueueDepth: 0, + lastEventSequence: 0, + command.CorrelationId)); + DispatchStarted.Set(); + + if (BlockDispatch) + { + releaseDispatch.Wait(TimeSpan.FromSeconds(5)); + } + + SetSnapshot(new WorkerRuntimeHeartbeatSnapshot( + DateTimeOffset.UtcNow, + pendingCommandCount: 0, + outboundEventQueueDepth: 0, + lastEventSequence: 0, + currentCommandCorrelationId: string.Empty)); + + if (ThrowAfterDispatchReleased) + { + throw new InvalidOperationException("Command failed after shutdown started."); + } + + return new MxCommandReply + { + SessionId = command.SessionId, + CorrelationId = command.CorrelationId, + Kind = command.Kind, + ProtocolStatus = new ProtocolStatus + { + Code = ProtocolStatusCode.Ok, + Message = "OK", + }, + }; + }); + } + + /// Captures current heartbeat snapshot. + /// Current runtime heartbeat snapshot. + public WorkerRuntimeHeartbeatSnapshot CaptureHeartbeat() + { + lock (gate) + { + return snapshot; + } + } + + /// Drains queued events up to the specified limit. + /// Maximum events to drain; 0 drains all. + /// The drained events. + public IReadOnlyList DrainEvents(uint maxEvents) + { + lock (gate) + { + int drainCount = maxEvents == 0 + ? events.Count + : Math.Min(events.Count, checked((int)Math.Min(maxEvents, int.MaxValue))); + List drained = new(drainCount); + for (int index = 0; index < drainCount; index++) + { + drained.Add(events.Dequeue()); + } + + return drained; + } + } + + /// Drains a pending fault if any. + /// Pending fault or null. + public WorkerFault? DrainFault() + { + return null; + } + + /// + /// Gets a snapshot of every correlation id passed to + /// . Recording lets the IPC tests + /// assert that a WorkerCancel envelope dispatched on the + /// gateway side reaches the runtime session — see Worker.Tests-017. + /// + public IReadOnlyList CancelledCorrelationIds + { + get + { + lock (gate) + { + return new List(cancelledCorrelationIds); + } + } + } + + private bool cancelCommandReturnValue; + + /// + /// Optional return value yielded by . + /// Defaults to false (the runtime had no matching in-flight + /// command), matching the previous test-double behaviour. Mutated + /// and read under lock(gate) to match the locking convention + /// the rest of this fake uses for cancelledCorrelationIds, + /// snapshot, and events (Worker.Tests-027). + /// + public bool CancelCommandReturnValue + { + get + { + lock (gate) + { + return cancelCommandReturnValue; + } + } + + set + { + lock (gate) + { + cancelCommandReturnValue = value; + } + } + } + + /// Cancels command by correlation ID. + /// The command correlation ID. + /// True if cancelled; false otherwise. + public bool CancelCommand(string correlationId) + { + lock (gate) + { + cancelledCorrelationIds.Add(correlationId); + return cancelCommandReturnValue; + } + } + + /// Requests graceful shutdown. + public void RequestShutdown() + { + releaseDispatch.Set(); + } + + /// Shuts down gracefully within the specified timeout. + /// Shutdown timeout period. + /// Cancellation token. + /// Shutdown result. + public Task ShutdownGracefullyAsync( + TimeSpan timeout, + CancellationToken cancellationToken = default) + { + releaseDispatch.Set(); + if (ThrowTimeoutOnShutdown) + { + return Task.FromException( + new TimeoutException("Simulated graceful shutdown timeout.")); + } + + return Task.FromResult(new MxAccessShutdownResult(Array.Empty())); + } + + /// Releases a blocked dispatch. + public void ReleaseDispatch() + { + releaseDispatch.Set(); + } + + /// Sets the current heartbeat snapshot. + /// The snapshot to set. + public void SetSnapshot(WorkerRuntimeHeartbeatSnapshot value) + { + lock (gate) + { + snapshot = value; + } + } + + /// Enqueues a worker event to be drained. + /// The event to enqueue. + public void EnqueueEvent(WorkerEvent workerEvent) + { + lock (gate) + { + events.Enqueue(workerEvent); + } + } + + /// Disposes resources. + public void Dispose() + { + Disposed = true; + releaseDispatch.Set(); + releaseDispatch.Dispose(); + DispatchStarted.Dispose(); + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/TestSupport/LiveMxAccessFactAttribute.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/TestSupport/LiveMxAccessFactAttribute.cs new file mode 100644 index 0000000..9da8fdf --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/TestSupport/LiveMxAccessFactAttribute.cs @@ -0,0 +1,39 @@ +using System; +using ZB.MOM.WW.MxGateway.Contracts; + +namespace ZB.MOM.WW.MxGateway.Worker.Tests.TestSupport; + +/// +/// Marks an xUnit test as requiring installed MXAccess COM and live +/// provider state. When the opt-in environment variable named by +/// is +/// not set to 1, the test is reported as Skipped by +/// xUnit rather than silently returning early (which xUnit would +/// otherwise report as Passed). Mirrors +/// ZB.MOM.WW.MxGateway.IntegrationTests.LiveMxAccessFactAttribute; both +/// copies bind to the same GatewayContractInfo constant so the +/// env-var name has a single literal source of truth (Worker.Tests-025). +/// +public sealed class LiveMxAccessFactAttribute : FactAttribute +{ + /// + /// The environment variable that opts the suite into running live + /// MXAccess COM tests. Must be set to 1 on a machine with the + /// installed MXAccess runtime and a reachable Galaxy provider. + /// Sourced from + /// so a single constant gates both Worker.Tests and IntegrationTests. + /// + public const string LiveMxAccessVariableName = GatewayContractInfo.LiveMxAccessOptInVariableName; + + /// Initializes the attribute, skipping the test unless the env var is set. + public LiveMxAccessFactAttribute() + { + if (!string.Equals( + Environment.GetEnvironmentVariable(LiveMxAccessVariableName), + "1", + StringComparison.Ordinal)) + { + Skip = $"Set {LiveMxAccessVariableName}=1 to run live MXAccess tests."; + } + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/TestSupport/NoopComApartmentInitializer.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/TestSupport/NoopComApartmentInitializer.cs new file mode 100644 index 0000000..1d578bc --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/TestSupport/NoopComApartmentInitializer.cs @@ -0,0 +1,22 @@ +using ZB.MOM.WW.MxGateway.Worker.Sta; + +namespace ZB.MOM.WW.MxGateway.Worker.Tests.TestSupport; + +/// +/// Shared no-operation for tests that +/// construct an without a real COM apartment. Replaces +/// the per-file copies that were previously defined independently in +/// StaCommandDispatcherTests, MxAccessStaSessionTests, and MxAccessCommandExecutorTests. +/// +internal sealed class NoopComApartmentInitializer : IStaComApartmentInitializer +{ + /// + public void Initialize() + { + } + + /// + public void Uninitialize() + { + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/TestSupport/NoopEventSink.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/TestSupport/NoopEventSink.cs new file mode 100644 index 0000000..a0634c1 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/TestSupport/NoopEventSink.cs @@ -0,0 +1,23 @@ +using ZB.MOM.WW.MxGateway.Worker.MxAccess; + +namespace ZB.MOM.WW.MxGateway.Worker.Tests.TestSupport; + +/// +/// Shared no-operation for tests that construct +/// an but do not exercise the event sink. +/// Replaces the per-file NoopEventSink/NullEventSink copies that +/// were previously defined independently in MxAccessCommandExecutorTests and +/// AlarmCommandExecutorTests. +/// +internal sealed class NoopEventSink : IMxAccessEventSink +{ + /// + public void Attach(object mxAccessComObject, string sessionId) + { + } + + /// + public void Detach() + { + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/TestSupport/NoopMxAccessServer.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/TestSupport/NoopMxAccessServer.cs new file mode 100644 index 0000000..1754ed2 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/TestSupport/NoopMxAccessServer.cs @@ -0,0 +1,92 @@ +using ZB.MOM.WW.MxGateway.Worker.MxAccess; + +namespace ZB.MOM.WW.MxGateway.Worker.Tests.TestSupport; + +/// +/// Shared no-operation for tests that need to +/// construct an via +/// but do not exercise any +/// MXAccess COM call. Replaces the per-file NullMxAccessServer copy +/// that previously lived inside AlarmCommandExecutorTests and was +/// constructed via reflection — see Worker.Tests-016 for the rationale. +/// +internal sealed class NoopMxAccessServer : IMxAccessServer +{ + /// + public int Register(string clientName) => 0; + + /// + public void Unregister(int serverHandle) + { + } + + /// + public int AddItem(int serverHandle, string itemDefinition) => 0; + + /// + public int AddItem2(int serverHandle, string itemDefinition, string itemContext) => 0; + + /// + public void RemoveItem(int serverHandle, int itemHandle) + { + } + + /// + public void Advise(int serverHandle, int itemHandle) + { + } + + /// + public void UnAdvise(int serverHandle, int itemHandle) + { + } + + /// + public void AdviseSupervisory(int serverHandle, int itemHandle) + { + } + + /// + public int AddBufferedItem(int serverHandle, string itemDefinition, string itemContext) => 0; + + /// + public void SetBufferedUpdateInterval(int serverHandle, int updateIntervalMilliseconds) + { + } + + /// + public void Suspend(int serverHandle, int itemHandle) + { + } + + /// + public void Activate(int serverHandle, int itemHandle) + { + } + + /// + public void Write(int serverHandle, int itemHandle, object? value, int userId) + { + } + + /// + public void Write2(int serverHandle, int itemHandle, object? value, object? timestampValue, int userId) + { + } + + /// + public void WriteSecured(int serverHandle, int itemHandle, int currentUserId, int verifierUserId, object? value) + { + } + + /// + public void WriteSecured2(int serverHandle, int itemHandle, int currentUserId, int verifierUserId, object? value, object? timestampValue) + { + } + + /// + public int AuthenticateUser(string userName, string password) => 0; + + /// + public int ArchestrAUserToId(string userName) => 0; +} diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/TestSupport/WorkerFrameTestHelpers.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/TestSupport/WorkerFrameTestHelpers.cs new file mode 100644 index 0000000..4a78baf --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/TestSupport/WorkerFrameTestHelpers.cs @@ -0,0 +1,43 @@ +using Google.Protobuf; + +namespace ZB.MOM.WW.MxGateway.Worker.Tests.TestSupport; + +/// +/// Shared helpers for building raw length-prefixed worker frames in tests. +/// Replaces the per-file CreateFrame/WriteUInt32LittleEndian copies +/// that were previously defined independently in WorkerFrameProtocolTests and +/// WorkerPipeSessionTests. +/// +internal static class WorkerFrameTestHelpers +{ + /// Builds a length-prefixed frame from a protobuf message. + /// Message to serialize into the frame payload. + public static byte[] CreateFrame(IMessage message) + { + return CreateFrame(message.ToByteArray()); + } + + /// Builds a length-prefixed frame from a raw payload. + /// Payload bytes to wrap in a frame. + public static byte[] CreateFrame(byte[] payload) + { + byte[] frame = new byte[sizeof(uint) + payload.Length]; + WriteUInt32LittleEndian(frame, (uint)payload.Length); + payload.CopyTo(frame, sizeof(uint)); + + return frame; + } + + /// Writes a little-endian unsigned 32-bit integer to the buffer head. + /// Buffer to write into; must have at least four bytes. + /// Value to encode. + public static void WriteUInt32LittleEndian( + byte[] buffer, + uint value) + { + buffer[0] = (byte)value; + buffer[1] = (byte)(value >> 8); + buffer[2] = (byte)(value >> 16); + buffer[3] = (byte)(value >> 24); + } +} diff --git a/src/MxGateway.Worker.Tests/MxGateway.Worker.Tests.csproj b/src/ZB.MOM.WW.MxGateway.Worker.Tests/ZB.MOM.WW.MxGateway.Worker.Tests.csproj similarity index 95% rename from src/MxGateway.Worker.Tests/MxGateway.Worker.Tests.csproj rename to src/ZB.MOM.WW.MxGateway.Worker.Tests/ZB.MOM.WW.MxGateway.Worker.Tests.csproj index 55b9608..3d8490e 100644 --- a/src/MxGateway.Worker.Tests/MxGateway.Worker.Tests.csproj +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/ZB.MOM.WW.MxGateway.Worker.Tests.csproj @@ -22,7 +22,7 @@ - + diff --git a/src/MxGateway.Worker/Bootstrap/EnvironmentVariableWorkerEnvironment.cs b/src/ZB.MOM.WW.MxGateway.Worker/Bootstrap/EnvironmentVariableWorkerEnvironment.cs similarity index 87% rename from src/MxGateway.Worker/Bootstrap/EnvironmentVariableWorkerEnvironment.cs rename to src/ZB.MOM.WW.MxGateway.Worker/Bootstrap/EnvironmentVariableWorkerEnvironment.cs index 3205976..6401d07 100644 --- a/src/MxGateway.Worker/Bootstrap/EnvironmentVariableWorkerEnvironment.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/Bootstrap/EnvironmentVariableWorkerEnvironment.cs @@ -1,6 +1,6 @@ using System; -namespace MxGateway.Worker.Bootstrap; +namespace ZB.MOM.WW.MxGateway.Worker.Bootstrap; /// /// Worker environment that reads from system environment variables. diff --git a/src/MxGateway.Worker/Bootstrap/IWorkerEnvironment.cs b/src/ZB.MOM.WW.MxGateway.Worker/Bootstrap/IWorkerEnvironment.cs similarity index 89% rename from src/MxGateway.Worker/Bootstrap/IWorkerEnvironment.cs rename to src/ZB.MOM.WW.MxGateway.Worker/Bootstrap/IWorkerEnvironment.cs index 03b342a..2306fca 100644 --- a/src/MxGateway.Worker/Bootstrap/IWorkerEnvironment.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/Bootstrap/IWorkerEnvironment.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Worker.Bootstrap; +namespace ZB.MOM.WW.MxGateway.Worker.Bootstrap; /// /// Abstracts access to environment variables for the worker. diff --git a/src/MxGateway.Worker/Bootstrap/IWorkerLogger.cs b/src/ZB.MOM.WW.MxGateway.Worker/Bootstrap/IWorkerLogger.cs similarity index 92% rename from src/MxGateway.Worker/Bootstrap/IWorkerLogger.cs rename to src/ZB.MOM.WW.MxGateway.Worker/Bootstrap/IWorkerLogger.cs index 36a8cac..44750c5 100644 --- a/src/MxGateway.Worker/Bootstrap/IWorkerLogger.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/Bootstrap/IWorkerLogger.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; -namespace MxGateway.Worker.Bootstrap; +namespace ZB.MOM.WW.MxGateway.Worker.Bootstrap; public interface IWorkerLogger { diff --git a/src/MxGateway.Worker/Bootstrap/WorkerBootstrapResult.cs b/src/ZB.MOM.WW.MxGateway.Worker/Bootstrap/WorkerBootstrapResult.cs similarity index 97% rename from src/MxGateway.Worker/Bootstrap/WorkerBootstrapResult.cs rename to src/ZB.MOM.WW.MxGateway.Worker/Bootstrap/WorkerBootstrapResult.cs index 003383d..fc51bbd 100644 --- a/src/MxGateway.Worker/Bootstrap/WorkerBootstrapResult.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/Bootstrap/WorkerBootstrapResult.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; using System.Linq; -namespace MxGateway.Worker.Bootstrap; +namespace ZB.MOM.WW.MxGateway.Worker.Bootstrap; public sealed class WorkerBootstrapResult { diff --git a/src/MxGateway.Worker/Bootstrap/WorkerConsoleLogger.cs b/src/ZB.MOM.WW.MxGateway.Worker/Bootstrap/WorkerConsoleLogger.cs similarity index 97% rename from src/MxGateway.Worker/Bootstrap/WorkerConsoleLogger.cs rename to src/ZB.MOM.WW.MxGateway.Worker/Bootstrap/WorkerConsoleLogger.cs index e1b3212..68c98d4 100644 --- a/src/MxGateway.Worker/Bootstrap/WorkerConsoleLogger.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/Bootstrap/WorkerConsoleLogger.cs @@ -3,7 +3,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; -namespace MxGateway.Worker.Bootstrap; +namespace ZB.MOM.WW.MxGateway.Worker.Bootstrap; public sealed class WorkerConsoleLogger : IWorkerLogger { diff --git a/src/MxGateway.Worker/Bootstrap/WorkerExitCode.cs b/src/ZB.MOM.WW.MxGateway.Worker/Bootstrap/WorkerExitCode.cs similarity index 81% rename from src/MxGateway.Worker/Bootstrap/WorkerExitCode.cs rename to src/ZB.MOM.WW.MxGateway.Worker/Bootstrap/WorkerExitCode.cs index 67bbad1..7045a38 100644 --- a/src/MxGateway.Worker/Bootstrap/WorkerExitCode.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/Bootstrap/WorkerExitCode.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Worker.Bootstrap; +namespace ZB.MOM.WW.MxGateway.Worker.Bootstrap; public enum WorkerExitCode { diff --git a/src/MxGateway.Worker/Bootstrap/WorkerLogRedactor.cs b/src/ZB.MOM.WW.MxGateway.Worker/Bootstrap/WorkerLogRedactor.cs similarity index 97% rename from src/MxGateway.Worker/Bootstrap/WorkerLogRedactor.cs rename to src/ZB.MOM.WW.MxGateway.Worker/Bootstrap/WorkerLogRedactor.cs index 4304eb6..25152aa 100644 --- a/src/MxGateway.Worker/Bootstrap/WorkerLogRedactor.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/Bootstrap/WorkerLogRedactor.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; -namespace MxGateway.Worker.Bootstrap; +namespace ZB.MOM.WW.MxGateway.Worker.Bootstrap; /// /// Redacts sensitive fields from worker log messages. diff --git a/src/MxGateway.Worker/Bootstrap/WorkerOptions.cs b/src/ZB.MOM.WW.MxGateway.Worker/Bootstrap/WorkerOptions.cs similarity index 96% rename from src/MxGateway.Worker/Bootstrap/WorkerOptions.cs rename to src/ZB.MOM.WW.MxGateway.Worker/Bootstrap/WorkerOptions.cs index 4b8afbe..e527903 100644 --- a/src/MxGateway.Worker/Bootstrap/WorkerOptions.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/Bootstrap/WorkerOptions.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Worker.Bootstrap; +namespace ZB.MOM.WW.MxGateway.Worker.Bootstrap; /// Worker bootstrap options passed via environment variables and named pipes. public sealed class WorkerOptions diff --git a/src/MxGateway.Worker/Bootstrap/WorkerOptionsParser.cs b/src/ZB.MOM.WW.MxGateway.Worker/Bootstrap/WorkerOptionsParser.cs similarity index 97% rename from src/MxGateway.Worker/Bootstrap/WorkerOptionsParser.cs rename to src/ZB.MOM.WW.MxGateway.Worker/Bootstrap/WorkerOptionsParser.cs index c5c9a6b..92f72eb 100644 --- a/src/MxGateway.Worker/Bootstrap/WorkerOptionsParser.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/Bootstrap/WorkerOptionsParser.cs @@ -1,8 +1,8 @@ using System; using System.Collections.Generic; -using MxGateway.Contracts; +using ZB.MOM.WW.MxGateway.Contracts; -namespace MxGateway.Worker.Bootstrap; +namespace ZB.MOM.WW.MxGateway.Worker.Bootstrap; /// /// Parses worker command-line arguments and environment variables. diff --git a/src/MxGateway.Worker/Conversion/.gitkeep b/src/ZB.MOM.WW.MxGateway.Worker/Conversion/.gitkeep similarity index 100% rename from src/MxGateway.Worker/Conversion/.gitkeep rename to src/ZB.MOM.WW.MxGateway.Worker/Conversion/.gitkeep diff --git a/src/MxGateway.Worker/Conversion/HResultConversion.cs b/src/ZB.MOM.WW.MxGateway.Worker/Conversion/HResultConversion.cs similarity index 91% rename from src/MxGateway.Worker/Conversion/HResultConversion.cs rename to src/ZB.MOM.WW.MxGateway.Worker/Conversion/HResultConversion.cs index fab120d..ea12894 100644 --- a/src/MxGateway.Worker/Conversion/HResultConversion.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/Conversion/HResultConversion.cs @@ -1,6 +1,6 @@ -using MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Contracts.Proto; -namespace MxGateway.Worker.Conversion; +namespace ZB.MOM.WW.MxGateway.Worker.Conversion; /// Result of converting an HResult to a protocol status and diagnostic message. public sealed class HResultConversion diff --git a/src/MxGateway.Worker/Conversion/HResultConverter.cs b/src/ZB.MOM.WW.MxGateway.Worker/Conversion/HResultConverter.cs similarity index 95% rename from src/MxGateway.Worker/Conversion/HResultConverter.cs rename to src/ZB.MOM.WW.MxGateway.Worker/Conversion/HResultConverter.cs index b428008..b46ece0 100644 --- a/src/MxGateway.Worker/Conversion/HResultConverter.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/Conversion/HResultConverter.cs @@ -1,8 +1,8 @@ using System; using System.Runtime.InteropServices; -using MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Contracts.Proto; -namespace MxGateway.Worker.Conversion; +namespace ZB.MOM.WW.MxGateway.Worker.Conversion; public sealed class HResultConverter { diff --git a/src/MxGateway.Worker/Conversion/MxStatusConversionException.cs b/src/ZB.MOM.WW.MxGateway.Worker/Conversion/MxStatusConversionException.cs similarity index 87% rename from src/MxGateway.Worker/Conversion/MxStatusConversionException.cs rename to src/ZB.MOM.WW.MxGateway.Worker/Conversion/MxStatusConversionException.cs index 9a63d80..2c88fbf 100644 --- a/src/MxGateway.Worker/Conversion/MxStatusConversionException.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/Conversion/MxStatusConversionException.cs @@ -1,6 +1,6 @@ using System; -namespace MxGateway.Worker.Conversion; +namespace ZB.MOM.WW.MxGateway.Worker.Conversion; public sealed class MxStatusConversionException : Exception { diff --git a/src/MxGateway.Worker/Conversion/MxStatusDetailText.cs b/src/ZB.MOM.WW.MxGateway.Worker/Conversion/MxStatusDetailText.cs similarity index 97% rename from src/MxGateway.Worker/Conversion/MxStatusDetailText.cs rename to src/ZB.MOM.WW.MxGateway.Worker/Conversion/MxStatusDetailText.cs index 8cf21e6..cab43e5 100644 --- a/src/MxGateway.Worker/Conversion/MxStatusDetailText.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/Conversion/MxStatusDetailText.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; -namespace MxGateway.Worker.Conversion; +namespace ZB.MOM.WW.MxGateway.Worker.Conversion; internal static class MxStatusDetailText { diff --git a/src/MxGateway.Worker/Conversion/MxStatusProxyConverter.cs b/src/ZB.MOM.WW.MxGateway.Worker/Conversion/MxStatusProxyConverter.cs similarity index 98% rename from src/MxGateway.Worker/Conversion/MxStatusProxyConverter.cs rename to src/ZB.MOM.WW.MxGateway.Worker/Conversion/MxStatusProxyConverter.cs index ef5b64d..aad07a4 100644 --- a/src/MxGateway.Worker/Conversion/MxStatusProxyConverter.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/Conversion/MxStatusProxyConverter.cs @@ -2,9 +2,9 @@ using System; using System.Collections.Generic; using System.Globalization; using System.Reflection; -using MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Contracts.Proto; -namespace MxGateway.Worker.Conversion; +namespace ZB.MOM.WW.MxGateway.Worker.Conversion; /// Converts MXAccess MXSTATUS_PROXY COM objects to protobuf MxStatusProxy messages. public sealed class MxStatusProxyConverter diff --git a/src/MxGateway.Worker/Conversion/VariantConverter.cs b/src/ZB.MOM.WW.MxGateway.Worker/Conversion/VariantConverter.cs similarity index 81% rename from src/MxGateway.Worker/Conversion/VariantConverter.cs rename to src/ZB.MOM.WW.MxGateway.Worker/Conversion/VariantConverter.cs index 4f55ee1..74e2d40 100644 --- a/src/MxGateway.Worker/Conversion/VariantConverter.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/Conversion/VariantConverter.cs @@ -1,10 +1,11 @@ using System; using System.Globalization; +using System.Linq; using Google.Protobuf; using Google.Protobuf.WellKnownTypes; -using MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Contracts.Proto; -namespace MxGateway.Worker.Conversion; +namespace ZB.MOM.WW.MxGateway.Worker.Conversion; public sealed class VariantConverter { @@ -118,6 +119,63 @@ public sealed class VariantConverter } } + /// + /// Converts an into a CLR object suitable for an + /// MXAccess COM write. The COM marshaler boxes the returned value into the + /// matching VARIANT, so this is the inverse of . + /// + /// Protobuf value to convert. + /// A COM-marshalable value, or for an MXAccess null. + public object? ConvertToComValue(MxValue value) + { + if (value is null) + { + throw new ArgumentNullException(nameof(value)); + } + + if (value.IsNull) + { + return null; + } + + return value.KindCase switch + { + MxValue.KindOneofCase.BoolValue => value.BoolValue, + MxValue.KindOneofCase.Int32Value => value.Int32Value, + MxValue.KindOneofCase.Int64Value => value.Int64Value, + MxValue.KindOneofCase.FloatValue => value.FloatValue, + MxValue.KindOneofCase.DoubleValue => value.DoubleValue, + MxValue.KindOneofCase.StringValue => value.StringValue, + // The COM marshaler renders a DateTime as VT_DATE; MXAccess accepts + // it as the timestamped-write time argument. + MxValue.KindOneofCase.TimestampValue => value.TimestampValue.ToDateTime(), + MxValue.KindOneofCase.ArrayValue => ConvertToComArray(value.ArrayValue), + MxValue.KindOneofCase.RawValue => throw new ArgumentException( + "MxValue raw payloads cannot be written to MXAccess.", nameof(value)), + _ => throw new ArgumentException( + "MxValue has no value kind set; nothing to write.", nameof(value)), + }; + } + + private static Array ConvertToComArray(MxArray array) + { + return array.ValuesCase switch + { + MxArray.ValuesOneofCase.BoolValues => array.BoolValues.Values.ToArray(), + MxArray.ValuesOneofCase.Int32Values => array.Int32Values.Values.ToArray(), + MxArray.ValuesOneofCase.Int64Values => array.Int64Values.Values.ToArray(), + MxArray.ValuesOneofCase.FloatValues => array.FloatValues.Values.ToArray(), + MxArray.ValuesOneofCase.DoubleValues => array.DoubleValues.Values.ToArray(), + MxArray.ValuesOneofCase.StringValues => array.StringValues.Values.ToArray(), + MxArray.ValuesOneofCase.TimestampValues => + array.TimestampValues.Values.Select(timestamp => timestamp.ToDateTime()).ToArray(), + MxArray.ValuesOneofCase.RawValues => throw new ArgumentException( + "MxArray raw payloads cannot be written to MXAccess.", nameof(array)), + _ => throw new ArgumentException( + "MxArray has no element values set; nothing to write.", nameof(array)), + }; + } + private static MxValue ConvertScalar( object value, MxDataType expectedDataType) @@ -207,7 +265,14 @@ public sealed class VariantConverter MxDataType expectedDataType) { long longValue = System.Convert.ToInt64(value, CultureInfo.InvariantCulture); - if (expectedDataType == MxDataType.Time) + + // The MxDataType.Time projection treats the source as a Windows FILETIME + // (a 64-bit 100-ns tick count since 1601). Only a genuine 64-bit source + // (long) can carry a valid full FILETIME; a uint can only hold the low + // 32 bits, which DateTime.FromFileTimeUtc would silently render as a + // near-1601 timestamp. For uint sources fall through to the integer + // projection rather than producing a bogus timestamp. + if (expectedDataType == MxDataType.Time && value is long) { return new MxValue { diff --git a/src/MxGateway.Worker/Ipc/IWorkerPipeClient.cs b/src/ZB.MOM.WW.MxGateway.Worker/Ipc/IWorkerPipeClient.cs similarity index 86% rename from src/MxGateway.Worker/Ipc/IWorkerPipeClient.cs rename to src/ZB.MOM.WW.MxGateway.Worker/Ipc/IWorkerPipeClient.cs index d40c162..37bad96 100644 --- a/src/MxGateway.Worker/Ipc/IWorkerPipeClient.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/Ipc/IWorkerPipeClient.cs @@ -1,8 +1,8 @@ using System.Threading; using System.Threading.Tasks; -using MxGateway.Worker.Bootstrap; +using ZB.MOM.WW.MxGateway.Worker.Bootstrap; -namespace MxGateway.Worker.Ipc; +namespace ZB.MOM.WW.MxGateway.Worker.Ipc; /// Manages the worker's named pipe connection to the gateway. public interface IWorkerPipeClient diff --git a/src/MxGateway.Worker/Ipc/WorkerContractInfo.cs b/src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerContractInfo.cs similarity index 77% rename from src/MxGateway.Worker/Ipc/WorkerContractInfo.cs rename to src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerContractInfo.cs index 7a7ec19..686cfa5 100644 --- a/src/MxGateway.Worker/Ipc/WorkerContractInfo.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerContractInfo.cs @@ -1,7 +1,7 @@ -using MxGateway.Contracts; -using MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Contracts; +using ZB.MOM.WW.MxGateway.Contracts.Proto; -namespace MxGateway.Worker.Ipc; +namespace ZB.MOM.WW.MxGateway.Worker.Ipc; public static class WorkerContractInfo { diff --git a/src/MxGateway.Worker/Ipc/WorkerEnvelopeValidator.cs b/src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerEnvelopeValidator.cs similarity index 94% rename from src/MxGateway.Worker/Ipc/WorkerEnvelopeValidator.cs rename to src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerEnvelopeValidator.cs index d6f8342..96757ab 100644 --- a/src/MxGateway.Worker/Ipc/WorkerEnvelopeValidator.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerEnvelopeValidator.cs @@ -1,7 +1,7 @@ using System; -using MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Contracts.Proto; -namespace MxGateway.Worker.Ipc; +namespace ZB.MOM.WW.MxGateway.Worker.Ipc; /// Validates worker envelope frames against protocol options. internal static class WorkerEnvelopeValidator diff --git a/src/MxGateway.Worker/Ipc/WorkerFrameProtocolErrorCode.cs b/src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerFrameProtocolErrorCode.cs similarity index 87% rename from src/MxGateway.Worker/Ipc/WorkerFrameProtocolErrorCode.cs rename to src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerFrameProtocolErrorCode.cs index 46e90ed..6f46446 100644 --- a/src/MxGateway.Worker/Ipc/WorkerFrameProtocolErrorCode.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerFrameProtocolErrorCode.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Worker.Ipc; +namespace ZB.MOM.WW.MxGateway.Worker.Ipc; public enum WorkerFrameProtocolErrorCode { diff --git a/src/MxGateway.Worker/Ipc/WorkerFrameProtocolException.cs b/src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerFrameProtocolException.cs similarity index 96% rename from src/MxGateway.Worker/Ipc/WorkerFrameProtocolException.cs rename to src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerFrameProtocolException.cs index f426947..a42460b 100644 --- a/src/MxGateway.Worker/Ipc/WorkerFrameProtocolException.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerFrameProtocolException.cs @@ -1,6 +1,6 @@ using System; -namespace MxGateway.Worker.Ipc; +namespace ZB.MOM.WW.MxGateway.Worker.Ipc; /// /// Exception raised when the named-pipe frame protocol encounters an error. diff --git a/src/MxGateway.Worker/Ipc/WorkerFrameProtocolOptions.cs b/src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerFrameProtocolOptions.cs similarity index 96% rename from src/MxGateway.Worker/Ipc/WorkerFrameProtocolOptions.cs rename to src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerFrameProtocolOptions.cs index 22387a7..d8332f5 100644 --- a/src/MxGateway.Worker/Ipc/WorkerFrameProtocolOptions.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerFrameProtocolOptions.cs @@ -1,8 +1,8 @@ using System; -using MxGateway.Contracts; -using MxGateway.Worker.Bootstrap; +using ZB.MOM.WW.MxGateway.Contracts; +using ZB.MOM.WW.MxGateway.Worker.Bootstrap; -namespace MxGateway.Worker.Ipc; +namespace ZB.MOM.WW.MxGateway.Worker.Ipc; /// Configuration options for the worker frame protocol. public sealed class WorkerFrameProtocolOptions diff --git a/src/MxGateway.Worker/Ipc/WorkerFrameReader.cs b/src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerFrameReader.cs similarity index 67% rename from src/MxGateway.Worker/Ipc/WorkerFrameReader.cs rename to src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerFrameReader.cs index 6324ad6..3ff27a0 100644 --- a/src/MxGateway.Worker/Ipc/WorkerFrameReader.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerFrameReader.cs @@ -1,11 +1,12 @@ using System; +using System.Buffers; using System.IO; using System.Threading; using System.Threading.Tasks; using Google.Protobuf; -using MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Contracts.Proto; -namespace MxGateway.Worker.Ipc; +namespace ZB.MOM.WW.MxGateway.Worker.Ipc; /// Reads length-prefixed WorkerEnvelope protobuf frames from a stream. public sealed class WorkerFrameReader @@ -29,7 +30,7 @@ public sealed class WorkerFrameReader public async Task ReadAsync(CancellationToken cancellationToken = default) { byte[] lengthPrefix = new byte[sizeof(uint)]; - await ReadExactlyOrThrowAsync(lengthPrefix, cancellationToken).ConfigureAwait(false); + await ReadExactlyOrThrowAsync(lengthPrefix, lengthPrefix.Length, cancellationToken).ConfigureAwait(false); uint payloadLength = ReadUInt32LittleEndian(lengthPrefix); if (payloadLength == 0) @@ -46,20 +47,32 @@ public sealed class WorkerFrameReader $"Worker frame payload length {payloadLength} exceeds the configured maximum of {_options.MaxMessageBytes} bytes."); } - byte[] payload = new byte[payloadLength]; - await ReadExactlyOrThrowAsync(payload, cancellationToken).ConfigureAwait(false); - + // Rent the payload buffer from the shared pool rather than allocating + // a fresh byte[] per frame. ParseFrom copies whatever it needs into + // the parsed message, so the rented buffer can be returned as soon as + // parsing completes. + int length = checked((int)payloadLength); + byte[] payload = ArrayPool.Shared.Rent(length); WorkerEnvelope envelope; try { - envelope = WorkerEnvelope.Parser.ParseFrom(payload); + await ReadExactlyOrThrowAsync(payload, length, cancellationToken).ConfigureAwait(false); + + try + { + envelope = WorkerEnvelope.Parser.ParseFrom(payload, 0, length); + } + catch (InvalidProtocolBufferException exception) + { + throw new WorkerFrameProtocolException( + WorkerFrameProtocolErrorCode.InvalidEnvelope, + "Worker frame payload is not a valid WorkerEnvelope protobuf message.", + exception); + } } - catch (InvalidProtocolBufferException exception) + finally { - throw new WorkerFrameProtocolException( - WorkerFrameProtocolErrorCode.InvalidEnvelope, - "Worker frame payload is not a valid WorkerEnvelope protobuf message.", - exception); + ArrayPool.Shared.Return(payload); } WorkerEnvelopeValidator.Validate(envelope, _options); @@ -77,13 +90,14 @@ public sealed class WorkerFrameReader private async Task ReadExactlyOrThrowAsync( byte[] buffer, + int count, CancellationToken cancellationToken) { int offset = 0; - while (offset < buffer.Length) + while (offset < count) { int bytesRead = await _stream - .ReadAsync(buffer, offset, buffer.Length - offset, cancellationToken) + .ReadAsync(buffer, offset, count - offset, cancellationToken) .ConfigureAwait(false); if (bytesRead == 0) diff --git a/src/MxGateway.Worker/Ipc/WorkerFrameWriter.cs b/src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerFrameWriter.cs similarity index 77% rename from src/MxGateway.Worker/Ipc/WorkerFrameWriter.cs rename to src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerFrameWriter.cs index 3e68106..478f6eb 100644 --- a/src/MxGateway.Worker/Ipc/WorkerFrameWriter.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerFrameWriter.cs @@ -3,9 +3,9 @@ using System.IO; using System.Threading; using System.Threading.Tasks; using Google.Protobuf; -using MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Contracts.Proto; -namespace MxGateway.Worker.Ipc; +namespace ZB.MOM.WW.MxGateway.Worker.Ipc; /// Writes worker frames to a stream with length-prefixed protobuf serialization. public sealed class WorkerFrameWriter @@ -54,15 +54,20 @@ public sealed class WorkerFrameWriter $"Worker envelope payload length {payloadLength} exceeds the configured maximum of {_options.MaxMessageBytes} bytes."); } - byte[] payload = envelope.ToByteArray(); - byte[] lengthPrefix = new byte[sizeof(uint)]; - WriteUInt32LittleEndian(lengthPrefix, (uint)payloadLength); + // Serialize once into a single buffer that carries the 4-byte + // length prefix followed by the payload, then issue one stream write. + // This avoids a second serialization pass (envelope.ToByteArray() + // would re-run CalculateSize internally), a separate prefix array, + // and a separate prefix write. + int frameLength = sizeof(uint) + payloadLength; + byte[] frame = new byte[frameLength]; + WriteUInt32LittleEndian(frame, (uint)payloadLength); + envelope.WriteTo(new Span(frame, sizeof(uint), payloadLength)); await _writeLock.WaitAsync(cancellationToken).ConfigureAwait(false); try { - await _stream.WriteAsync(lengthPrefix, 0, lengthPrefix.Length, cancellationToken).ConfigureAwait(false); - await _stream.WriteAsync(payload, 0, payload.Length, cancellationToken).ConfigureAwait(false); + await _stream.WriteAsync(frame, 0, frameLength, cancellationToken).ConfigureAwait(false); await _stream.FlushAsync(cancellationToken).ConfigureAwait(false); } finally diff --git a/src/MxGateway.Worker/Ipc/WorkerPipeClient.cs b/src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerPipeClient.cs similarity index 93% rename from src/MxGateway.Worker/Ipc/WorkerPipeClient.cs rename to src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerPipeClient.cs index f37cfaa..bfba1be 100644 --- a/src/MxGateway.Worker/Ipc/WorkerPipeClient.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerPipeClient.cs @@ -4,11 +4,11 @@ using System.IO; using System.IO.Pipes; using System.Threading; using System.Threading.Tasks; -using MxGateway.Worker.Bootstrap; +using ZB.MOM.WW.MxGateway.Worker.Bootstrap; using Polly; using Polly.Retry; -namespace MxGateway.Worker.Ipc; +namespace ZB.MOM.WW.MxGateway.Worker.Ipc; /// /// Connects to the gateway via a named pipe and runs the worker frame protocol session. @@ -166,14 +166,17 @@ public sealed class WorkerPipeClient : IWorkerPipeClient string pipeName, CancellationToken cancellationToken) { - int retryAttempts = Math.Max( - 0, - (_connectTimeoutMilliseconds / Math.Min(_connectTimeoutMilliseconds, _connectAttemptTimeoutMilliseconds)) - 1); - + // The real bound on connection attempts is the connectDeadline token + // below (CancelAfter(connectTimeout)): Polly stops retrying as soon as + // that token is cancelled. Driving retries purely off the deadline — + // rather than a fragile attempt-count formula that ignored the + // exponential backoff between attempts — keeps the time budget the + // single source of truth. MaxRetryAttempts is set to its maximum so it + // never ends the retry loop before the deadline does. ResiliencePipeline pipeline = new ResiliencePipelineBuilder() .AddRetry(new RetryStrategyOptions { - MaxRetryAttempts = retryAttempts, + MaxRetryAttempts = int.MaxValue, BackoffType = DelayBackoffType.Exponential, UseJitter = true, Delay = TimeSpan.FromMilliseconds(250), diff --git a/src/MxGateway.Worker/Ipc/WorkerPipeSession.cs b/src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerPipeSession.cs similarity index 79% rename from src/MxGateway.Worker/Ipc/WorkerPipeSession.cs rename to src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerPipeSession.cs index caaa2b3..fa9f297 100644 --- a/src/MxGateway.Worker/Ipc/WorkerPipeSession.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerPipeSession.cs @@ -5,12 +5,12 @@ using System.IO; using System.Threading; using System.Threading.Tasks; using Google.Protobuf.WellKnownTypes; -using MxGateway.Contracts.Proto; -using MxGateway.Worker.Bootstrap; -using MxGateway.Worker.MxAccess; -using MxGateway.Worker.Sta; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Worker.Bootstrap; +using ZB.MOM.WW.MxGateway.Worker.MxAccess; +using ZB.MOM.WW.MxGateway.Worker.Sta; -namespace MxGateway.Worker.Ipc; +namespace ZB.MOM.WW.MxGateway.Worker.Ipc; public sealed class WorkerPipeSession { @@ -29,10 +29,13 @@ public sealed class WorkerPipeSession private readonly HashSet _activeCommandTasks = new(); private IWorkerRuntimeSession? _runtimeSession; private long _nextSequence; - private WorkerState _state = WorkerState.Starting; + + // Mutated from the message loop, command tasks, the heartbeat loop and the + // shutdown path; volatile so cross-thread reads observe the latest state + // without tearing (WorkerState is an int-backed protobuf enum). + private volatile WorkerState _state = WorkerState.Starting; private bool _acceptingCommands = true; private bool _watchdogFaultSent; - private bool _shutdownTimedOut; /// Initializes a new worker pipe session over the provided stream. /// Network stream for reading and writing frames. @@ -48,7 +51,7 @@ public sealed class WorkerPipeSession options, () => Process.GetCurrentProcess().Id, new WorkerPipeSessionOptions(), - () => new MxAccessStaSession(), + () => new MxAccessStaSession((eq, affinity) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity)), logger) { } @@ -69,7 +72,7 @@ public sealed class WorkerPipeSession options, processIdProvider, new WorkerPipeSessionOptions(), - () => new MxAccessStaSession(), + () => new MxAccessStaSession((eq, affinity) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity)), logger: null) { } @@ -105,7 +108,16 @@ public sealed class WorkerPipeSession /// Token to cancel the asynchronous operation. public async Task RunAsync(CancellationToken cancellationToken = default) { - _runtimeSession = _runtimeSessionFactory(); + // Worker-025: the factory delegate itself is null-checked in the + // constructor, but its return value is not — a factory that returned + // null would NRE on the StartAsync lambda below. Throw a diagnostic + // exception instead so the failure is unambiguous (and so the + // finally block's _runtimeSession?.Dispose() can't silently no-op + // on a torn half-initialized session). Mirrors the same pattern + // AlarmCommandHandler.Subscribe uses for its consumerFactory(). + _runtimeSession = _runtimeSessionFactory() + ?? throw new InvalidOperationException( + "Worker runtime session factory returned null."); try { await CompleteStartupHandshakeAsync( @@ -115,11 +127,14 @@ public sealed class WorkerPipeSession } finally { - if (!_shutdownTimedOut) - { - _runtimeSession?.Dispose(); - } - + // Always dispose the runtime session, including after a + // shutdown timeout. MxAccessStaSession.Dispose is idempotent and + // bounded (each STA join is capped at 2s), so re-entering it on + // the normal path is a harmless no-op, while on the timed-out + // path it is the only thing that reclaims the STA thread and + // releases the MXAccess COM object — skipping it leaked both and + // left cleanup to rely solely on process exit. + _runtimeSession?.Dispose(); _runtimeSession = null; _state = WorkerState.Stopped; } @@ -396,8 +411,17 @@ public sealed class WorkerPipeSession try { MxCommandReply reply = await runtimeSession.DispatchAsync(staCommand).ConfigureAwait(false); - if (_state is not WorkerState.Ready and not WorkerState.ExecutingCommand) + // _state is only ever assigned Starting, Handshaking, InitializingSta, + // Ready, ShuttingDown, Faulted, or Stopped — never ExecutingCommand + // (that value is synthesized in CreateHeartbeat from the live + // CurrentCommandCorrelationId and never written back to _state). So + // the only command-serving state is Ready; anything else means a + // state transition (shutdown / fault) raced the command's + // completion and we must drop the reply rather than write into a + // half-torn-down pipe. + if (_state != WorkerState.Ready) { + LogCommandResultDropped(envelope.CorrelationId, staCommand.MethodName); return; } @@ -413,8 +437,9 @@ public sealed class WorkerPipeSession } catch (Exception exception) when (exception is not OperationCanceledException) { - if (_state is not WorkerState.Ready and not WorkerState.ExecutingCommand) + if (_state != WorkerState.Ready) { + LogCommandResultDropped(envelope.CorrelationId, staCommand.MethodName); return; } @@ -428,6 +453,25 @@ public sealed class WorkerPipeSession } } + /// + /// Logs that a completed command result was dropped because the + /// worker is no longer in a command-serving state (typically a + /// shutdown that raced the command's completion). Without this + /// diagnostic the gateway's correlation-id wait blocks until its own + /// timeout with no trace of why no reply arrived. + /// + private void LogCommandResultDropped(string correlationId, string commandMethod) + { + _logger?.Information( + "WorkerCommandResultDropped", + new Dictionary + { + ["correlation_id"] = correlationId, + ["command_method"] = commandMethod, + ["worker_state"] = _state.ToString(), + }); + } + private async Task ShutdownAsync( WorkerShutdown shutdown, CancellationToken cancellationToken) @@ -455,7 +499,6 @@ public sealed class WorkerPipeSession } catch (TimeoutException exception) { - _shutdownTimedOut = true; _state = WorkerState.Faulted; await TryWriteFaultAsync(CreateShutdownTimeoutFault(exception), cancellationToken).ConfigureAwait(false); throw; @@ -544,9 +587,20 @@ public sealed class WorkerPipeSession private async Task RunHeartbeatLoopAsync(CancellationToken cancellationToken) { + // The first heartbeat is sent immediately on entering the loop so the + // gateway's liveness watchdog sees a beat as soon as the worker is + // Ready; the delay is applied between subsequent beats only. A + // delay-before-first-beat loop would leave the gateway without a + // heartbeat for a full HeartbeatInterval after startup. + bool firstBeat = true; while (!cancellationToken.IsCancellationRequested) { - await Task.Delay(_sessionOptions.HeartbeatInterval, cancellationToken).ConfigureAwait(false); + if (!firstBeat) + { + await Task.Delay(_sessionOptions.HeartbeatInterval, cancellationToken).ConfigureAwait(false); + } + + firstBeat = false; IWorkerRuntimeSession? runtimeSession = _runtimeSession; if (runtimeSession is null) { @@ -562,6 +616,36 @@ public sealed class WorkerPipeSession } } + /// + /// The watchdog detects a hung STA (no thread activity for longer than + /// HeartbeatGrace) and emits an StaHung fault. Design + /// intent: catch a stuck STA thread, not a legitimately long-running + /// command. StaRuntime.ProcessQueuedCommands calls + /// MarkActivity() only immediately before and after + /// workItem.Execute(), so a synchronously long-running STA + /// command (e.g. ReadBulk waiting timeout_ms for the + /// first OnDataChange callback) freezes LastActivityUtc for the + /// duration of the wait even though the worker is healthy. To avoid + /// self-faulting a healthy in-flight command (Worker-017), the + /// watchdog is suppressed while CurrentCommandCorrelationId is + /// non-empty — the worker already advertises the in-flight command on + /// each heartbeat, so the gateway has the signal it needs to decide + /// the command is just slow. The watchdog still fires on a truly hung + /// STA (no command in flight and no activity), which is the only case + /// the watchdog can usefully distinguish from a slow command. + /// + /// + /// Worker-023: the in-flight-command suppression is itself bounded by + /// WorkerPipeSessionOptions.HeartbeatStuckCeiling. A truly stuck + /// synchronous COM call (e.g. against a dead MXAccess provider whose + /// cross-apartment marshaler is permanently blocked) leaves + /// CurrentCommandCorrelationId non-empty forever; without an + /// upper bound the worker-side StaHung watchdog would be + /// permanently defeated and only the gateway's per-command timeout + /// would catch the hang. Once LastActivityUtc has been stale + /// for longer than HeartbeatStuckCeiling the watchdog fires + /// StaHung regardless of whether a command is in flight. + /// private async Task ReportWatchdogFaultIfNeededAsync( WorkerRuntimeHeartbeatSnapshot snapshot, CancellationToken cancellationToken) @@ -573,12 +657,37 @@ public sealed class WorkerPipeSession return; } + if (!string.IsNullOrEmpty(snapshot.CurrentCommandCorrelationId) + && staleFor <= _sessionOptions.HeartbeatStuckCeiling) + { + // A command is in flight and we are still within the defensive + // suppression ceiling — the STA is busy executing it, not + // hung. The next MarkActivity() in StaRuntime.ProcessQueuedCommands + // will refresh LastActivityUtc once the command returns, at which + // point this branch stops being taken. The heartbeat already + // surfaces the in-flight correlation id so the gateway can apply + // its own per-command timeout if it considers the command too slow. + // + // Worker-023: once staleFor exceeds HeartbeatStuckCeiling we fall + // through to the fault path even with a command in flight — a + // truly stuck synchronous COM call would otherwise keep + // CurrentCommandCorrelationId non-empty indefinitely and the + // worker-side watchdog would never fire. + return; + } + if (_watchdogFaultSent) { return; } _watchdogFaultSent = true; + + // The STA is hung — move the session to Faulted before the next + // heartbeat so the heartbeat's reported State stays consistent with + // the StaHung fault just sent. Without this the heartbeat loop keeps + // advertising a non-faulted state that contradicts the fault. + _state = WorkerState.Faulted; await TryWriteFaultAsync( CreateFault( WorkerFaultCategory.StaHung, @@ -746,16 +855,29 @@ public sealed class WorkerPipeSession private async Task InitializeMxAccessAsync(CancellationToken cancellationToken) { - _runtimeSession = new MxAccessStaSession(); + // RunAsync constructs the runtime session via _runtimeSessionFactory() + // before invoking CompleteStartupHandshakeAsync, so on the production + // path _runtimeSession is already non-null when this default + // initializer runs. Treat that pre-existing instance as authoritative + // and only drive its StartAsync — unconditionally reassigning + // _runtimeSession here would leak the factory-supplied session (no + // Dispose) and replace it with a hard-coded MxAccessStaSession, + // discarding the factory's configuration. The fall-back construction + // is preserved for the legacy direct-invocation path where the + // parameterless CompleteStartupHandshakeAsync is used without a + // prior factory call. + _runtimeSession ??= new MxAccessStaSession( + (eq, affinity) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity)); + IWorkerRuntimeSession session = _runtimeSession; try { - return await _runtimeSession + return await session .StartAsync(_options.SessionId, _processIdProvider(), cancellationToken) .ConfigureAwait(false); } catch { - _runtimeSession.Dispose(); + session.Dispose(); _runtimeSession = null; throw; } diff --git a/src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerPipeSessionOptions.cs b/src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerPipeSessionOptions.cs new file mode 100644 index 0000000..995e445 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerPipeSessionOptions.cs @@ -0,0 +1,88 @@ +using System; + +namespace ZB.MOM.WW.MxGateway.Worker.Ipc; + +/// Configuration options for worker pipe sessions including heartbeat parameters. +public sealed class WorkerPipeSessionOptions +{ + /// Default heartbeat interval (5 seconds). + public static readonly TimeSpan DefaultHeartbeatInterval = TimeSpan.FromSeconds(5); + /// Default heartbeat grace period (15 seconds). + public static readonly TimeSpan DefaultHeartbeatGrace = TimeSpan.FromSeconds(15); + /// + /// Default defensive ceiling beyond which the watchdog fires + /// + /// even while a command is in flight (75 seconds = 5 × + /// ). See + /// for the rationale. + /// + public static readonly TimeSpan DefaultHeartbeatStuckCeiling = TimeSpan.FromSeconds(75); + + /// Initializes a new instance of the WorkerPipeSessionOptions class with default values. + public WorkerPipeSessionOptions() + { + HeartbeatInterval = DefaultHeartbeatInterval; + HeartbeatGrace = DefaultHeartbeatGrace; + HeartbeatStuckCeiling = DefaultHeartbeatStuckCeiling; + } + + /// Gets or sets the heartbeat interval. + public TimeSpan HeartbeatInterval { get; set; } + + /// Gets or sets the heartbeat grace period. + public TimeSpan HeartbeatGrace { get; set; } + + /// + /// Gets or sets the defensive upper bound on how long the watchdog + /// will suppress its StaHung fault while a command is in + /// flight. Worker-017 suppresses the watchdog when the heartbeat + /// snapshot's CurrentCommandCorrelationId is non-empty so a + /// legitimately slow command (e.g. ReadBulk against many + /// uncached tags) does not self-fault — but a truly stuck + /// synchronous COM call against a dead MXAccess provider leaves + /// CurrentCommandCorrelationId non-empty forever and would + /// permanently defeat the watchdog. HeartbeatStuckCeiling is + /// the upper bound on that suppression: once + /// LastStaActivityUtc has been stale for longer than this + /// ceiling, the watchdog DOES fire StaHung even with a + /// command in flight, on the assumption that no legitimate STA + /// command should run that long without periodically refreshing + /// activity. Default is + /// (75 seconds = 5 × ); raise + /// for deployments that run very long bulk operations. + /// + public TimeSpan HeartbeatStuckCeiling { get; set; } + + /// Validates the session options. + public void Validate() + { + if (HeartbeatInterval <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException( + nameof(HeartbeatInterval), + "Worker heartbeat interval must be greater than zero."); + } + + if (HeartbeatGrace <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException( + nameof(HeartbeatGrace), + "Worker heartbeat grace must be greater than zero."); + } + + if (HeartbeatStuckCeiling <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException( + nameof(HeartbeatStuckCeiling), + "Worker heartbeat stuck ceiling must be greater than zero."); + } + + if (HeartbeatStuckCeiling <= HeartbeatGrace) + { + throw new ArgumentOutOfRangeException( + nameof(HeartbeatStuckCeiling), + "Worker heartbeat stuck ceiling must be greater than HeartbeatGrace; " + + "otherwise it would fire before the in-flight-command suppression had any effect."); + } + } +} diff --git a/src/MxGateway.Worker/MxAccess/AlarmCommandHandler.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmCommandHandler.cs similarity index 73% rename from src/MxGateway.Worker/MxAccess/AlarmCommandHandler.cs rename to src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmCommandHandler.cs index 7867de8..a188aec 100644 --- a/src/MxGateway.Worker/MxAccess/AlarmCommandHandler.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmCommandHandler.cs @@ -1,8 +1,8 @@ using System; using System.Collections.Generic; -using MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Contracts.Proto; -namespace MxGateway.Worker.MxAccess; +namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; /// /// Per-session owner of the worker's alarm-side state. Lazy-creates an @@ -24,22 +24,25 @@ namespace MxGateway.Worker.MxAccess; /// /// /// Threading: invoked from -/// which runs on the STA. The wnwrap consumer's polling timer -/// fires on a thread-pool thread; the only cross-thread surface -/// is the 's event handler, which -/// hand-offs into the thread-safe . +/// which runs on the STA. The wnwrap consumer owns no internal +/// timer — the worker's STA drives via +/// StaRuntime.InvokeAsync, so the consumer's transition +/// events fire on the same STA. The +/// 's event handler hands transitions +/// into the thread-safe . /// /// public sealed class AlarmCommandHandler : IAlarmCommandHandler { private readonly MxAccessEventQueue eventQueue; private readonly Func consumerFactory; + private readonly Action? threadAffinityCheck; private readonly object syncRoot = new object(); private AlarmDispatcher? dispatcher; private bool disposed; public AlarmCommandHandler(MxAccessEventQueue eventQueue) - : this(eventQueue, () => new WnWrapAlarmConsumer()) + : this(eventQueue, () => new WnWrapAlarmConsumer(), threadAffinityCheck: null) { } @@ -47,9 +50,32 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler public AlarmCommandHandler( MxAccessEventQueue eventQueue, Func consumerFactory) + : this(eventQueue, consumerFactory, threadAffinityCheck: null) + { + } + + /// + /// Worker-024: production constructor that also injects an + /// STA-affinity guard. is + /// invoked at the entry of every method that touches the underlying + /// (or the wnwrap COM object + /// through it) — , , + /// , , + /// , — so an + /// off-STA call raises a programming-error diagnostic instead of + /// deadlocking on cross-apartment marshaling to the + /// ThreadingModel=Apartment wnwrap CLSID. The guard is + /// optional: tests that already drive the handler on a single + /// thread can pass null. + /// + public AlarmCommandHandler( + MxAccessEventQueue eventQueue, + Func consumerFactory, + Action? threadAffinityCheck) { this.eventQueue = eventQueue ?? throw new ArgumentNullException(nameof(eventQueue)); this.consumerFactory = consumerFactory ?? throw new ArgumentNullException(nameof(consumerFactory)); + this.threadAffinityCheck = threadAffinityCheck; } public bool IsSubscribed @@ -62,6 +88,7 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler { if (disposed) throw new ObjectDisposedException(nameof(AlarmCommandHandler)); if (subscription is null) throw new ArgumentNullException(nameof(subscription)); + threadAffinityCheck?.Invoke(); lock (syncRoot) { @@ -92,6 +119,7 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler /// public void Unsubscribe() { + threadAffinityCheck?.Invoke(); AlarmDispatcher? toDispose; lock (syncRoot) { @@ -110,6 +138,7 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler string operatorDomain, string operatorFullName) { + threadAffinityCheck?.Invoke(); AlarmDispatcher? d = GetDispatcherOrThrow(); return d.Acknowledge( alarmGuid, @@ -131,6 +160,7 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler string operatorDomain, string operatorFullName) { + threadAffinityCheck?.Invoke(); AlarmDispatcher? d = GetDispatcherOrThrow(); return d.AcknowledgeByName( alarmName ?? string.Empty, @@ -146,6 +176,7 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler /// public IReadOnlyList QueryActive(string? alarmFilterPrefix) { + threadAffinityCheck?.Invoke(); AlarmDispatcher? d = GetDispatcherOrThrow(); IReadOnlyList all = d.SnapshotActiveAlarms(); if (string.IsNullOrEmpty(alarmFilterPrefix)) return all; @@ -160,6 +191,16 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler return filtered; } + /// + public void PollOnce() + { + threadAffinityCheck?.Invoke(); + AlarmDispatcher? d; + lock (syncRoot) d = dispatcher; + // No-op when not yet subscribed or already disposed. + d?.PollOnce(); + } + private AlarmDispatcher GetDispatcherOrThrow() { if (disposed) throw new ObjectDisposedException(nameof(AlarmCommandHandler)); @@ -182,48 +223,3 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler Unsubscribe(); } } - -/// -/// Per-session interface routing the worker's alarm IPC commands — -/// SubscribeAlarmsCommand, AcknowledgeAlarmCommand, -/// QueryActiveAlarmsCommand, UnsubscribeAlarmsCommand — -/// to the underlying . Production binding -/// is ; tests substitute a fake. -/// -public interface IAlarmCommandHandler : IDisposable -{ - /// Begin a subscription against the supplied AVEVA alarm-provider expression. - void Subscribe(string subscription, string sessionId); - - /// Tear down the active subscription. No-op if not subscribed. - void Unsubscribe(); - - /// Acknowledge a single alarm by GUID. Returns AVEVA's native status (0 = success). - int Acknowledge( - Guid alarmGuid, - string comment, - string operatorUser, - string operatorNode, - string operatorDomain, - string operatorFullName); - - /// - /// Acknowledge a single alarm by (name, provider, group) — used when - /// the caller has the human-readable reference but not the GUID. - /// - int AcknowledgeByName( - string alarmName, - string providerName, - string groupName, - string comment, - string operatorUser, - string operatorNode, - string operatorDomain, - string operatorFullName); - - /// - /// Snapshot the currently-active alarm set, optionally scoped to a - /// prefix matched against AlarmFullReference. - /// - IReadOnlyList QueryActive(string? alarmFilterPrefix); -} diff --git a/src/MxGateway.Worker/MxAccess/AlarmDispatcher.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs similarity index 86% rename from src/MxGateway.Worker/MxAccess/AlarmDispatcher.cs rename to src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs index cc4e617..ac46d66 100644 --- a/src/MxGateway.Worker/MxAccess/AlarmDispatcher.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs @@ -1,8 +1,8 @@ using System; using System.Collections.Generic; -using MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Contracts.Proto; -namespace MxGateway.Worker.MxAccess; +namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; /// /// In-process dispatcher that owns the lifetime of an @@ -14,22 +14,20 @@ namespace MxGateway.Worker.MxAccess; /// /// /// -/// This is the in-process slice of A.3 — it proves the -/// consumer→sink→queue pipeline end-to-end without touching the -/// worker's IPC command framing. The companion follow-up PR adds -/// SubscribeAlarmsCommand / AcknowledgeAlarmCommand / -/// QueryActiveAlarmsCommand proto entries plus the gateway- -/// side WorkerAlarmRpcDispatcher that issues them. +/// The dispatcher carries the consumer→sink→queue pipeline. The +/// worker's IPC layer issues SubscribeAlarmsCommand / +/// AcknowledgeAlarmCommand / QueryActiveAlarmsCommand +/// through , which owns one +/// dispatcher per session. /// /// -/// Threading: polls on a -/// thread today; production -/// hosting should marshal the consumer onto the worker's STA via -/// StaRuntime.InvokeAsync. The dispatcher itself is purely -/// a pass-through, so it inherits whatever thread the consumer's -/// event handler fires on. Fan-out into EnqueueTransition -/// uses which is -/// thread-safe. +/// Threading: owns no internal +/// timer — the worker's STA drives polling via +/// StaRuntime.InvokeAsync(() => PollOnce()), so the +/// consumer's AlarmTransitionEmitted event fires on the STA. +/// The dispatcher is purely a pass-through, so it inherits that +/// thread. Fan-out into EnqueueTransition uses the +/// thread-safe . /// /// public sealed class AlarmDispatcher : IDisposable @@ -119,6 +117,17 @@ public sealed class AlarmDispatcher : IDisposable ackOperatorFullName); } + /// + /// Drives a single synchronous poll of the underlying consumer. + /// Must be called on the STA thread that owns the wnwrap COM object. + /// No-op if the dispatcher has been disposed. + /// + public void PollOnce() + { + if (disposed) return; + consumer.PollOnce(); + } + /// /// Snapshot the currently-active alarm set as /// protos for the diff --git a/src/MxGateway.Worker/MxAccess/AlarmRecordTransitionMapper.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmRecordTransitionMapper.cs similarity index 98% rename from src/MxGateway.Worker/MxAccess/AlarmRecordTransitionMapper.cs rename to src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmRecordTransitionMapper.cs index 4865828..b7003f3 100644 --- a/src/MxGateway.Worker/MxAccess/AlarmRecordTransitionMapper.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmRecordTransitionMapper.cs @@ -1,7 +1,7 @@ using System; -using MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Contracts.Proto; -namespace MxGateway.Worker.MxAccess; +namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; /// /// Translation helpers between the wnwrapConsumer XML payload and the diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IAlarmCommandHandler.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IAlarmCommandHandler.cs new file mode 100644 index 0000000..3d346ae --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IAlarmCommandHandler.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; +using ZB.MOM.WW.MxGateway.Contracts.Proto; + +namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; + +/// +/// Per-session interface routing the worker's alarm IPC commands — +/// SubscribeAlarmsCommand, AcknowledgeAlarmCommand, +/// QueryActiveAlarmsCommand, UnsubscribeAlarmsCommand — +/// to the underlying . Production binding +/// is ; tests substitute a fake. +/// +public interface IAlarmCommandHandler : IDisposable +{ + /// Begin a subscription against the supplied AVEVA alarm-provider expression. + void Subscribe(string subscription, string sessionId); + + /// Tear down the active subscription. No-op if not subscribed. + void Unsubscribe(); + + /// Acknowledge a single alarm by GUID. Returns AVEVA's native status (0 = success). + int Acknowledge( + Guid alarmGuid, + string comment, + string operatorUser, + string operatorNode, + string operatorDomain, + string operatorFullName); + + /// + /// Acknowledge a single alarm by (name, provider, group) — used when + /// the caller has the human-readable reference but not the GUID. + /// + int AcknowledgeByName( + string alarmName, + string providerName, + string groupName, + string comment, + string operatorUser, + string operatorNode, + string operatorDomain, + string operatorFullName); + + /// + /// Snapshot the currently-active alarm set, optionally scoped to a + /// prefix matched against AlarmFullReference. + /// + IReadOnlyList QueryActive(string? alarmFilterPrefix); + + /// + /// Drives a single poll of the underlying alarm consumer on the + /// caller's thread. This is a no-op when there is no active + /// subscription. In production the caller is the worker's STA + /// (marshalled via StaRuntime.InvokeAsync), which satisfies + /// the ThreadingModel=Apartment requirement of + /// wwAlarmConsumerClass. + /// + void PollOnce(); +} diff --git a/src/MxGateway.Worker/MxAccess/IMxAccessAlarmConsumer.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IMxAccessAlarmConsumer.cs similarity index 83% rename from src/MxGateway.Worker/MxAccess/IMxAccessAlarmConsumer.cs rename to src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IMxAccessAlarmConsumer.cs index c2973bd..484ef96 100644 --- a/src/MxGateway.Worker/MxAccess/IMxAccessAlarmConsumer.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IMxAccessAlarmConsumer.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; -namespace MxGateway.Worker.MxAccess; +namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; /// /// Abstraction over an AVEVA alarm-consumer COM library. The production @@ -42,8 +42,8 @@ public interface IMxAccessAlarmConsumer : IDisposable /// Subscription string follows AVEVA's canonical format: /// \\<node>\Galaxy!<area>. The literal "Galaxy" is /// the provider name (regardless of the configured Galaxy database - /// name). Calling Subscribe also begins polling on the consumer's - /// internal timer. + /// name). Subscribe does not start any polling of its own; the caller + /// drives polls explicitly via . /// void Subscribe(string subscription); @@ -85,4 +85,15 @@ public interface IMxAccessAlarmConsumer : IDisposable /// to seed local Part 9 state. /// IReadOnlyList SnapshotActiveAlarms(); + + /// + /// Drives a single synchronous poll of the underlying alarm source. + /// The production consumer owns no internal timer; the worker's STA + /// drives polls via StaRuntime.InvokeAsync, satisfying the + /// ThreadingModel=Apartment requirement of + /// wwAlarmConsumerClass. Fake implementations should no-op. + /// This method must be invoked on the thread that created the consumer + /// (the worker's STA in production). + /// + void PollOnce(); } diff --git a/src/MxGateway.Worker/MxAccess/IMxAccessComObjectFactory.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IMxAccessComObjectFactory.cs similarity index 80% rename from src/MxGateway.Worker/MxAccess/IMxAccessComObjectFactory.cs rename to src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IMxAccessComObjectFactory.cs index 0b435ea..d6de189 100644 --- a/src/MxGateway.Worker/MxAccess/IMxAccessComObjectFactory.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IMxAccessComObjectFactory.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Worker.MxAccess; +namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; public interface IMxAccessComObjectFactory { diff --git a/src/MxGateway.Worker/MxAccess/IMxAccessEventSink.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IMxAccessEventSink.cs similarity index 89% rename from src/MxGateway.Worker/MxAccess/IMxAccessEventSink.cs rename to src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IMxAccessEventSink.cs index 80c9545..83a477c 100644 --- a/src/MxGateway.Worker/MxAccess/IMxAccessEventSink.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IMxAccessEventSink.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Worker.MxAccess; +namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; public interface IMxAccessEventSink { diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IMxAccessServer.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IMxAccessServer.cs new file mode 100644 index 0000000..ed5beb3 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IMxAccessServer.cs @@ -0,0 +1,111 @@ +namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; + +public interface IMxAccessServer +{ + /// Registers a client and returns a server handle. + /// Name of the client requesting registration. + /// Server handle for subsequent operations. + int Register(string clientName); + + /// Unregisters a server handle. + /// Server handle to unregister. + void Unregister(int serverHandle); + + /// Adds an item to a server and returns an item handle. + /// Server handle identifying the registration. + /// Item definition string. + /// Item handle for the added item. + int AddItem( + int serverHandle, + string itemDefinition); + + /// Adds an item with context to a server and returns an item handle. + /// Server handle identifying the registration. + /// Item definition string. + /// Item context string. + /// Item handle for the added item. + int AddItem2( + int serverHandle, + string itemDefinition, + string itemContext); + + /// Removes an item from a server. + /// Server handle identifying the registration. + /// Item handle to remove. + void RemoveItem( + int serverHandle, + int itemHandle); + + /// Subscribes to change notifications for an item. + /// Server handle identifying the registration. + /// Item handle to subscribe to. + void Advise( + int serverHandle, + int itemHandle); + + /// Unsubscribes from change notifications for an item. + /// Server handle identifying the registration. + /// Item handle to unsubscribe from. + void UnAdvise( + int serverHandle, + int itemHandle); + + /// Subscribes to supervisory change notifications for an item. + /// Server handle identifying the registration. + /// Item handle to subscribe to. + void AdviseSupervisory( + int serverHandle, + int itemHandle); + + /// Writes a value to an item. + /// Server handle identifying the registration. + /// Item handle to write to. + /// COM-marshalable value to write; writes an MXAccess null. + /// MXAccess user id (security classification) for the write. + void Write( + int serverHandle, + int itemHandle, + object? value, + int userId); + + /// Writes a value with an explicit source timestamp to an item. + /// Server handle identifying the registration. + /// Item handle to write to. + /// COM-marshalable value to write; writes an MXAccess null. + /// COM-marshalable source timestamp for the write. + /// MXAccess user id (security classification) for the write. + void Write2( + int serverHandle, + int itemHandle, + object? value, + object? timestamp, + int userId); + + /// Performs a secured/verified write to an item. + /// Server handle identifying the registration. + /// Item handle to write to. + /// MXAccess user id of the operator performing the write. + /// MXAccess user id of the verifier authorizing the write. + /// COM-marshalable value to write; writes an MXAccess null. + void WriteSecured( + int serverHandle, + int itemHandle, + int currentUserId, + int verifierUserId, + object? value); + + /// Performs a secured/verified write with an explicit source timestamp. + /// Server handle identifying the registration. + /// Item handle to write to. + /// MXAccess user id of the operator performing the write. + /// MXAccess user id of the verifier authorizing the write. + /// COM-marshalable value to write; writes an MXAccess null. + /// COM-marshalable source timestamp for the write. + void WriteSecured2( + int serverHandle, + int itemHandle, + int currentUserId, + int verifierUserId, + object? value, + object? timestamp); +} diff --git a/src/MxGateway.Worker/MxAccess/IWorkerRuntimeSession.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IWorkerRuntimeSession.cs similarity index 95% rename from src/MxGateway.Worker/MxAccess/IWorkerRuntimeSession.cs rename to src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IWorkerRuntimeSession.cs index ceb3172..0785e0b 100644 --- a/src/MxGateway.Worker/MxAccess/IWorkerRuntimeSession.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IWorkerRuntimeSession.cs @@ -2,10 +2,10 @@ using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; -using MxGateway.Contracts.Proto; -using MxGateway.Worker.Sta; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Worker.Sta; -namespace MxGateway.Worker.MxAccess; +namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; /// /// Manages the runtime session between the worker and the MXAccess COM instance on an STA thread. diff --git a/src/MxGateway.Worker/MxAccess/MxAccessAdviceKind.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessAdviceKind.cs similarity index 60% rename from src/MxGateway.Worker/MxAccess/MxAccessAdviceKind.cs rename to src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessAdviceKind.cs index a3e1b25..79fa363 100644 --- a/src/MxGateway.Worker/MxAccess/MxAccessAdviceKind.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessAdviceKind.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Worker.MxAccess; +namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; public enum MxAccessAdviceKind { diff --git a/src/MxGateway.Worker/MxAccess/MxAccessAlarmEventSink.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessAlarmEventSink.cs similarity index 79% rename from src/MxGateway.Worker/MxAccess/MxAccessAlarmEventSink.cs rename to src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessAlarmEventSink.cs index 59bf61e..33c485f 100644 --- a/src/MxGateway.Worker/MxAccess/MxAccessAlarmEventSink.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessAlarmEventSink.cs @@ -1,7 +1,7 @@ using System; -using MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Contracts.Proto; -namespace MxGateway.Worker.MxAccess; +namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; /// /// Sink for native MxAccess alarm transitions. Bridges @@ -11,13 +11,15 @@ namespace MxGateway.Worker.MxAccess; /// /// /// -/// The dispatcher subscribes the consumer's +/// owns the wire-up: it constructs the +/// consumer/sink pair, calls to propagate the +/// session id, and subscribes the consumer's /// event -/// to at session attach time. The -/// override here is a stub kept for the data- -/// session shape; the actual wire-up between consumer and sink -/// lives in the A.3 dispatcher (one step up the stack). Captured -/// payload schema and consumer threading discipline are described in +/// so each decoded transition reaches . +/// The method here carries only the session id — +/// the alarm path needs no COM-event subscription of its own because +/// the consumer already polls and raises transition events. The +/// captured payload schema is described in /// docs/AlarmClientDiscovery.md "Option A — captured". /// /// @@ -47,10 +49,10 @@ public sealed class MxAccessAlarmEventSink : IMxAccessEventSink if (mxAccessComObject is null) throw new ArgumentNullException(nameof(mxAccessComObject)); this.sessionId = sessionId ?? string.Empty; - // PR A.2 — COM-side subscription scaffold. The MXAccess Toolkit alarm - // event source is pinned during dev-rig validation. Until then, the - // worker advertises no alarm subscription; data-change behaviour is - // unaffected. + // The alarm path needs no COM-event subscription here: the wnwrap + // consumer is polled by the worker's STA and raises transition events + // that AlarmDispatcher routes into EnqueueTransition. Attach only + // records the session id stamped onto every emitted MxEvent. attached = true; } diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessBaseEventSink.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessBaseEventSink.cs new file mode 100644 index 0000000..66c5053 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessBaseEventSink.cs @@ -0,0 +1,254 @@ +using System; +using ArchestrA.MxAccess; +using Proto = ZB.MOM.WW.MxGateway.Contracts.Proto; + +namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; + +/// Sink for MXAccess COM events that converts them to protobuf format. +public sealed class MxAccessBaseEventSink : IMxAccessEventSink +{ + private readonly MxAccessEventMapper eventMapper; + private readonly MxAccessEventQueue eventQueue; + private readonly MxAccessValueCache valueCache; + private LMXProxyServerClass? server; + private string sessionId = string.Empty; + + /// Initializes a new instance of the MxAccessBaseEventSink class with a default queue. + public MxAccessBaseEventSink() + : this(new MxAccessEventQueue()) + { + } + + /// Initializes a new instance of the MxAccessBaseEventSink class with a provided queue. + /// Queue for buffering converted MXAccess events. + public MxAccessBaseEventSink(MxAccessEventQueue eventQueue) + : this(eventQueue, new MxAccessEventMapper(), new MxAccessValueCache()) + { + } + + /// Initializes a new instance of the MxAccessBaseEventSink class with provided queue and mapper. + /// Queue for buffering converted MXAccess events. + /// Converter for MXAccess events to protobuf format. + public MxAccessBaseEventSink( + MxAccessEventQueue eventQueue, + MxAccessEventMapper eventMapper) + : this(eventQueue, eventMapper, new MxAccessValueCache()) + { + } + + /// + /// Initializes a new instance of the MxAccessBaseEventSink class with + /// provided queue, mapper, and a shared value cache. The cache is + /// populated from every successful OnDataChange dispatch so the + /// worker's ReadBulk executor can satisfy a "current value" request + /// from an already-advised tag without touching the subscription. + /// + /// Queue for buffering converted MXAccess events. + /// Converter for MXAccess events to protobuf format. + /// Per-session last-value cache shared with the MxAccessSession. + public MxAccessBaseEventSink( + MxAccessEventQueue eventQueue, + MxAccessEventMapper eventMapper, + MxAccessValueCache valueCache) + { + this.eventQueue = eventQueue ?? throw new ArgumentNullException(nameof(eventQueue)); + this.eventMapper = eventMapper ?? throw new ArgumentNullException(nameof(eventMapper)); + this.valueCache = valueCache ?? throw new ArgumentNullException(nameof(valueCache)); + } + + /// + /// The last-value cache populated by this sink. Exposed so the + /// MxAccessSession can share the same instance for ReadBulk lookups. + /// + public MxAccessValueCache ValueCache => valueCache; + + /// + public void Attach( + object mxAccessComObject, + string sessionId) + { + this.sessionId = sessionId ?? string.Empty; + server = (LMXProxyServerClass)mxAccessComObject; + server.OnDataChange += OnDataChange; + server.OnWriteComplete += OnWriteComplete; + server.OperationComplete += OperationComplete; + server.OnBufferedDataChange += OnBufferedDataChange; + } + + /// + public void Detach() + { + if (server is null) + { + return; + } + + server.OnDataChange -= OnDataChange; + server.OnWriteComplete -= OnWriteComplete; + server.OperationComplete -= OperationComplete; + server.OnBufferedDataChange -= OnBufferedDataChange; + server = null; + sessionId = string.Empty; + } + + /// + /// Handles the MXAccess OnDataChange COM event: converts the + /// event arguments to a protobuf and enqueues + /// it. Subscribed to the COM object's event in . + /// Exposed internal so unit tests can drive the integrated + /// sink → mapper → queue path without a live MXAccess COM event source. + /// + internal void OnDataChange( + int hLMXServerHandle, + int phItemHandle, + object pvItemValue, + int pwItemQuality, + object pftItemTimeStamp, + ref MXSTATUS_PROXY[] pVars) + { + MXSTATUS_PROXY[] statuses = pVars; + // Build the protobuf event once, enqueue it for the outbound stream, and + // also publish it into the per-session value cache so ReadBulk can serve + // it as a "current value" without re-advising. The cache update is the + // ONLY new side effect — fail-fast on conversion still drops the event + // through the same EnqueueEvent path as before. + EnqueueEvent( + () => eventMapper.CreateOnDataChange( + sessionId, + hLMXServerHandle, + phItemHandle, + pvItemValue, + pwItemQuality, + pftItemTimeStamp, + statuses), + mxEvent => valueCache.Set(hLMXServerHandle, phItemHandle, mxEvent)); + } + + /// + /// Handles the MXAccess OnWriteComplete COM event. Exposed + /// internal as a unit-test seam; see . + /// + internal void OnWriteComplete( + int hLMXServerHandle, + int phItemHandle, + ref MXSTATUS_PROXY[] pVars) + { + MXSTATUS_PROXY[] statuses = pVars; + EnqueueEvent(() => eventMapper.CreateOnWriteComplete( + sessionId, + hLMXServerHandle, + phItemHandle, + statuses)); + } + + /// + /// Handles the MXAccess OperationComplete COM event. Exposed + /// internal as a unit-test seam; see . + /// + internal void OperationComplete( + int hLMXServerHandle, + int phItemHandle, + ref MXSTATUS_PROXY[] pVars) + { + MXSTATUS_PROXY[] statuses = pVars; + EnqueueEvent(() => eventMapper.CreateOperationComplete( + sessionId, + hLMXServerHandle, + phItemHandle, + statuses)); + } + + /// + /// Handles the MXAccess OnBufferedDataChange COM event. Exposed + /// internal as a unit-test seam; see . + /// + internal void OnBufferedDataChange( + int hLMXServerHandle, + int phItemHandle, + MxDataType dtDataType, + object pvItemValue, + object pwItemQuality, + object pftItemTimeStamp, + ref MXSTATUS_PROXY[] pVars) + { + MXSTATUS_PROXY[] statuses = pVars; + EnqueueEvent(() => eventMapper.CreateOnBufferedDataChange( + sessionId, + hLMXServerHandle, + phItemHandle, + (int)dtDataType, + pvItemValue, + pwItemQuality, + pftItemTimeStamp, + statuses)); + } + + private void EnqueueEvent(Func createEvent) + { + EnqueueEvent(createEvent, postPublish: null); + } + + private void EnqueueEvent(Func createEvent, Action? postPublish) + { + Proto.MxEvent mxEvent; + try + { + mxEvent = createEvent(); + } + catch (Exception exception) + { + eventQueue.RecordFault(CreateEventConversionFault(exception)); + return; + } + + try + { + eventQueue.Enqueue(mxEvent); + } + catch (Exception exception) + { + // Two distinct failures land here, both intentionally fail-fast: + // - A conversion failure from createEvent() — recorded here as an + // MxaccessEventConversionFailed fault. + // - An MxAccessEventQueueOverflowException from Enqueue when the + // queue is at capacity. Per the fail-fast backpressure design + // (docs/DesignDecisions.md) the event is dropped and the queue + // has *already* self-recorded a QueueOverflow fault. Because + // MxAccessEventQueue.RecordFault keeps only the first fault, + // this catch's RecordFault call is then a deliberate near + // no-op rather than a second, conflicting fault. + eventQueue.RecordFault(CreateEventConversionFault(exception)); + return; + } + + // Only publish to caches/observers after the event has cleared the + // queue, so a queue overflow does not leak a "fresher" cached value + // than what was actually shipped to the gateway. + if (postPublish is not null) + { + try + { + postPublish(mxEvent); + } + catch (Exception exception) + { + eventQueue.RecordFault(CreateEventConversionFault(exception)); + } + } + } + + private Proto.WorkerFault CreateEventConversionFault(Exception exception) + { + return new Proto.WorkerFault + { + Category = Proto.WorkerFaultCategory.MxaccessEventConversionFailed, + ExceptionType = exception.GetType().FullName ?? string.Empty, + DiagnosticMessage = $"{exception.GetType().FullName}: HRESULT 0x{unchecked((uint)exception.HResult):X8}", + ProtocolStatus = new Proto.ProtocolStatus + { + Code = Proto.ProtocolStatusCode.MxaccessFailure, + Message = "MXAccess event conversion failed.", + }, + }; + } +} diff --git a/src/MxGateway.Worker/MxAccess/MxAccessComObjectFactory.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessComObjectFactory.cs similarity index 86% rename from src/MxGateway.Worker/MxAccess/MxAccessComObjectFactory.cs rename to src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessComObjectFactory.cs index ceb0167..704d4cb 100644 --- a/src/MxGateway.Worker/MxAccess/MxAccessComObjectFactory.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessComObjectFactory.cs @@ -1,6 +1,6 @@ using ArchestrA.MxAccess; -namespace MxGateway.Worker.MxAccess; +namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; /// Factory for creating MXAccess COM objects on the STA thread. public sealed class MxAccessComObjectFactory : IMxAccessComObjectFactory diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessComServer.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessComServer.cs new file mode 100644 index 0000000..db159c1 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessComServer.cs @@ -0,0 +1,234 @@ +using System; +using ArchestrA.MxAccess; + +namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; + +/// +/// Adapter exposing MXAccess COM object methods through the +/// interface. +/// +/// +/// The supplied object must implement the typed MXAccess COM interface contract. +/// In production it is the LMXProxyServerClass RCW, which implements +/// / / +/// . Tests substitute a typed fake that +/// implements directly. The earlier late-bound +/// Type.InvokeMember reflection fallback was removed: it bypassed the +/// typed interface contract, boxed value-type handles on every call, and only +/// ever served test doubles — a typed fake is the supported test seam now. +/// +public sealed class MxAccessComServer : IMxAccessServer +{ + private readonly object mxAccessComObject; + + /// + /// Initializes the adapter with the MXAccess COM object. + /// + /// + /// MXAccess COM object instance. Must implement either the typed + /// COM interface family (production) or + /// directly (test fakes). + /// + public MxAccessComServer(object mxAccessComObject) + { + this.mxAccessComObject = mxAccessComObject ?? throw new ArgumentNullException(nameof(mxAccessComObject)); + } + + /// + public int Register(string clientName) + { + if (mxAccessComObject is IMxAccessServer typedFake) + { + return typedFake.Register(clientName); + } + + return AsProxyServer().Register(clientName); + } + + /// + public void Unregister(int serverHandle) + { + if (mxAccessComObject is IMxAccessServer typedFake) + { + typedFake.Unregister(serverHandle); + return; + } + + AsProxyServer().Unregister(serverHandle); + } + + /// + public int AddItem( + int serverHandle, + string itemDefinition) + { + if (mxAccessComObject is IMxAccessServer typedFake) + { + return typedFake.AddItem(serverHandle, itemDefinition); + } + + return AsProxyServer().AddItem(serverHandle, itemDefinition); + } + + /// + public int AddItem2( + int serverHandle, + string itemDefinition, + string itemContext) + { + if (mxAccessComObject is IMxAccessServer typedFake) + { + return typedFake.AddItem2(serverHandle, itemDefinition, itemContext); + } + + return AsProxyServer3().AddItem2(serverHandle, itemDefinition, itemContext); + } + + /// + public void RemoveItem( + int serverHandle, + int itemHandle) + { + if (mxAccessComObject is IMxAccessServer typedFake) + { + typedFake.RemoveItem(serverHandle, itemHandle); + return; + } + + AsProxyServer().RemoveItem(serverHandle, itemHandle); + } + + /// + public void Advise( + int serverHandle, + int itemHandle) + { + if (mxAccessComObject is IMxAccessServer typedFake) + { + typedFake.Advise(serverHandle, itemHandle); + return; + } + + AsProxyServer().Advise(serverHandle, itemHandle); + } + + /// + public void UnAdvise( + int serverHandle, + int itemHandle) + { + if (mxAccessComObject is IMxAccessServer typedFake) + { + typedFake.UnAdvise(serverHandle, itemHandle); + return; + } + + AsProxyServer().UnAdvise(serverHandle, itemHandle); + } + + /// + public void AdviseSupervisory( + int serverHandle, + int itemHandle) + { + if (mxAccessComObject is IMxAccessServer typedFake) + { + typedFake.AdviseSupervisory(serverHandle, itemHandle); + return; + } + + AsProxyServer4().AdviseSupervisory(serverHandle, itemHandle); + } + + /// + public void Write( + int serverHandle, + int itemHandle, + object? value, + int userId) + { + if (mxAccessComObject is IMxAccessServer typedFake) + { + typedFake.Write(serverHandle, itemHandle, value, userId); + return; + } + + AsProxyServer().Write(serverHandle, itemHandle, value!, userId); + } + + /// + public void Write2( + int serverHandle, + int itemHandle, + object? value, + object? timestamp, + int userId) + { + if (mxAccessComObject is IMxAccessServer typedFake) + { + typedFake.Write2(serverHandle, itemHandle, value, timestamp, userId); + return; + } + + AsProxyServer4().Write2(serverHandle, itemHandle, value!, timestamp!, userId); + } + + /// + public void WriteSecured( + int serverHandle, + int itemHandle, + int currentUserId, + int verifierUserId, + object? value) + { + if (mxAccessComObject is IMxAccessServer typedFake) + { + typedFake.WriteSecured(serverHandle, itemHandle, currentUserId, verifierUserId, value); + return; + } + + AsProxyServer().WriteSecured(serverHandle, itemHandle, currentUserId, verifierUserId, value!); + } + + /// + public void WriteSecured2( + int serverHandle, + int itemHandle, + int currentUserId, + int verifierUserId, + object? value, + object? timestamp) + { + if (mxAccessComObject is IMxAccessServer typedFake) + { + typedFake.WriteSecured2(serverHandle, itemHandle, currentUserId, verifierUserId, value, timestamp); + return; + } + + AsProxyServer4().WriteSecured2(serverHandle, itemHandle, currentUserId, verifierUserId, value!, timestamp!); + } + + private ILMXProxyServer AsProxyServer() + { + return mxAccessComObject as ILMXProxyServer + ?? throw new InvalidOperationException( + $"MXAccess COM object of type '{mxAccessComObject.GetType().FullName}' does not implement " + + $"{nameof(ILMXProxyServer)} or {nameof(IMxAccessServer)}."); + } + + private ILMXProxyServer3 AsProxyServer3() + { + return mxAccessComObject as ILMXProxyServer3 + ?? throw new InvalidOperationException( + $"MXAccess COM object of type '{mxAccessComObject.GetType().FullName}' does not implement " + + $"{nameof(ILMXProxyServer3)} or {nameof(IMxAccessServer)}."); + } + + private ILMXProxyServer4 AsProxyServer4() + { + return mxAccessComObject as ILMXProxyServer4 + ?? throw new InvalidOperationException( + $"MXAccess COM object of type '{mxAccessComObject.GetType().FullName}' does not implement " + + $"{nameof(ILMXProxyServer4)} or {nameof(IMxAccessServer)}."); + } +} diff --git a/src/MxGateway.Worker/MxAccess/MxAccessCommandExecutor.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessCommandExecutor.cs similarity index 63% rename from src/MxGateway.Worker/MxAccess/MxAccessCommandExecutor.cs rename to src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessCommandExecutor.cs index da49ffa..058f388 100644 --- a/src/MxGateway.Worker/MxAccess/MxAccessCommandExecutor.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessCommandExecutor.cs @@ -1,26 +1,30 @@ using System; using System.Collections.Generic; -using MxGateway.Contracts.Proto; -using MxGateway.Worker.Conversion; -using MxGateway.Worker.Sta; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Worker.Conversion; +using ZB.MOM.WW.MxGateway.Worker.Sta; -namespace MxGateway.Worker.MxAccess; +namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; /// /// Executes MXAccess commands on an STA session. /// public sealed class MxAccessCommandExecutor : IStaCommandExecutor { + /// Default per-tag timeout used when ReadBulkCommand.timeout_ms is zero. + internal static readonly TimeSpan DefaultReadBulkTimeout = TimeSpan.FromMilliseconds(1000); + private readonly MxAccessSession session; private readonly VariantConverter variantConverter; private readonly IAlarmCommandHandler? alarmCommandHandler; + private readonly Action pumpStep; /// /// Initializes a command executor with an MXAccess session. /// /// MXAccess session on the STA thread. public MxAccessCommandExecutor(MxAccessSession session) - : this(session, new VariantConverter(), alarmCommandHandler: null) + : this(session, new VariantConverter(), alarmCommandHandler: null, pumpStep: null) { } @@ -32,7 +36,7 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor public MxAccessCommandExecutor( MxAccessSession session, VariantConverter variantConverter) - : this(session, variantConverter, alarmCommandHandler: null) + : this(session, variantConverter, alarmCommandHandler: null, pumpStep: null) { } @@ -46,10 +50,29 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor MxAccessSession session, VariantConverter variantConverter, IAlarmCommandHandler? alarmCommandHandler) + : this(session, variantConverter, alarmCommandHandler, pumpStep: null) + { + } + + /// + /// Initializes a command executor with an MXAccess session, variant + /// converter, alarm command handler, and a Windows-message pump action. + /// The pump action is invoked from inside ReadBulk's wait loop so + /// MXAccess COM events queued for this STA can be dispatched while the + /// executor is still holding the thread. Pass null in tests where + /// ReadBulk is exercised against a fake worker that pre-populates the + /// value cache — the executor falls back to a no-op pump step. + /// + public MxAccessCommandExecutor( + MxAccessSession session, + VariantConverter variantConverter, + IAlarmCommandHandler? alarmCommandHandler, + Action? pumpStep) { this.session = session ?? throw new ArgumentNullException(nameof(session)); this.variantConverter = variantConverter ?? throw new ArgumentNullException(nameof(variantConverter)); this.alarmCommandHandler = alarmCommandHandler; + this.pumpStep = pumpStep ?? (static () => { }); } /// @@ -74,12 +97,21 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor MxCommandKind.Advise => ExecuteAdvise(command), MxCommandKind.UnAdvise => ExecuteUnAdvise(command), MxCommandKind.AdviseSupervisory => ExecuteAdviseSupervisory(command), + MxCommandKind.Write => ExecuteWrite(command), + MxCommandKind.Write2 => ExecuteWrite2(command), + MxCommandKind.WriteSecured => ExecuteWriteSecured(command), + MxCommandKind.WriteSecured2 => ExecuteWriteSecured2(command), MxCommandKind.AddItemBulk => ExecuteAddItemBulk(command), MxCommandKind.AdviseItemBulk => ExecuteAdviseItemBulk(command), MxCommandKind.RemoveItemBulk => ExecuteRemoveItemBulk(command), MxCommandKind.UnAdviseItemBulk => ExecuteUnAdviseItemBulk(command), MxCommandKind.SubscribeBulk => ExecuteSubscribeBulk(command), MxCommandKind.UnsubscribeBulk => ExecuteUnsubscribeBulk(command), + MxCommandKind.WriteBulk => ExecuteWriteBulk(command), + MxCommandKind.Write2Bulk => ExecuteWrite2Bulk(command), + MxCommandKind.WriteSecuredBulk => ExecuteWriteSecuredBulk(command), + MxCommandKind.WriteSecured2Bulk => ExecuteWriteSecured2Bulk(command), + MxCommandKind.ReadBulk => ExecuteReadBulk(command), MxCommandKind.SubscribeAlarms => ExecuteSubscribeAlarms(command), MxCommandKind.UnsubscribeAlarms => ExecuteUnsubscribeAlarms(command), MxCommandKind.AcknowledgeAlarm => ExecuteAcknowledgeAlarm(command), @@ -223,6 +255,108 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor return CreateOkReply(command); } + private MxCommandReply ExecuteWrite(StaCommand command) + { + if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.Write) + { + return CreateInvalidRequestReply(command, "Write command payload is required."); + } + + WriteCommand writeCommand = command.Command.Write; + if (writeCommand.Value is null) + { + return CreateInvalidRequestReply(command, "Write command value is required."); + } + + session.Write( + writeCommand.ServerHandle, + writeCommand.ItemHandle, + variantConverter.ConvertToComValue(writeCommand.Value), + writeCommand.UserId); + + return CreateOkReply(command); + } + + private MxCommandReply ExecuteWrite2(StaCommand command) + { + if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.Write2) + { + return CreateInvalidRequestReply(command, "Write2 command payload is required."); + } + + Write2Command write2Command = command.Command.Write2; + if (write2Command.Value is null) + { + return CreateInvalidRequestReply(command, "Write2 command value is required."); + } + + if (write2Command.TimestampValue is null) + { + return CreateInvalidRequestReply(command, "Write2 command timestamp value is required."); + } + + session.Write2( + write2Command.ServerHandle, + write2Command.ItemHandle, + variantConverter.ConvertToComValue(write2Command.Value), + variantConverter.ConvertToComValue(write2Command.TimestampValue), + write2Command.UserId); + + return CreateOkReply(command); + } + + private MxCommandReply ExecuteWriteSecured(StaCommand command) + { + if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.WriteSecured) + { + return CreateInvalidRequestReply(command, "WriteSecured command payload is required."); + } + + WriteSecuredCommand writeSecuredCommand = command.Command.WriteSecured; + if (writeSecuredCommand.Value is null) + { + return CreateInvalidRequestReply(command, "WriteSecured command value is required."); + } + + session.WriteSecured( + writeSecuredCommand.ServerHandle, + writeSecuredCommand.ItemHandle, + writeSecuredCommand.CurrentUserId, + writeSecuredCommand.VerifierUserId, + variantConverter.ConvertToComValue(writeSecuredCommand.Value)); + + return CreateOkReply(command); + } + + private MxCommandReply ExecuteWriteSecured2(StaCommand command) + { + if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.WriteSecured2) + { + return CreateInvalidRequestReply(command, "WriteSecured2 command payload is required."); + } + + WriteSecured2Command writeSecured2Command = command.Command.WriteSecured2; + if (writeSecured2Command.Value is null) + { + return CreateInvalidRequestReply(command, "WriteSecured2 command value is required."); + } + + if (writeSecured2Command.TimestampValue is null) + { + return CreateInvalidRequestReply(command, "WriteSecured2 command timestamp value is required."); + } + + session.WriteSecured2( + writeSecured2Command.ServerHandle, + writeSecured2Command.ItemHandle, + writeSecured2Command.CurrentUserId, + writeSecured2Command.VerifierUserId, + variantConverter.ConvertToComValue(writeSecured2Command.Value), + variantConverter.ConvertToComValue(writeSecured2Command.TimestampValue)); + + return CreateOkReply(command); + } + private MxCommandReply ExecuteAddItemBulk(StaCommand command) { if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.AddItemBulk) @@ -301,6 +435,149 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor session.UnsubscribeBulk(unsubscribeBulkCommand.ServerHandle, unsubscribeBulkCommand.ItemHandles)); } + private MxCommandReply ExecuteWriteBulk(StaCommand command) + { + if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.WriteBulk) + { + return CreateInvalidRequestReply(command, "WriteBulk command payload is required."); + } + + WriteBulkCommand writeBulkCommand = command.Command.WriteBulk; + foreach (WriteBulkEntry entry in writeBulkCommand.Entries) + { + if (entry.Value is null) + { + return CreateInvalidRequestReply( + command, + $"WriteBulk entry for item handle {entry.ItemHandle} is missing its value."); + } + } + + return CreateBulkWriteReply( + command, + session.WriteBulk( + writeBulkCommand.ServerHandle, + writeBulkCommand.Entries, + value => variantConverter.ConvertToComValue(value))); + } + + private MxCommandReply ExecuteWrite2Bulk(StaCommand command) + { + if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.Write2Bulk) + { + return CreateInvalidRequestReply(command, "Write2Bulk command payload is required."); + } + + Write2BulkCommand write2BulkCommand = command.Command.Write2Bulk; + foreach (Write2BulkEntry entry in write2BulkCommand.Entries) + { + if (entry.Value is null) + { + return CreateInvalidRequestReply( + command, + $"Write2Bulk entry for item handle {entry.ItemHandle} is missing its value."); + } + + if (entry.TimestampValue is null) + { + return CreateInvalidRequestReply( + command, + $"Write2Bulk entry for item handle {entry.ItemHandle} is missing its timestamp value."); + } + } + + return CreateBulkWriteReply( + command, + session.Write2Bulk( + write2BulkCommand.ServerHandle, + write2BulkCommand.Entries, + value => variantConverter.ConvertToComValue(value))); + } + + private MxCommandReply ExecuteWriteSecuredBulk(StaCommand command) + { + if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.WriteSecuredBulk) + { + return CreateInvalidRequestReply(command, "WriteSecuredBulk command payload is required."); + } + + WriteSecuredBulkCommand writeSecuredBulkCommand = command.Command.WriteSecuredBulk; + foreach (WriteSecuredBulkEntry entry in writeSecuredBulkCommand.Entries) + { + if (entry.Value is null) + { + return CreateInvalidRequestReply( + command, + $"WriteSecuredBulk entry for item handle {entry.ItemHandle} is missing its value."); + } + } + + return CreateBulkWriteReply( + command, + session.WriteSecuredBulk( + writeSecuredBulkCommand.ServerHandle, + writeSecuredBulkCommand.Entries, + value => variantConverter.ConvertToComValue(value))); + } + + private MxCommandReply ExecuteWriteSecured2Bulk(StaCommand command) + { + if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.WriteSecured2Bulk) + { + return CreateInvalidRequestReply(command, "WriteSecured2Bulk command payload is required."); + } + + WriteSecured2BulkCommand writeSecured2BulkCommand = command.Command.WriteSecured2Bulk; + foreach (WriteSecured2BulkEntry entry in writeSecured2BulkCommand.Entries) + { + if (entry.Value is null) + { + return CreateInvalidRequestReply( + command, + $"WriteSecured2Bulk entry for item handle {entry.ItemHandle} is missing its value."); + } + + if (entry.TimestampValue is null) + { + return CreateInvalidRequestReply( + command, + $"WriteSecured2Bulk entry for item handle {entry.ItemHandle} is missing its timestamp value."); + } + } + + return CreateBulkWriteReply( + command, + session.WriteSecured2Bulk( + writeSecured2BulkCommand.ServerHandle, + writeSecured2BulkCommand.Entries, + value => variantConverter.ConvertToComValue(value))); + } + + private MxCommandReply ExecuteReadBulk(StaCommand command) + { + if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.ReadBulk) + { + return CreateInvalidRequestReply(command, "ReadBulk command payload is required."); + } + + ReadBulkCommand readBulkCommand = command.Command.ReadBulk; + TimeSpan timeout = readBulkCommand.TimeoutMs == 0 + ? DefaultReadBulkTimeout + : TimeSpan.FromMilliseconds(readBulkCommand.TimeoutMs); + + IReadOnlyList results = session.ReadBulk( + readBulkCommand.ServerHandle, + readBulkCommand.TagAddresses, + timeout, + pumpStep); + + MxCommandReply reply = CreateOkReply(command); + BulkReadReply bulkReply = new(); + bulkReply.Results.Add(results); + reply.ReadBulk = bulkReply; + return reply; + } + private MxCommandReply ExecuteSubscribeAlarms(StaCommand command) { if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.SubscribeAlarms) @@ -547,6 +824,35 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor return reply; } + private static MxCommandReply CreateBulkWriteReply( + StaCommand command, + IEnumerable results) + { + MxCommandReply reply = CreateOkReply(command); + BulkWriteReply bulkReply = new(); + bulkReply.Results.Add(results); + + switch (command.Kind) + { + case MxCommandKind.WriteBulk: + reply.WriteBulk = bulkReply; + break; + case MxCommandKind.Write2Bulk: + reply.Write2Bulk = bulkReply; + break; + case MxCommandKind.WriteSecuredBulk: + reply.WriteSecuredBulk = bulkReply; + break; + case MxCommandKind.WriteSecured2Bulk: + reply.WriteSecured2Bulk = bulkReply; + break; + default: + throw new InvalidOperationException($"Unsupported bulk write command kind {command.Kind}."); + } + + return reply; + } + private static MxCommandReply CreateInvalidRequestReply( StaCommand command, string message) diff --git a/src/MxGateway.Worker/MxAccess/MxAccessCreationException.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessCreationException.cs similarity index 98% rename from src/MxGateway.Worker/MxAccess/MxAccessCreationException.cs rename to src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessCreationException.cs index 4cfc635..fd300f2 100644 --- a/src/MxGateway.Worker/MxAccess/MxAccessCreationException.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessCreationException.cs @@ -1,7 +1,7 @@ using System; using System.Runtime.InteropServices; -namespace MxGateway.Worker.MxAccess; +namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; /// Thrown when the worker fails to instantiate the MXAccess COM object. public sealed class MxAccessCreationException : Exception diff --git a/src/MxGateway.Worker/MxAccess/MxAccessEventMapper.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessEventMapper.cs similarity index 81% rename from src/MxGateway.Worker/MxAccess/MxAccessEventMapper.cs rename to src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessEventMapper.cs index d2a94a5..3f3f40b 100644 --- a/src/MxGateway.Worker/MxAccess/MxAccessEventMapper.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessEventMapper.cs @@ -1,8 +1,10 @@ using System; -using MxGateway.Contracts.Proto; -using MxGateway.Worker.Conversion; +using System.Globalization; +using Google.Protobuf.WellKnownTypes; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Worker.Conversion; -namespace MxGateway.Worker.MxAccess; +namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; /// Maps MXAccess COM events to protobuf MxEvent messages. public sealed class MxAccessEventMapper @@ -103,12 +105,11 @@ public sealed class MxAccessEventMapper } /// - /// Creates an OnAlarmTransition event from MXAccess COM alarm-event arguments. - /// PR A.2 — proto-build path is mechanical and unit-testable; the COM-side - /// subscription that calls into this method (registering an - /// IAlarmEventSink against the MXAccess Toolkit's alarm provider) is - /// pinned during dev-rig validation since the exact MXAccess Toolkit version - /// installed on the worker host determines the API shape. + /// Creates an OnAlarmTransition event from MXAccess alarm-event arguments. + /// The worker's alarm path drives this method from + /// once + /// decodes a transition raised by the + /// wnwrap-backed . /// /// Identifier of the session. /// Fully-qualified MxAccess alarm reference (e.g. "Tank01.Level.HiHi"). @@ -266,6 +267,19 @@ public sealed class MxAccessEventMapper return; } + // MXAccess fires OnDataChange with pftItemTimeStamp marshaled as a + // VT_BSTR string (e.g. "3/26/2026 1:38:22.907 PM"), not a FILETIME or + // a VT_DATE — so the variant converter classifies it as a plain + // string and the timestamp would otherwise be dropped. Parse it here + // so the source timestamp still reaches MxEvent. MXAccess formats the + // string in the worker host's local time; see TryParseSourceTimestamp. + if (convertedTimestamp.KindCase == MxValue.KindOneofCase.StringValue + && TryParseSourceTimestamp(convertedTimestamp.StringValue, out DateTime parsedUtc)) + { + mxEvent.SourceTimestamp = Timestamp.FromDateTime(parsedUtc); + return; + } + if (!string.IsNullOrWhiteSpace(convertedTimestamp.RawDiagnostic)) { mxEvent.RawStatus = string.IsNullOrWhiteSpace(mxEvent.RawStatus) @@ -274,6 +288,38 @@ public sealed class MxAccessEventMapper } } + /// + /// Parses an MXAccess OnDataChange timestamp string into a UTC + /// . MXAccess delivers the value as a culture- + /// formatted string rather than a FILETIME or VT_DATE, and formats it + /// in the worker host's local time (verified empirically — a + /// fast-changing tag's timestamp lands the host's UTC offset behind + /// wall-clock UTC). The parsed value is therefore taken as local time + /// and converted to UTC. Tries the worker host's culture first + /// (MXAccess formats with the host locale), then the invariant culture. + /// + /// The MXAccess timestamp string. + /// The parsed UTC timestamp on success. + /// when the string parsed successfully. + internal static bool TryParseSourceTimestamp(string? text, out DateTime utc) + { + utc = default; + if (string.IsNullOrWhiteSpace(text)) + { + return false; + } + + const DateTimeStyles styles = DateTimeStyles.AssumeLocal | DateTimeStyles.AdjustToUniversal; + if (DateTime.TryParse(text, CultureInfo.CurrentCulture, styles, out DateTime parsed) + || DateTime.TryParse(text, CultureInfo.InvariantCulture, styles, out parsed)) + { + utc = DateTime.SpecifyKind(parsed, DateTimeKind.Utc); + return true; + } + + return false; + } + private MxArray ConvertBufferedArray( object? value, MxDataType expectedElementDataType) diff --git a/src/MxGateway.Worker/MxAccess/MxAccessEventQueue.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessEventQueue.cs similarity index 98% rename from src/MxGateway.Worker/MxAccess/MxAccessEventQueue.cs rename to src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessEventQueue.cs index b07ad9f..ae8393b 100644 --- a/src/MxGateway.Worker/MxAccess/MxAccessEventQueue.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessEventQueue.cs @@ -1,9 +1,9 @@ using System; using System.Collections.Generic; using Google.Protobuf.WellKnownTypes; -using MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Contracts.Proto; -namespace MxGateway.Worker.MxAccess; +namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; /// /// Thread-safe queue for MxAccess events with capacity overflow and fault tracking. diff --git a/src/MxGateway.Worker/MxAccess/MxAccessEventQueueOverflowException.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessEventQueueOverflowException.cs similarity index 92% rename from src/MxGateway.Worker/MxAccess/MxAccessEventQueueOverflowException.cs rename to src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessEventQueueOverflowException.cs index 9c48b07..866ce06 100644 --- a/src/MxGateway.Worker/MxAccess/MxAccessEventQueueOverflowException.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessEventQueueOverflowException.cs @@ -1,6 +1,6 @@ using System; -namespace MxGateway.Worker.MxAccess; +namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; public sealed class MxAccessEventQueueOverflowException : Exception { diff --git a/src/MxGateway.Worker/MxAccess/MxAccessHandleRegistry.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessHandleRegistry.cs similarity index 99% rename from src/MxGateway.Worker/MxAccess/MxAccessHandleRegistry.cs rename to src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessHandleRegistry.cs index 88222c9..1e8526f 100644 --- a/src/MxGateway.Worker/MxAccess/MxAccessHandleRegistry.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessHandleRegistry.cs @@ -2,7 +2,7 @@ using System; using System.Collections.Generic; using System.Linq; -namespace MxGateway.Worker.MxAccess; +namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; public sealed class MxAccessHandleRegistry { diff --git a/src/MxGateway.Worker/MxAccess/MxAccessInteropInfo.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessInteropInfo.cs similarity index 97% rename from src/MxGateway.Worker/MxAccess/MxAccessInteropInfo.cs rename to src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessInteropInfo.cs index c71ea8e..1cede3b 100644 --- a/src/MxGateway.Worker/MxAccess/MxAccessInteropInfo.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessInteropInfo.cs @@ -1,7 +1,7 @@ using System; using ArchestrA.MxAccess; -namespace MxGateway.Worker.MxAccess; +namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; /// /// Constants and metadata for MXAccess COM interop. diff --git a/src/MxGateway.Worker/MxAccess/MxAccessSession.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessSession.cs similarity index 51% rename from src/MxGateway.Worker/MxAccess/MxAccessSession.cs rename to src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessSession.cs index 965bdfb..464f795 100644 --- a/src/MxGateway.Worker/MxAccess/MxAccessSession.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessSession.cs @@ -2,9 +2,9 @@ using System; using System.Collections.Generic; using System.Runtime.InteropServices; using Google.Protobuf.WellKnownTypes; -using MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Contracts.Proto; -namespace MxGateway.Worker.MxAccess; +namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; public sealed class MxAccessSession : IDisposable { @@ -12,6 +12,7 @@ public sealed class MxAccessSession : IDisposable private readonly IMxAccessServer mxAccessServer; private readonly IMxAccessEventSink eventSink; private readonly MxAccessHandleRegistry handleRegistry; + private readonly MxAccessValueCache valueCache; private bool disposed; private MxAccessSession( @@ -19,12 +20,14 @@ public sealed class MxAccessSession : IDisposable IMxAccessServer mxAccessServer, IMxAccessEventSink eventSink, MxAccessHandleRegistry handleRegistry, + MxAccessValueCache valueCache, int creationThreadId) { this.mxAccessComObject = mxAccessComObject ?? throw new ArgumentNullException(nameof(mxAccessComObject)); this.mxAccessServer = mxAccessServer ?? throw new ArgumentNullException(nameof(mxAccessServer)); this.eventSink = eventSink ?? throw new ArgumentNullException(nameof(eventSink)); this.handleRegistry = handleRegistry ?? throw new ArgumentNullException(nameof(handleRegistry)); + this.valueCache = valueCache ?? throw new ArgumentNullException(nameof(valueCache)); CreationThreadId = creationThreadId; } @@ -34,6 +37,14 @@ public sealed class MxAccessSession : IDisposable /// The registry for tracking opened handles. public MxAccessHandleRegistry HandleRegistry => handleRegistry; + /// + /// Per-session last-value cache populated by the event sink. ReadBulk + /// consults this cache before falling back to its own snapshot + /// lifecycle so it can serve a "current value" for an already-advised + /// tag without touching the existing subscription. + /// + public MxAccessValueCache ValueCache => valueCache; + /// Creates a WorkerReady message with session metadata. /// Process ID of the worker. public WorkerReady CreateWorkerReady(int workerProcessId) @@ -47,6 +58,54 @@ public sealed class MxAccessSession : IDisposable }; } + /// + /// Test-only seam: constructs a session that bypasses the live COM + /// factory. The caller supplies the and + /// directly so tests can exercise + /// session methods without touching MXAccess COM. This is exposed via + /// InternalsVisibleTo("ZB.MOM.WW.MxGateway.Worker.Tests"); production code + /// must use the factory. + /// + /// A runtime guard rejects an — + /// the production sink wired by — because the + /// new object() stand-in this factory uses for the COM object + /// would silently bypass + /// during disposal and mask lifetime regressions (Worker.Tests-026). + /// + /// The server abstraction to drive. + /// The event sink to attach to the session. + /// Optional handle registry; a fresh one is created when null. + /// Optional value cache; a fresh one is created when null. + /// Optional creation thread id; defaults to the current managed thread id. + /// + /// Thrown when is the production + /// . Tests must pass a test + /// double sink — production code must use . + /// + internal static MxAccessSession CreateForTesting( + IMxAccessServer mxAccessServer, + IMxAccessEventSink eventSink, + MxAccessHandleRegistry? handleRegistry = null, + MxAccessValueCache? valueCache = null, + int? creationThreadId = null) + { + if (eventSink is MxAccessBaseEventSink) + { + throw new ArgumentException( + "CreateForTesting must not be used with the production MxAccessBaseEventSink. " + + "Use MxAccessSession.Create for production code; pass a test-double IMxAccessEventSink here.", + nameof(eventSink)); + } + + return new MxAccessSession( + new object(), + mxAccessServer, + eventSink, + handleRegistry ?? new MxAccessHandleRegistry(), + valueCache ?? new MxAccessValueCache(), + creationThreadId ?? Environment.CurrentManagedThreadId); + } + /// Creates and initializes an MXAccess COM session. /// Factory to create the MXAccess COM object. /// Event sink to attach to the COM object. @@ -78,11 +137,21 @@ public sealed class MxAccessSession : IDisposable eventSink.Attach(mxAccessComObject, sessionId); + // Share the event sink's value cache when one is wired (the + // production MxAccessBaseEventSink path) so OnDataChange writes and + // ReadBulk reads both see the same instance. Fall back to a fresh + // cache for test fakes that supply their own sink — ReadBulk simply + // never serves cached values in that case. + MxAccessValueCache valueCache = eventSink is MxAccessBaseEventSink baseSink + ? baseSink.ValueCache + : new MxAccessValueCache(); + return new MxAccessSession( mxAccessComObject, new MxAccessComServer(mxAccessComObject), eventSink, new MxAccessHandleRegistry(), + valueCache, Environment.CurrentManagedThreadId); } catch (Exception exception) @@ -180,6 +249,10 @@ public sealed class MxAccessSession : IDisposable mxAccessServer.RemoveItem(serverHandle, itemHandle); handleRegistry.RemoveItemHandle(serverHandle, itemHandle); + // Evict the last-value entry so a future AddItem + Advise on the + // same handle id (which MXAccess may reuse) does not serve a stale + // OnDataChange snapshot from the previous lifetime. + valueCache.Remove(serverHandle, itemHandle); } /// Advises on item changes with plain subscription. @@ -227,6 +300,78 @@ public sealed class MxAccessSession : IDisposable MxAccessAdviceKind.Supervisory); } + /// Writes a value to an item. + /// Handle returned by the worker. + /// Handle returned by the worker. + /// COM-marshalable value to write. + /// MXAccess user id (security classification) for the write. + public void Write( + int serverHandle, + int itemHandle, + object? value, + int userId) + { + ThrowIfDisposed(); + + mxAccessServer.Write(serverHandle, itemHandle, value, userId); + } + + /// Writes a value with an explicit source timestamp to an item. + /// Handle returned by the worker. + /// Handle returned by the worker. + /// COM-marshalable value to write. + /// COM-marshalable source timestamp for the write. + /// MXAccess user id (security classification) for the write. + public void Write2( + int serverHandle, + int itemHandle, + object? value, + object? timestamp, + int userId) + { + ThrowIfDisposed(); + + mxAccessServer.Write2(serverHandle, itemHandle, value, timestamp, userId); + } + + /// Performs a secured/verified write to an item. + /// Handle returned by the worker. + /// Handle returned by the worker. + /// MXAccess user id of the operator performing the write. + /// MXAccess user id of the verifier authorizing the write. + /// COM-marshalable value to write. + public void WriteSecured( + int serverHandle, + int itemHandle, + int currentUserId, + int verifierUserId, + object? value) + { + ThrowIfDisposed(); + + mxAccessServer.WriteSecured(serverHandle, itemHandle, currentUserId, verifierUserId, value); + } + + /// Performs a secured/verified write with an explicit source timestamp. + /// Handle returned by the worker. + /// Handle returned by the worker. + /// MXAccess user id of the operator performing the write. + /// MXAccess user id of the verifier authorizing the write. + /// COM-marshalable value to write. + /// COM-marshalable source timestamp for the write. + public void WriteSecured2( + int serverHandle, + int itemHandle, + int currentUserId, + int verifierUserId, + object? value, + object? timestamp) + { + ThrowIfDisposed(); + + mxAccessServer.WriteSecured2(serverHandle, itemHandle, currentUserId, verifierUserId, value, timestamp); + } + /// Adds multiple items in bulk, returning success/failure results. /// Handle returned by the worker. /// Enumerable of item definitions to add. @@ -441,6 +586,394 @@ public sealed class MxAccessSession : IDisposable return results; } + /// + /// Bulk write — runs sequentially for each entry. + /// Each entry's turns the protobuf + /// MxValue into a COM-marshalable variant. Per-item failures are + /// captured as entries with + /// was_successful = false; the loop never throws. + /// + public IReadOnlyList WriteBulk( + int serverHandle, + IReadOnlyList entries, + Func convertValue) + { + ThrowIfDisposed(); + if (entries is null) + { + throw new ArgumentNullException(nameof(entries)); + } + + if (convertValue is null) + { + throw new ArgumentNullException(nameof(convertValue)); + } + + List results = new(entries.Count); + foreach (WriteBulkEntry entry in entries) + { + results.Add(ExecuteBulkWriteEntry( + serverHandle, + entry.ItemHandle, + () => Write(serverHandle, entry.ItemHandle, convertValue(entry.Value), entry.UserId))); + } + + return results; + } + + /// Bulk Write2 — sequential MXAccess per entry. + public IReadOnlyList Write2Bulk( + int serverHandle, + IReadOnlyList entries, + Func convertValue) + { + ThrowIfDisposed(); + if (entries is null) + { + throw new ArgumentNullException(nameof(entries)); + } + + if (convertValue is null) + { + throw new ArgumentNullException(nameof(convertValue)); + } + + List results = new(entries.Count); + foreach (Write2BulkEntry entry in entries) + { + results.Add(ExecuteBulkWriteEntry( + serverHandle, + entry.ItemHandle, + () => Write2( + serverHandle, + entry.ItemHandle, + convertValue(entry.Value), + convertValue(entry.TimestampValue), + entry.UserId))); + } + + return results; + } + + /// Bulk WriteSecured — sequential MXAccess per entry. + public IReadOnlyList WriteSecuredBulk( + int serverHandle, + IReadOnlyList entries, + Func convertValue) + { + ThrowIfDisposed(); + if (entries is null) + { + throw new ArgumentNullException(nameof(entries)); + } + + if (convertValue is null) + { + throw new ArgumentNullException(nameof(convertValue)); + } + + List results = new(entries.Count); + foreach (WriteSecuredBulkEntry entry in entries) + { + results.Add(ExecuteBulkWriteEntry( + serverHandle, + entry.ItemHandle, + () => WriteSecured( + serverHandle, + entry.ItemHandle, + entry.CurrentUserId, + entry.VerifierUserId, + convertValue(entry.Value)))); + } + + return results; + } + + /// Bulk WriteSecured2 — sequential MXAccess per entry. + public IReadOnlyList WriteSecured2Bulk( + int serverHandle, + IReadOnlyList entries, + Func convertValue) + { + ThrowIfDisposed(); + if (entries is null) + { + throw new ArgumentNullException(nameof(entries)); + } + + if (convertValue is null) + { + throw new ArgumentNullException(nameof(convertValue)); + } + + List results = new(entries.Count); + foreach (WriteSecured2BulkEntry entry in entries) + { + results.Add(ExecuteBulkWriteEntry( + serverHandle, + entry.ItemHandle, + () => WriteSecured2( + serverHandle, + entry.ItemHandle, + entry.CurrentUserId, + entry.VerifierUserId, + convertValue(entry.Value), + convertValue(entry.TimestampValue)))); + } + + return results; + } + + /// + /// Bulk read snapshot. For each requested tag, returns the most recent + /// OnDataChange value if the tag is already advised AND a cached value + /// exists (no subscription side effects); otherwise takes the AddItem + /// + Advise + wait + UnAdvise + RemoveItem snapshot lifecycle itself. + /// bounds the wait per-tag in the snapshot + /// case; is invoked on every poll + /// iteration so the worker's STA can dispatch the incoming MXAccess + /// message that carries the value. + /// + public IReadOnlyList ReadBulk( + int serverHandle, + IReadOnlyList tagAddresses, + TimeSpan timeout, + Action pumpStep) + { + ThrowIfDisposed(); + if (tagAddresses is null) + { + throw new ArgumentNullException(nameof(tagAddresses)); + } + + if (pumpStep is null) + { + throw new ArgumentNullException(nameof(pumpStep)); + } + + List results = new(tagAddresses.Count); + foreach (string? tagAddress in tagAddresses) + { + if (string.IsNullOrWhiteSpace(tagAddress)) + { + results.Add(FailedRead(serverHandle, tagAddress ?? string.Empty, itemHandle: 0, wasCached: false, "Tag address is required.")); + continue; + } + + results.Add(ReadOneTag(serverHandle, tagAddress, timeout, pumpStep)); + } + + return results; + } + + private BulkReadResult ReadOneTag( + int serverHandle, + string tagAddress, + TimeSpan timeout, + Action pumpStep) + { + // 1. Cached-and-advised fast path: scan the registry for a live item + // handle matching this tag and check whether the value cache has a + // payload for it. If so, return the cached value without touching + // the existing subscription — the caller didn't create it, so + // ReadBulk must not tear it down. + if (TryGetCachedReadFor(serverHandle, tagAddress, out int cachedItemHandle, out MxAccessValueCache.CachedValue cachedValue)) + { + return SucceededRead( + serverHandle, + tagAddress, + cachedItemHandle, + wasCached: true, + cachedValue); + } + + // 2. Snapshot lifecycle. Reserve our own item handle, advise, pump + // until we see a fresh OnDataChange (or the deadline elapses), + // then tear it down. + int itemHandle = 0; + bool advised = false; + try + { + itemHandle = AddItem(serverHandle, tagAddress); + ulong baseline = valueCache.CurrentVersion(serverHandle, itemHandle); + Advise(serverHandle, itemHandle); + advised = true; + + DateTime deadline = DateTime.UtcNow + timeout; + bool gotValue = valueCache.TryWaitForUpdate( + serverHandle, + itemHandle, + baseline, + deadline, + pumpStep, + out MxAccessValueCache.CachedValue snapshot); + + return gotValue + ? SucceededRead(serverHandle, tagAddress, itemHandle, wasCached: false, snapshot) + : FailedRead( + serverHandle, + tagAddress, + itemHandle, + wasCached: false, + $"ReadBulk timed out after {timeout.TotalMilliseconds:F0} ms waiting for first OnDataChange."); + } + catch (Exception exception) + { + return FailedRead(serverHandle, tagAddress, itemHandle, wasCached: false, exception.Message); + } + finally + { + // Snapshot teardown — best-effort. Errors here are noted on the + // diagnostic message of the original result (above) by appending + // a cleanup suffix; we never re-throw from finally. + if (advised) + { + try { UnAdvise(serverHandle, itemHandle); } catch { /* swallow — best effort */ } + } + + if (itemHandle != 0) + { + try { RemoveItem(serverHandle, itemHandle); } catch { /* swallow — best effort */ } + } + } + } + + private bool TryGetCachedReadFor( + int serverHandle, + string tagAddress, + out int itemHandle, + out MxAccessValueCache.CachedValue cachedValue) + { + // Linear scan — bulk-read sizes are small in practice and the registry + // is keyed by handle, not by tag. If profiling ever shows this hot, a + // reverse tag→handle map can be added on the registry side. + foreach (RegisteredItemHandle registered in handleRegistry.ItemHandles) + { + if (registered.ServerHandle != serverHandle) + { + continue; + } + + if (!string.Equals(registered.ItemDefinition, tagAddress, StringComparison.Ordinal)) + { + continue; + } + + if (!handleRegistry.ContainsAdviceHandle(serverHandle, registered.ItemHandle, MxAccessAdviceKind.Plain) + && !handleRegistry.ContainsAdviceHandle(serverHandle, registered.ItemHandle, MxAccessAdviceKind.Supervisory)) + { + // Tag is added but not advised — no fresh OnDataChange will + // arrive without us advising. Fall through to the snapshot + // path which advises explicitly. + continue; + } + + if (valueCache.TryGet(serverHandle, registered.ItemHandle, out cachedValue)) + { + itemHandle = registered.ItemHandle; + return true; + } + } + + itemHandle = 0; + cachedValue = default; + return false; + } + + private BulkWriteResult ExecuteBulkWriteEntry( + int serverHandle, + int itemHandle, + Action invokeWrite) + { + try + { + invokeWrite(); + return new BulkWriteResult + { + ServerHandle = serverHandle, + ItemHandle = itemHandle, + WasSuccessful = true, + ErrorMessage = string.Empty, + }; + } + catch (System.Runtime.InteropServices.COMException comException) + { + BulkWriteResult result = new() + { + ServerHandle = serverHandle, + ItemHandle = itemHandle, + WasSuccessful = false, + ErrorMessage = comException.Message, + }; + result.Hresult = comException.HResult; + return result; + } + catch (Exception exception) + { + return new BulkWriteResult + { + ServerHandle = serverHandle, + ItemHandle = itemHandle, + WasSuccessful = false, + ErrorMessage = exception.Message, + }; + } + } + + private static BulkReadResult SucceededRead( + int serverHandle, + string tagAddress, + int itemHandle, + bool wasCached, + MxAccessValueCache.CachedValue snapshot) + { + BulkReadResult result = new() + { + ServerHandle = serverHandle, + TagAddress = tagAddress, + ItemHandle = itemHandle, + WasSuccessful = true, + WasCached = wasCached, + Quality = snapshot.Quality, + ErrorMessage = string.Empty, + }; + + if (snapshot.Value is not null) + { + result.Value = snapshot.Value; + } + + if (snapshot.SourceTimestamp is not null) + { + result.SourceTimestamp = snapshot.SourceTimestamp; + } + + if (snapshot.Statuses is not null) + { + result.Statuses.Add(snapshot.Statuses); + } + + return result; + } + + private static BulkReadResult FailedRead( + int serverHandle, + string tagAddress, + int itemHandle, + bool wasCached, + string errorMessage) + { + return new BulkReadResult + { + ServerHandle = serverHandle, + TagAddress = tagAddress, + ItemHandle = itemHandle, + WasSuccessful = false, + WasCached = wasCached, + ErrorMessage = errorMessage, + }; + } + /// Gracefully shuts down the session, cleaning up all handles. public MxAccessShutdownResult ShutdownGracefully() { diff --git a/src/MxGateway.Worker/MxAccess/MxAccessShutdownFailure.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessShutdownFailure.cs similarity index 97% rename from src/MxGateway.Worker/MxAccess/MxAccessShutdownFailure.cs rename to src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessShutdownFailure.cs index a3e377a..453fad2 100644 --- a/src/MxGateway.Worker/MxAccess/MxAccessShutdownFailure.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessShutdownFailure.cs @@ -1,6 +1,6 @@ using System; -namespace MxGateway.Worker.MxAccess; +namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; /// /// Captures details about an MXAccess operation that failed during shutdown. diff --git a/src/MxGateway.Worker/MxAccess/MxAccessShutdownResult.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessShutdownResult.cs similarity index 94% rename from src/MxGateway.Worker/MxAccess/MxAccessShutdownResult.cs rename to src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessShutdownResult.cs index b992a0c..ee31b3d 100644 --- a/src/MxGateway.Worker/MxAccess/MxAccessShutdownResult.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessShutdownResult.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; -namespace MxGateway.Worker.MxAccess; +namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; public sealed class MxAccessShutdownResult { diff --git a/src/MxGateway.Worker/MxAccess/MxAccessStaSession.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessStaSession.cs similarity index 54% rename from src/MxGateway.Worker/MxAccess/MxAccessStaSession.cs rename to src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessStaSession.cs index 4b223a9..22f4984 100644 --- a/src/MxGateway.Worker/MxAccess/MxAccessStaSession.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessStaSession.cs @@ -3,22 +3,33 @@ using System.Collections.Generic; using System.Diagnostics; using System.Threading; using System.Threading.Tasks; -using MxGateway.Contracts.Proto; -using MxGateway.Worker.Conversion; -using MxGateway.Worker.Sta; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Worker.Conversion; +using ZB.MOM.WW.MxGateway.Worker.Sta; -namespace MxGateway.Worker.MxAccess; +namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; public sealed class MxAccessStaSession : IWorkerRuntimeSession { + private static readonly TimeSpan AlarmPollInterval = TimeSpan.FromMilliseconds(500); + private readonly IMxAccessComObjectFactory factory; private readonly IMxAccessEventSink eventSink; private readonly MxAccessEventQueue eventQueue; private readonly StaRuntime staRuntime; - private readonly Func? alarmCommandHandlerFactory; + // Worker-024: the factory takes an Action so MxAccessStaSession can hand + // the alarm handler its STA-affinity guard (a closure over + // alarmConsumerThreadId captured at the factory call site). The handler + // then invokes the guard at the entry of every method that touches the + // wnwrap consumer, matching the STA-affinity invariant already enforced + // for the poll path via EnsureOnAlarmConsumerThread. + private readonly Func? alarmCommandHandlerFactory; private StaCommandDispatcher? commandDispatcher; private MxAccessSession? session; private IAlarmCommandHandler? alarmCommandHandler; + private CancellationTokenSource? alarmPollCts; + private Task? alarmPollTask; + private int? alarmConsumerThreadId; private bool disposed; /// @@ -32,6 +43,22 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession { } + /// + /// Initializes a new instance of with default STA runtime, + /// factory, and event queue, but with a custom alarm-command handler factory. The factory is + /// invoked on the STA thread during + /// ; pass null to opt out + /// of alarm-side commands. + /// + internal MxAccessStaSession(Func? alarmCommandHandlerFactory) + : this( + new StaRuntime(), + new MxAccessComObjectFactory(), + new MxAccessEventQueue(), + alarmCommandHandlerFactory) + { + } + /// /// Initializes a new instance of with custom STA runtime and factory. /// @@ -60,6 +87,26 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession { } + /// + /// Initializes a new instance of with custom event queue + /// and an alarm-command handler factory. + /// + /// STA thread runtime. + /// MXAccess COM object factory. + /// Event queue for buffering MXAccess events. + /// + /// Factory that constructs the alarm-command handler from the event queue. + /// Pass null to opt out of alarm-side commands. + /// + public MxAccessStaSession( + StaRuntime staRuntime, + IMxAccessComObjectFactory factory, + MxAccessEventQueue eventQueue, + Func? alarmCommandHandlerFactory) + : this(staRuntime, factory, new MxAccessBaseEventSink(eventQueue), eventQueue, alarmCommandHandlerFactory) + { + } + /// /// Initializes a new instance of with all dependencies. /// @@ -88,7 +135,7 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession IMxAccessComObjectFactory factory, IMxAccessEventSink eventSink, MxAccessEventQueue eventQueue, - Func? alarmCommandHandlerFactory) + Func? alarmCommandHandlerFactory) { this.staRuntime = staRuntime ?? throw new ArgumentNullException(nameof(staRuntime)); this.factory = factory ?? throw new ArgumentNullException(nameof(factory)); @@ -122,14 +169,14 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession /// Worker process identifier. /// Cancellation token. /// Worker ready message. - public Task StartAsync( + public async Task StartAsync( string sessionId, int workerProcessId, CancellationToken cancellationToken = default) { staRuntime.Start(); - return staRuntime.InvokeAsync( + WorkerReady ready = await staRuntime.InvokeAsync( () => { if (session is not null) @@ -140,18 +187,179 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession session = MxAccessSession.Create(factory, eventSink, sessionId); if (alarmCommandHandlerFactory is not null) { - alarmCommandHandler = alarmCommandHandlerFactory(eventQueue); + // STA-affinity invariant: the alarm consumer factory and + // every IMxAccessAlarmConsumer call must run on the STA + // thread, because the production wnwrap consumer holds an + // Apartment-threaded COM object. The factory runs here + // inside staRuntime.InvokeAsync, so this records the STA + // thread id; RunAlarmPollLoopAsync then asserts each + // PollOnce executes on the same thread. + alarmConsumerThreadId = Environment.CurrentManagedThreadId; + // Worker-024: hand the handler an affinity guard so each + // of its command-path entries (Subscribe / Acknowledge / + // AcknowledgeByName / QueryActive / Unsubscribe / PollOnce) + // asserts the same STA-affinity invariant the poll path + // already enforced. Without this the command path relied + // on convention alone; a future refactor that let a + // command run off-STA would silently deadlock on + // cross-apartment marshaling against the wnwrap consumer. + alarmCommandHandler = alarmCommandHandlerFactory( + eventQueue, + EnsureOnAlarmConsumerThread); } commandDispatcher = new StaCommandDispatcher( staRuntime, new MxAccessCommandExecutor( session, new VariantConverter(), - alarmCommandHandler)); + alarmCommandHandler, + // ReadBulk needs to pump Windows messages while it waits + // for the first OnDataChange callback so the inbound COM + // event can dispatch on this same STA thread. The pump + // step closes over staRuntime so it always pumps the + // pump tied to the apartment that owns this session. + pumpStep: () => staRuntime.PumpPendingMessages())); return session.CreateWorkerReady(workerProcessId); }, - cancellationToken); + cancellationToken).ConfigureAwait(false); + + if (alarmCommandHandler is not null) + { + alarmPollCts = new CancellationTokenSource(); + alarmPollTask = RunAlarmPollLoopAsync(alarmCommandHandler, alarmPollCts.Token); + } + + return ready; + } + + private Task RunAlarmPollLoopAsync( + IAlarmCommandHandler handler, + CancellationToken cancellationToken) + { + return Task.Run(async () => + { + while (!cancellationToken.IsCancellationRequested) + { + try + { + await Task.Delay(AlarmPollInterval, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + return; + } + + if (cancellationToken.IsCancellationRequested) + { + return; + } + + try + { + await staRuntime.InvokeAsync( + () => + { + EnsureOnAlarmConsumerThread(); + handler.PollOnce(); + }, + cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + return; + } + catch (ObjectDisposedException) + { + // STA runtime or alarm handler disposed — stop the loop gracefully. + return; + } + catch (StaRuntimeShutdownException) + { + // STA runtime shutting down — stop the loop gracefully. + // The dedicated shutdown type lets us distinguish this + // graceful-stop signal from the STA-affinity assertion + // raised by EnsureOnAlarmConsumerThread (Worker-008), + // which is also an InvalidOperationException but signals + // a programming-error regression — that case falls through + // to the generic Exception arm below and is recorded as a + // fault on the event queue, so an affinity regression + // becomes observable on the IPC fault path instead of + // silently stopping alarm delivery. + return; + } + catch (Exception exception) + { + // A real alarm-poll failure (COMException from + // GetXmlCurrentAlarms2, malformed-XML parse failure, an + // STA-affinity InvalidOperationException from + // EnsureOnAlarmConsumerThread, etc.). Record it as a + // fault on the event queue so a broken alarm subscription + // — or an affinity-invariant regression — becomes + // observable on the IPC fault path instead of silently + // faulting this never-awaited task. The loop then stops — + // the subscription is dead. + eventQueue.RecordFault(CreateAlarmPollFault(exception)); + return; + } + } + }, CancellationToken.None); + } + + private void EnsureOnAlarmConsumerThread() + { + AssertOnAlarmConsumerThread(alarmConsumerThreadId, Environment.CurrentManagedThreadId); + } + + /// + /// Enforces the STA-affinity invariant for the alarm consumer: every + /// call (and the consumer factory) + /// must run on the same thread the consumer was created on (the worker's + /// STA). Throws when a caller + /// breaks affinity — a programming error that would otherwise risk a + /// cross-apartment COM deadlock in the production wnwrap consumer, since + /// its CLSID is registered ThreadingModel=Apartment. The check is + /// a no-op until the consumer thread has been recorded (no alarm handler + /// configured, or session not yet started). + /// + /// + /// The managed thread id the alarm consumer was created on, or + /// null if no alarm consumer is configured. + /// + /// The current managed thread id. + internal static void AssertOnAlarmConsumerThread(int? expectedThreadId, int actualThreadId) + { + if (expectedThreadId is not null && actualThreadId != expectedThreadId.Value) + { + throw new InvalidOperationException( + $"Alarm consumer accessed off its owning STA thread. Expected thread {expectedThreadId.Value}, " + + $"actual {actualThreadId}. All IMxAccessAlarmConsumer calls must run on the STA that " + + "created the consumer."); + } + } + + private static WorkerFault CreateAlarmPollFault(Exception exception) + { + string message = + $"MXAccess alarm poll failed: {exception.Message}"; + WorkerFault fault = new() + { + Category = WorkerFaultCategory.MxaccessEventConversionFailed, + ExceptionType = exception.GetType().FullName ?? string.Empty, + DiagnosticMessage = message, + ProtocolStatus = new ProtocolStatus + { + Code = ProtocolStatusCode.WorkerUnavailable, + Message = message, + }, + }; + + if (exception is System.Runtime.InteropServices.COMException comException) + { + fault.Hresult = comException.HResult; + } + + return fault; } /// @@ -307,6 +515,30 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession commandDispatcher?.RequestShutdown(); + // Cancel the STA poll loop before disposing the alarm handler. + // The loop references the alarm handler and must be stopped first + // so that no further PollOnce calls race with disposal. + CancellationTokenSource? pollCtsToDispose = alarmPollCts; + Task? pollTaskToAwait = alarmPollTask; + alarmPollCts = null; + alarmPollTask = null; + if (pollCtsToDispose is not null) + { + pollCtsToDispose.Cancel(); + if (pollTaskToAwait is not null) + { + try + { + await pollTaskToAwait.ConfigureAwait(false); + } + catch + { + // Swallow — poll loop cancellation must not block data shutdown. + } + } + pollCtsToDispose.Dispose(); + } + // Stop the alarm consumer's polling timer and tear down the // dispatcher BEFORE the data-side cleanup begins. The alarm // consumer holds a wnwrap COM RCW that needs the STA pump to @@ -382,6 +614,30 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession RequestShutdown(); + // Cancel the STA poll loop and join it before disposing the alarm + // handler. Joining (rather than discarding alarmPollTask) makes the + // stop deterministic: once Dispose returns, no further PollOnce calls + // can be in flight, so callers and tests can rely on a frozen poll + // count instead of an elapsed-time "no further polls" window. + CancellationTokenSource? pollCtsToDispose = alarmPollCts; + Task? pollTaskToJoin = alarmPollTask; + alarmPollCts = null; + alarmPollTask = null; + if (pollCtsToDispose is not null) + { + try { pollCtsToDispose.Cancel(); } catch { } + if (pollTaskToJoin is not null) + { + try + { + pollTaskToJoin.Wait(TimeSpan.FromSeconds(5)); + } + catch (AggregateException) { } + catch (ObjectDisposedException) { } + } + try { pollCtsToDispose.Dispose(); } catch { } + } + IAlarmCommandHandler? alarmHandlerToDispose = alarmCommandHandler; alarmCommandHandler = null; if (alarmHandlerToDispose is not null) diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessValueCache.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessValueCache.cs new file mode 100644 index 0000000..e763f9d --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessValueCache.cs @@ -0,0 +1,190 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using Google.Protobuf.Collections; +using Google.Protobuf.WellKnownTypes; +using ZB.MOM.WW.MxGateway.Contracts.Proto; + +namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; + +/// +/// Per-session cache of the most recent OnDataChange payload for +/// each (server handle, item handle) pair. Written by the MXAccess event +/// sink as new OnDataChange callbacks arrive; read by the ReadBulk command +/// executor so it can satisfy a "current value" request from a tag that is +/// already advised without modifying the existing subscription. +/// +/// +/// Both writers and readers run on the worker's STA thread (COM dispatches +/// events on the apartment thread; commands also execute on the STA), so +/// no internal locking is required. The class is still nominally +/// thread-safe via a single sync root in case tests drive it from a +/// non-STA thread. +/// +public sealed class MxAccessValueCache +{ + private readonly Dictionary entries = new(); + private readonly object syncRoot = new(); + + /// Records a fresh OnDataChange payload for the given handle pair. + /// MXAccess server handle. + /// MXAccess item handle. + /// The protobuf MxEvent created by the event mapper. + public void Set( + int serverHandle, + int itemHandle, + MxEvent mxEvent) + { + if (mxEvent is null) + { + throw new ArgumentNullException(nameof(mxEvent)); + } + + long key = CreateItemKey(serverHandle, itemHandle); + lock (syncRoot) + { + ulong nextVersion = entries.TryGetValue(key, out CachedValue existing) + ? existing.Version + 1 + : 1UL; + + entries[key] = new CachedValue( + nextVersion, + mxEvent.Value, + mxEvent.Quality, + mxEvent.SourceTimestamp, + mxEvent.Statuses); + } + } + + /// Tries to read the most recent cached value for the handle pair. + public bool TryGet( + int serverHandle, + int itemHandle, + out CachedValue value) + { + long key = CreateItemKey(serverHandle, itemHandle); + lock (syncRoot) + { + return entries.TryGetValue(key, out value); + } + } + + /// + /// Removes the cache slot for a handle pair. The session calls this + /// when an item is unregistered so stale values are not served to a + /// subsequent ReadBulk after a tag is removed and re-added. + /// + public void Remove( + int serverHandle, + int itemHandle) + { + long key = CreateItemKey(serverHandle, itemHandle); + lock (syncRoot) + { + entries.Remove(key); + } + } + + /// + /// Waits until the cache entry's version exceeds + /// or the deadline elapses, calling on every poll + /// iteration so the worker's STA can dispatch the inbound MXAccess message. + /// + /// MXAccess server handle. + /// MXAccess item handle. + /// Version snapshot captured before the wait. + /// Absolute UTC deadline. + /// Action that pumps any pending Windows messages. + /// How long to sleep between pump cycles. Default 5 ms. + public bool TryWaitForUpdate( + int serverHandle, + int itemHandle, + ulong sinceVersion, + DateTime deadlineUtc, + Action pumpStep, + out CachedValue value, + int pollIntervalMs = 5) + { + if (pumpStep is null) + { + throw new ArgumentNullException(nameof(pumpStep)); + } + + while (true) + { + pumpStep(); + + if (TryGet(serverHandle, itemHandle, out value) && value.Version > sinceVersion) + { + return true; + } + + if (DateTime.UtcNow >= deadlineUtc) + { + return false; + } + + Thread.Sleep(pollIntervalMs); + } + } + + /// Returns the current version for a handle pair, or 0 if no entry exists. + public ulong CurrentVersion( + int serverHandle, + int itemHandle) + { + return TryGet(serverHandle, itemHandle, out CachedValue existing) + ? existing.Version + : 0UL; + } + + private static long CreateItemKey( + int serverHandle, + int itemHandle) + { + return ((long)serverHandle << 32) | (uint)itemHandle; + } + + /// + /// Snapshot of the most recent OnDataChange payload for a handle pair. + /// increments by one on every + /// call so the bulk read executor can detect "a new value arrived + /// since I started waiting". + /// + /// + /// Plain readonly struct (not a record) so this compiles under the + /// worker's net48 target, which lacks IsExternalInit. + /// + public readonly struct CachedValue + { + /// Initializes a new cached value snapshot. + public CachedValue( + ulong version, + MxValue value, + int quality, + Timestamp sourceTimestamp, + RepeatedField statuses) + { + Version = version; + Value = value; + Quality = quality; + SourceTimestamp = sourceTimestamp; + Statuses = statuses; + } + + /// Monotonic per-handle version counter. + public ulong Version { get; } + + /// The cached MxValue payload. + public MxValue Value { get; } + + /// Quality code from the OnDataChange event. + public int Quality { get; } + + /// Source timestamp from the OnDataChange event. + public Timestamp SourceTimestamp { get; } + + /// MxStatusProxy entries from the OnDataChange event. + public RepeatedField Statuses { get; } + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAlarmSnapshot.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAlarmSnapshot.cs new file mode 100644 index 0000000..401bdeb --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAlarmSnapshot.cs @@ -0,0 +1,26 @@ +using System; + +namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; + +/// +/// Single alarm record as emitted by the wnwrapConsumer XML stream. +/// Field names match the captured XML schema (see +/// docs/AlarmClientDiscovery.md "Option A — captured" section). +/// +public sealed class MxAlarmSnapshotRecord +{ + public Guid AlarmGuid { get; set; } + public DateTime TransitionTimestampUtc { get; set; } + public string ProviderNode { get; set; } = string.Empty; + public string ProviderName { get; set; } = string.Empty; + public string Group { get; set; } = string.Empty; + public string TagName { get; set; } = string.Empty; + public string Type { get; set; } = string.Empty; + public string Value { get; set; } = string.Empty; + public string Limit { get; set; } = string.Empty; + public int Priority { get; set; } + public MxAlarmStateKind State { get; set; } + public string OperatorNode { get; set; } = string.Empty; + public string OperatorName { get; set; } = string.Empty; + public string AlarmComment { get; set; } = string.Empty; +} diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAlarmStateKind.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAlarmStateKind.cs new file mode 100644 index 0000000..512ad92 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAlarmStateKind.cs @@ -0,0 +1,17 @@ +namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; + +/// +/// Library-agnostic alarm-state enum. Mirrors the four STATE +/// values returned by AVEVA's WNWRAPCONSUMERLib XML payload — +/// UNACK_ALM, ACK_ALM, UNACK_RTN, ACK_RTN. +/// Decoupling the consumer from any specific COM library keeps the +/// proto-build path testable without an AVEVA install. +/// +public enum MxAlarmStateKind +{ + Unspecified = 0, + UnackAlm = 1, + AckAlm = 2, + UnackRtn = 3, + AckRtn = 4, +} diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAlarmTransitionEvent.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAlarmTransitionEvent.cs new file mode 100644 index 0000000..d903e99 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAlarmTransitionEvent.cs @@ -0,0 +1,20 @@ +using System; + +namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; + +/// +/// One transition emitted by the consumer's snapshot diff. Pairs the +/// latest record with its previous state so the proto layer can decide +/// whether the transition is a Raise / Acknowledge / Clear. +/// +public sealed class MxAlarmTransitionEvent : EventArgs +{ + public MxAlarmSnapshotRecord Record { get; set; } = new MxAlarmSnapshotRecord(); + + /// + /// The state on the consumer's previous polled snapshot, or + /// when this is the + /// first time the GUID has been observed. + /// + public MxAlarmStateKind PreviousState { get; set; } +} diff --git a/src/MxGateway.Worker/MxAccess/RegisteredAdviceHandle.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/RegisteredAdviceHandle.cs similarity index 94% rename from src/MxGateway.Worker/MxAccess/RegisteredAdviceHandle.cs rename to src/ZB.MOM.WW.MxGateway.Worker/MxAccess/RegisteredAdviceHandle.cs index f07301f..b0da333 100644 --- a/src/MxGateway.Worker/MxAccess/RegisteredAdviceHandle.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/RegisteredAdviceHandle.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Worker.MxAccess; +namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; public sealed class RegisteredAdviceHandle { diff --git a/src/MxGateway.Worker/MxAccess/RegisteredItemHandle.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/RegisteredItemHandle.cs similarity index 97% rename from src/MxGateway.Worker/MxAccess/RegisteredItemHandle.cs rename to src/ZB.MOM.WW.MxGateway.Worker/MxAccess/RegisteredItemHandle.cs index e62d496..6833426 100644 --- a/src/MxGateway.Worker/MxAccess/RegisteredItemHandle.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/RegisteredItemHandle.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Worker.MxAccess; +namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; /// /// Metadata for an item handle registered in an MXAccess session. diff --git a/src/MxGateway.Worker/MxAccess/RegisteredServerHandle.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/RegisteredServerHandle.cs similarity index 93% rename from src/MxGateway.Worker/MxAccess/RegisteredServerHandle.cs rename to src/ZB.MOM.WW.MxGateway.Worker/MxAccess/RegisteredServerHandle.cs index 59011ce..4b77c6d 100644 --- a/src/MxGateway.Worker/MxAccess/RegisteredServerHandle.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/RegisteredServerHandle.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Worker.MxAccess; +namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; public sealed class RegisteredServerHandle { diff --git a/src/MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs similarity index 76% rename from src/MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs rename to src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs index 06dc39a..bdcbf79 100644 --- a/src/MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs @@ -2,11 +2,10 @@ using System; using System.Collections.Generic; using System.Globalization; using System.Runtime.InteropServices; -using System.Threading; using System.Xml; using WNWRAPCONSUMERLib; -namespace MxGateway.Worker.MxAccess; +namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; /// /// Production backed by AVEVA's @@ -31,60 +30,55 @@ namespace MxGateway.Worker.MxAccess; /// Threading. The wnwrap CLSID is registered with /// ThreadingModel=Apartment. The consumer must be created /// and operated from an STA thread; the worker's -/// already runs an STA pump that -/// is the natural host. Polling cadence is governed by -/// on a dedicated timer the -/// consumer owns; in production the worker's STA dispatcher should -/// marshal each callback onto the STA thread before invoking -/// GetXmlCurrentAlarms2. For now (test-grade), this consumer -/// calls the COM API on whichever thread the timer fires it on — -/// the worker bootstrap will gain a thin "run-on-STA" wrapper as -/// part of A.3 dispatcher wiring. +/// runs an STA pump that hosts it. +/// The consumer owns no internal timer: every COM call +/// (Subscribe, PollOnce, AcknowledgeBy*) must +/// be invoked on the STA that created the consumer. Polling cadence +/// is driven externally by the worker's STA via +/// StaRuntime.InvokeAsync(() => consumer.PollOnce()), which +/// keeps every GetXmlCurrentAlarms2 call on the apartment that +/// owns the COM object. A thread-pool timer would call the COM API +/// off the owning STA and can deadlock on cross-apartment marshaling +/// when the STA is not pumping messages, so no such timer exists. /// /// public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer { private const string DefaultProductName = "OtOpcUa.MxGateway"; - private const string DefaultApplicationName = "OtOpcUa.MxGateway.Worker"; + private const string DefaultApplicationName = "OtOpcUa.ZB.MOM.WW.MxGateway.Worker"; private const string DefaultVersion = "1.0"; - private const int DefaultPollIntervalMilliseconds = 500; private const int DefaultMaxAlarmsPerFetch = 1024; private readonly object syncRoot = new object(); private readonly Dictionary latestSnapshot = new Dictionary(); - private readonly int pollIntervalMs; private readonly int maxAlarmsPerFetch; private wwAlarmConsumerClass? client; private wwAlarmConsumerClass? ackClient; - private string subscriptionExpression = string.Empty; - private Timer? pollTimer; private bool subscribed; private bool disposed; + /// + /// Production constructor — creates the wnwrap COM object on the + /// current thread (which must be the worker's STA). Polling is driven + /// externally by the STA via + /// StaRuntime.InvokeAsync(() => consumer.PollOnce()) so that + /// every COM call stays on the STA that owns the apartment. + /// public WnWrapAlarmConsumer() - : this(new wwAlarmConsumerClass(), DefaultPollIntervalMilliseconds, DefaultMaxAlarmsPerFetch) + : this(new wwAlarmConsumerClass(), DefaultMaxAlarmsPerFetch) { } /// - /// Test seam / explicit construction — inject a pre-created COM - /// client and tune the poll cadence. pollIntervalMilliseconds == 0 - /// disables the internal entirely; the caller - /// must drive manually (used by hosts that - /// marshal polls onto a foreign STA, and by live smoke tests that - /// pump from the STA they own). + /// Test seam / explicit construction. /// public WnWrapAlarmConsumer( wwAlarmConsumerClass client, - int pollIntervalMilliseconds, int maxAlarmsPerFetch) { this.client = client ?? throw new ArgumentNullException(nameof(client)); - this.pollIntervalMs = pollIntervalMilliseconds < 0 - ? DefaultPollIntervalMilliseconds - : pollIntervalMilliseconds; this.maxAlarmsPerFetch = maxAlarmsPerFetch > 0 ? maxAlarmsPerFetch : DefaultMaxAlarmsPerFetch; @@ -93,8 +87,6 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer /// public event EventHandler? AlarmTransitionEmitted; - public int PollIntervalMilliseconds => pollIntervalMs; - /// public void Subscribe(string subscription) { @@ -128,7 +120,9 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer } // hWnd=0: wnwrap supports a pull-based model — no message pump - // is required. We poll GetXmlCurrentAlarms2 on a timer below. + // is required. GetXmlCurrentAlarms2 is polled by the worker's STA + // via StaRuntime.InvokeAsync(() => consumer.PollOnce()); this type + // owns no internal timer. int reg = com.IwwAlarmConsumer_RegisterConsumer( hWnd: 0, szProductName: DefaultProductName, @@ -162,8 +156,28 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer // also breaks AlarmAckByName on the same consumer (rejects with // -55), so a separate ack-only consumer is provisioned below // that gets only Initialize/Register/Subscribe (no SetXmlAlarmQuery). + // + // The wnwrap interop signature is `void SetXmlAlarmQuery(string)` + // — there is no integer return code to gate on like the other v1 + // lifecycle calls in this method. A genuine failure surfaces as a + // COM exception (mapped from the underlying HRESULT). Wrap the + // call so a failure becomes an InvalidOperationException with + // diagnostic context, matching the other call-gates' failure + // shape rather than letting an opaque COMException escape with + // no indication that the alarm subscription is now misconfigured + // and the next GetXmlCurrentAlarms2 poll will fail with E_FAIL. string xmlQuery = ComposeXmlAlarmQuery(subscription); - com.SetXmlAlarmQuery(xmlQuery); + try + { + com.SetXmlAlarmQuery(xmlQuery); + } + catch (COMException ex) + { + throw new InvalidOperationException( + $"wwAlarmConsumer.SetXmlAlarmQuery failed with HRESULT 0x{ex.HResult:X8}; " + + "subsequent GetXmlCurrentAlarms2 polls would return E_FAIL.", + ex); + } // Provision a parallel COM consumer for ack calls. It runs the // v1 lifecycle (Initialize/Register/Subscribe) only; without @@ -190,13 +204,8 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer $"Ack consumer setup returned non-zero status: " + $"Initialize={ackInit}, Register={ackReg}, Subscribe={ackSub}."); } - subscriptionExpression = subscription; subscribed = true; - if (pollIntervalMs > 0) - { - pollTimer = new Timer(OnPoll, state: null, dueTime: 0, period: pollIntervalMs); - } } } @@ -286,31 +295,14 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer } } - private void OnPoll(object? _) - { - if (disposed) return; - try - { - PollOnce(); - } - catch (Exception ex) - { - // Swallow — the poll loop must not propagate exceptions out of - // the timer callback, or the worker process tears down. The - // EventQueue fault counter (wired in by the future A.3 dispatcher) - // is the right place to surface poll failures; for now the - // exception is intentionally silent so the timer keeps firing. - _ = ex; - } - } - /// /// Synchronously poll the wnwrap consumer once and dispatch any - /// transitions. Public so STA-bound hosts can drive polling from - /// the thread that owns the COM object instead of relying on the - /// internal (which fires on a thread-pool - /// thread and blocks indefinitely on cross-apartment marshaling - /// when the host STA isn't pumping messages). + /// transitions. STA-bound hosts drive polling by calling this from + /// the thread that owns the COM object. The consumer deliberately + /// owns no internal timer: a thread-pool timer would call the + /// apartment-threaded COM object off its owning STA and can block + /// indefinitely on cross-apartment marshaling when the STA is not + /// pumping messages. /// public void PollOnce() { @@ -329,23 +321,10 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer Dictionary next = ParseSnapshotXml(xml); - List transitions = new List(); + IReadOnlyList transitions; lock (syncRoot) { - foreach (KeyValuePair kv in next) - { - MxAlarmStateKind previousState = MxAlarmStateKind.Unspecified; - if (latestSnapshot.TryGetValue(kv.Key, out MxAlarmSnapshotRecord? prev)) - { - previousState = prev.State; - if (previousState == kv.Value.State) continue; // no transition - } - transitions.Add(new MxAlarmTransitionEvent - { - Record = kv.Value, - PreviousState = previousState, - }); - } + transitions = ComputeTransitions(latestSnapshot, next); latestSnapshot.Clear(); foreach (KeyValuePair kv in next) { @@ -362,6 +341,52 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer } } + /// + /// Pure snapshot-to-transitions diff. Compares the previous polled + /// snapshot to the next snapshot and produces one + /// per state change. Used by + /// after a successful + /// GetXmlCurrentAlarms2 call; exposed as internal static + /// so the diff rules can be unit-tested without driving the + /// wnwrapConsumer COM object (Worker.Tests-022). + /// + /// + /// Rules: + /// + /// A GUID present in but not in produces a transition with as the previous state — first sighting. + /// A GUID present in both with the same produces no transition. + /// A GUID present in both with a different produces a transition carrying the prior state. + /// A GUID present in but absent from produces no transition. AVEVA drops cleared alarms from the active set; the snapshot simply stops mentioning them. + /// + /// + /// The snapshot from the previous poll (or empty on first call). + /// The snapshot just parsed from GetXmlCurrentAlarms2. + /// One transition per state change in . + internal static IReadOnlyList ComputeTransitions( + Dictionary previous, + Dictionary next) + { + if (previous is null) throw new ArgumentNullException(nameof(previous)); + if (next is null) throw new ArgumentNullException(nameof(next)); + + List transitions = new List(); + foreach (KeyValuePair kv in next) + { + MxAlarmStateKind previousState = MxAlarmStateKind.Unspecified; + if (previous.TryGetValue(kv.Key, out MxAlarmSnapshotRecord? prev)) + { + previousState = prev.State; + if (previousState == kv.Value.State) continue; // no transition + } + transitions.Add(new MxAlarmTransitionEvent + { + Record = kv.Value, + PreviousState = previousState, + }); + } + return transitions; + } + /// /// Parse the XML payload returned by GetXmlCurrentAlarms2 /// into a GUID-keyed dictionary. Records with malformed GUIDs are @@ -516,21 +541,17 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer /// public void Dispose() { - Timer? timerToDispose; wwAlarmConsumerClass? clientToDispose; wwAlarmConsumerClass? ackClientToDispose; lock (syncRoot) { if (disposed) return; disposed = true; - timerToDispose = pollTimer; - pollTimer = null; clientToDispose = client; client = null; ackClientToDispose = ackClient; ackClient = null; } - timerToDispose?.Dispose(); ReleaseConsumerCom(clientToDispose); ReleaseConsumerCom(ackClientToDispose); } diff --git a/src/MxGateway.Worker/MxAccess/WorkerRuntimeHeartbeatSnapshot.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WorkerRuntimeHeartbeatSnapshot.cs similarity index 97% rename from src/MxGateway.Worker/MxAccess/WorkerRuntimeHeartbeatSnapshot.cs rename to src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WorkerRuntimeHeartbeatSnapshot.cs index 3fe7011..0852166 100644 --- a/src/MxGateway.Worker/MxAccess/WorkerRuntimeHeartbeatSnapshot.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WorkerRuntimeHeartbeatSnapshot.cs @@ -1,6 +1,6 @@ using System; -namespace MxGateway.Worker.MxAccess; +namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; public sealed class WorkerRuntimeHeartbeatSnapshot { diff --git a/src/MxGateway.Worker/Program.cs b/src/ZB.MOM.WW.MxGateway.Worker/Program.cs similarity index 52% rename from src/MxGateway.Worker/Program.cs rename to src/ZB.MOM.WW.MxGateway.Worker/Program.cs index 8057af5..dac6084 100644 --- a/src/MxGateway.Worker/Program.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/Program.cs @@ -1,3 +1,3 @@ -using MxGateway.Worker; +using ZB.MOM.WW.MxGateway.Worker; return WorkerApplication.Run(args); diff --git a/src/MxGateway.Worker/Sta/.gitkeep b/src/ZB.MOM.WW.MxGateway.Worker/Sta/.gitkeep similarity index 100% rename from src/MxGateway.Worker/Sta/.gitkeep rename to src/ZB.MOM.WW.MxGateway.Worker/Sta/.gitkeep diff --git a/src/MxGateway.Worker/Sta/IStaComApartmentInitializer.cs b/src/ZB.MOM.WW.MxGateway.Worker/Sta/IStaComApartmentInitializer.cs similarity index 90% rename from src/MxGateway.Worker/Sta/IStaComApartmentInitializer.cs rename to src/ZB.MOM.WW.MxGateway.Worker/Sta/IStaComApartmentInitializer.cs index 38f25a5..1751c57 100644 --- a/src/MxGateway.Worker/Sta/IStaComApartmentInitializer.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/Sta/IStaComApartmentInitializer.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Worker.Sta; +namespace ZB.MOM.WW.MxGateway.Worker.Sta; /// /// Initializes and uninitializes the COM apartment for the STA thread. diff --git a/src/MxGateway.Worker/Sta/IStaCommandExecutor.cs b/src/ZB.MOM.WW.MxGateway.Worker/Sta/IStaCommandExecutor.cs similarity index 75% rename from src/MxGateway.Worker/Sta/IStaCommandExecutor.cs rename to src/ZB.MOM.WW.MxGateway.Worker/Sta/IStaCommandExecutor.cs index 5c649e5..728c4d0 100644 --- a/src/MxGateway.Worker/Sta/IStaCommandExecutor.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/Sta/IStaCommandExecutor.cs @@ -1,6 +1,6 @@ -using MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Contracts.Proto; -namespace MxGateway.Worker.Sta; +namespace ZB.MOM.WW.MxGateway.Worker.Sta; public interface IStaCommandExecutor { diff --git a/src/MxGateway.Worker/Sta/IStaWorkItem.cs b/src/ZB.MOM.WW.MxGateway.Worker/Sta/IStaWorkItem.cs similarity index 85% rename from src/MxGateway.Worker/Sta/IStaWorkItem.cs rename to src/ZB.MOM.WW.MxGateway.Worker/Sta/IStaWorkItem.cs index ae1d735..5880c36 100644 --- a/src/MxGateway.Worker/Sta/IStaWorkItem.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/Sta/IStaWorkItem.cs @@ -1,4 +1,4 @@ -namespace MxGateway.Worker.Sta; +namespace ZB.MOM.WW.MxGateway.Worker.Sta; internal interface IStaWorkItem { diff --git a/src/MxGateway.Worker/Sta/StaComApartmentInitializer.cs b/src/ZB.MOM.WW.MxGateway.Worker/Sta/StaComApartmentInitializer.cs similarity index 95% rename from src/MxGateway.Worker/Sta/StaComApartmentInitializer.cs rename to src/ZB.MOM.WW.MxGateway.Worker/Sta/StaComApartmentInitializer.cs index c605bce..ca332db 100644 --- a/src/MxGateway.Worker/Sta/StaComApartmentInitializer.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/Sta/StaComApartmentInitializer.cs @@ -1,7 +1,7 @@ using System; using System.Runtime.InteropServices; -namespace MxGateway.Worker.Sta; +namespace ZB.MOM.WW.MxGateway.Worker.Sta; public sealed class StaComApartmentInitializer : IStaComApartmentInitializer { diff --git a/src/MxGateway.Worker/Sta/StaCommand.cs b/src/ZB.MOM.WW.MxGateway.Worker/Sta/StaCommand.cs similarity index 96% rename from src/MxGateway.Worker/Sta/StaCommand.cs rename to src/ZB.MOM.WW.MxGateway.Worker/Sta/StaCommand.cs index 3ae977b..efdd486 100644 --- a/src/MxGateway.Worker/Sta/StaCommand.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/Sta/StaCommand.cs @@ -1,9 +1,9 @@ using System; using System.Threading; using Google.Protobuf.WellKnownTypes; -using MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Contracts.Proto; -namespace MxGateway.Worker.Sta; +namespace ZB.MOM.WW.MxGateway.Worker.Sta; public sealed class StaCommand { diff --git a/src/MxGateway.Worker/Sta/StaCommandDispatcher.cs b/src/ZB.MOM.WW.MxGateway.Worker/Sta/StaCommandDispatcher.cs similarity index 98% rename from src/MxGateway.Worker/Sta/StaCommandDispatcher.cs rename to src/ZB.MOM.WW.MxGateway.Worker/Sta/StaCommandDispatcher.cs index 1a822cb..834c49d 100644 --- a/src/MxGateway.Worker/Sta/StaCommandDispatcher.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/Sta/StaCommandDispatcher.cs @@ -1,10 +1,10 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; -using MxGateway.Contracts.Proto; -using MxGateway.Worker.Conversion; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Worker.Conversion; -namespace MxGateway.Worker.Sta; +namespace ZB.MOM.WW.MxGateway.Worker.Sta; public sealed class StaCommandDispatcher { diff --git a/src/MxGateway.Worker/Sta/StaMessagePump.cs b/src/ZB.MOM.WW.MxGateway.Worker/Sta/StaMessagePump.cs similarity index 98% rename from src/MxGateway.Worker/Sta/StaMessagePump.cs rename to src/ZB.MOM.WW.MxGateway.Worker/Sta/StaMessagePump.cs index a06cd6c..3284aa3 100644 --- a/src/MxGateway.Worker/Sta/StaMessagePump.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/Sta/StaMessagePump.cs @@ -3,7 +3,7 @@ using System.Runtime.InteropServices; using System.Threading; using Microsoft.Win32.SafeHandles; -namespace MxGateway.Worker.Sta; +namespace ZB.MOM.WW.MxGateway.Worker.Sta; /// Pumps Windows messages on the STA thread to allow MXAccess COM events to deliver. public sealed class StaMessagePump diff --git a/src/MxGateway.Worker/Sta/StaRuntime.cs b/src/ZB.MOM.WW.MxGateway.Worker/Sta/StaRuntime.cs similarity index 92% rename from src/MxGateway.Worker/Sta/StaRuntime.cs rename to src/ZB.MOM.WW.MxGateway.Worker/Sta/StaRuntime.cs index 07a11ee..3f90b7f 100644 --- a/src/MxGateway.Worker/Sta/StaRuntime.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/Sta/StaRuntime.cs @@ -3,7 +3,7 @@ using System.Collections.Concurrent; using System.Threading; using System.Threading.Tasks; -namespace MxGateway.Worker.Sta; +namespace ZB.MOM.WW.MxGateway.Worker.Sta; public sealed class StaRuntime : IDisposable { @@ -79,6 +79,15 @@ public sealed class StaRuntime : IDisposable /// public bool IsRunning => startedEvent.IsSet && !stoppedEvent.IsSet; + /// + /// Pumps any pending Windows messages on the calling thread. Intended + /// for commands that synchronously hold the STA (e.g. ReadBulk) and + /// must allow inbound MXAccess COM events to dispatch while they + /// wait. Callers must already be on the STA; the method is otherwise + /// safe (PeekMessage simply finds no messages). + /// + public int PumpPendingMessages() => messagePump.PumpPendingMessages(); + /// /// Starts the STA thread. /// @@ -90,7 +99,7 @@ public sealed class StaRuntime : IDisposable { if (shutdownRequested) { - throw new InvalidOperationException("The worker STA runtime is shutting down."); + throw new StaRuntimeShutdownException(); } if (!startRequested) @@ -158,8 +167,7 @@ public sealed class StaRuntime : IDisposable { if (shutdownRequested) { - return Task.FromException( - new InvalidOperationException("The worker STA runtime is shutting down.")); + return Task.FromException(new StaRuntimeShutdownException()); } commandQueue.Enqueue(workItem); diff --git a/src/ZB.MOM.WW.MxGateway.Worker/Sta/StaRuntimeShutdownException.cs b/src/ZB.MOM.WW.MxGateway.Worker/Sta/StaRuntimeShutdownException.cs new file mode 100644 index 0000000..62dc229 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Worker/Sta/StaRuntimeShutdownException.cs @@ -0,0 +1,35 @@ +using System; + +namespace ZB.MOM.WW.MxGateway.Worker.Sta; + +/// +/// Thrown by when an operation is rejected because +/// the runtime is shutting down (or has already shut down). The dedicated +/// type lets callers distinguish a graceful shutdown signal — which should +/// stop their work loops without recording a fault — from a genuine +/// programming-error such as the +/// STA-affinity assertion in MxAccessStaSession.AssertOnAlarmConsumerThread. +/// It inherits from so existing +/// callers that catch the latter remain source-compatible. +/// +public sealed class StaRuntimeShutdownException : InvalidOperationException +{ + /// + /// Initializes a new instance of + /// with a default message. + /// + public StaRuntimeShutdownException() + : base("The worker STA runtime is shutting down.") + { + } + + /// + /// Initializes a new instance of + /// with the specified message. + /// + /// Diagnostic message. + public StaRuntimeShutdownException(string message) + : base(message) + { + } +} diff --git a/src/MxGateway.Worker/Sta/StaWorkItem.cs b/src/ZB.MOM.WW.MxGateway.Worker/Sta/StaWorkItem.cs similarity index 98% rename from src/MxGateway.Worker/Sta/StaWorkItem.cs rename to src/ZB.MOM.WW.MxGateway.Worker/Sta/StaWorkItem.cs index 08d2ec2..e7d7504 100644 --- a/src/MxGateway.Worker/Sta/StaWorkItem.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/Sta/StaWorkItem.cs @@ -2,7 +2,7 @@ using System; using System.Threading; using System.Threading.Tasks; -namespace MxGateway.Worker.Sta; +namespace ZB.MOM.WW.MxGateway.Worker.Sta; /// /// Encapsulates a work item to be executed on an STA thread with cancellation support. diff --git a/src/MxGateway.Worker/WorkerApplication.cs b/src/ZB.MOM.WW.MxGateway.Worker/WorkerApplication.cs similarity index 97% rename from src/MxGateway.Worker/WorkerApplication.cs rename to src/ZB.MOM.WW.MxGateway.Worker/WorkerApplication.cs index fec84c8..d253d86 100644 --- a/src/MxGateway.Worker/WorkerApplication.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/WorkerApplication.cs @@ -1,10 +1,10 @@ using System; using System.Collections.Generic; using System.IO; -using MxGateway.Worker.Bootstrap; -using MxGateway.Worker.Ipc; +using ZB.MOM.WW.MxGateway.Worker.Bootstrap; +using ZB.MOM.WW.MxGateway.Worker.Ipc; -namespace MxGateway.Worker; +namespace ZB.MOM.WW.MxGateway.Worker; /// Entry point for the worker process. public static class WorkerApplication diff --git a/src/MxGateway.Worker/MxGateway.Worker.csproj b/src/ZB.MOM.WW.MxGateway.Worker/ZB.MOM.WW.MxGateway.Worker.csproj similarity index 84% rename from src/MxGateway.Worker/MxGateway.Worker.csproj rename to src/ZB.MOM.WW.MxGateway.Worker/ZB.MOM.WW.MxGateway.Worker.csproj index 6850b7f..976528f 100644 --- a/src/MxGateway.Worker/MxGateway.Worker.csproj +++ b/src/ZB.MOM.WW.MxGateway.Worker/ZB.MOM.WW.MxGateway.Worker.csproj @@ -15,7 +15,11 @@ - + + + + + diff --git a/src/ZB.MOM.WW.MxGateway.slnx b/src/ZB.MOM.WW.MxGateway.slnx new file mode 100644 index 0000000..c14b7d5 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.slnx @@ -0,0 +1,13 @@ + + + + + + + + + + + + +