Initial project state: .NET reference, design, Rust port (M0+M1), evidence
rust / build / test / clippy / fmt (push) Has been cancelled
rust / build / test / clippy / fmt (push) Has been cancelled
Layout:
- src/ .NET 10 x64 reference: MxNativeCodec, MxNativeClient,
MxAsbClient, probes, tests, harnesses. Executable spec.
- design/ Architectural plan for the Rust port (M0–M6), error
model, protocol invariants, risks (R1–R16), adversarial
review log (review.md).
- rust/ Rust workspace. M0 skeleton + M1 codec parity.
mxaccess-codec: 215 unit tests + 2 cross-implementation
parity tests (byte-identical against .NET reference).
Other crates are M0 stubs awaiting M2+.
- captures/ Frida + netsh + pcap evidence per CLAUDE.md
("captures are evidence, not throwaway logs").
- analysis/ Decompiled C# (frida/proxy/decompiled-*),
Ghidra exports for native DLLs (`exports/` only —
working state at `projects/` and AVEVA's input
binaries at `input/` are gitignored).
- docs/ Reverse-engineering reference docs.
- tools/ Setup-LiveProbeEnv.ps1 (Infisical credential fetcher),
Compute-Crc.ps1 (.NET parity helper).
- .github/workflows/ Rust CI: fmt + build + test + clippy on Windows.
- LICENSE MIT (Joseph Doherty, 2026).
Verified:
- cargo test --workspace → 217 passed (215 unit + 2 .NET parity), 0 failed
- cargo clippy --workspace -- -D warnings → clean
- cargo fmt --all -- --check → clean
- cargo publish --dry-run -p mxaccess-codec → packages cleanly
Excluded from history (see .gitignore):
- **/bin, **/obj, **/target — build artifacts
- analysis/ghidra/projects/ — Ghidra working state (regenerable)
- analysis/ghidra/input/ — AVEVA proprietary DLLs (vendor IP)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net481</TargetFramework>
|
||||
<PlatformTarget>x64</PlatformTarget>
|
||||
<Prefer32Bit>false</Prefer32Bit>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>disable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="ASBIDataV2Adapter">
|
||||
<HintPath>C:\Windows\Microsoft.NET\assembly\GAC_MSIL\ASBIDataV2Adapter\v4.0_1.0.0.0__23106a86e706d0ae\ASBIDataV2Adapter.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="aaServicesCommon">
|
||||
<HintPath>C:\Program Files (x86)\ArchestrA\Framework\Bin\ViewAppFramework\Services\Authenticator\aaServicesCommon.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="aaServicesCommonDataContracts">
|
||||
<HintPath>C:\Program Files (x86)\ArchestrA\Framework\Bin\ViewAppFramework\Services\Authenticator\aaServicesCommonDataContracts.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="aaServicesContractIAuthenticateASB">
|
||||
<HintPath>C:\Program Files (x86)\ArchestrA\Framework\Bin\ViewAppFramework\Services\Authenticator\aaServicesContractIAuthenticateASB.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="aaServicesContractIData">
|
||||
<HintPath>C:\Program Files (x86)\ArchestrA\Framework\Bin\ViewAppFramework\Services\Authenticator\aaServicesContractIData.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="aaServicesContractIDataV2">
|
||||
<HintPath>C:\Program Files (x86)\ArchestrA\Framework\Bin\ViewAppFramework\Services\Authenticator\aaServicesContractIDataV2.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="aaServicesProxyIData">
|
||||
<HintPath>C:\Program Files (x86)\ArchestrA\Framework\Bin\ViewAppFramework\Services\Authenticator\aaServicesProxyIData.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="aaServicesProxyIDataV2">
|
||||
<HintPath>C:\Program Files (x86)\ArchestrA\Framework\Bin\ViewAppFramework\Services\Authenticator\aaServicesProxyIDataV2.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="System.ServiceModel" />
|
||||
<Reference Include="System.ServiceModel.Discovery" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,305 @@
|
||||
using System;
|
||||
using System.Text;
|
||||
using System.Linq;
|
||||
using ArchestrAServices.Contract;
|
||||
using ArchestrAServices.Proxy;
|
||||
using ArchestrAServices.ASBIDataV2Contract;
|
||||
using V2ItemIdentity = ArchestrAServices.ASBIDataV2Contract.ItemIdentity;
|
||||
using V2ItemRegistration = ArchestrAServices.ASBIDataV2Contract.ItemRegistration;
|
||||
using V2ItemStatus = ArchestrAServices.ASBIDataV2Contract.ItemStatus;
|
||||
using V2RuntimeValue = ArchestrAServices.ASBIDataV2Contract.RuntimeValue;
|
||||
using V2Variant = ArchestrAServices.ASBIDataContract.V2.Variant;
|
||||
using V2VariantFactory = ArchestrAServices.ASBIDataContract.V2.VariantFactory;
|
||||
using V2WriteValue = ArchestrAServices.ASBIDataV2Contract.WriteValue;
|
||||
|
||||
namespace AsbProxyProbe;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
private static int Main(string[] args)
|
||||
{
|
||||
string[] accessNames = GetStrings(args, "--access");
|
||||
if (accessNames.Length == 0)
|
||||
{
|
||||
accessNames =
|
||||
[
|
||||
"ZB",
|
||||
"ZB2",
|
||||
"Default_ZB_MxDataProvider",
|
||||
"Default_ZB2_MxDataProvider",
|
||||
"Galaxy",
|
||||
"localhost",
|
||||
];
|
||||
}
|
||||
|
||||
bool connect = HasFlag(args, "--connect");
|
||||
bool register = HasFlag(args, "--register");
|
||||
bool unregister = HasFlag(args, "--unregister");
|
||||
bool read = HasFlag(args, "--read");
|
||||
int? writeInt = GetInt(args, "--write-int");
|
||||
string tag = GetString(args, "--tag") ?? "TestChildObject.TestInt";
|
||||
Console.WriteLine($"process=x64:{Environment.Is64BitProcess}");
|
||||
Console.WriteLine($"tag={tag}");
|
||||
if (HasFlag(args, "--dump-register-payload"))
|
||||
{
|
||||
DumpRegisterPayload(tag);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (writeInt.HasValue)
|
||||
{
|
||||
Console.WriteLine($"write_int={writeInt.Value}");
|
||||
}
|
||||
|
||||
foreach (string accessName in accessNames)
|
||||
{
|
||||
ProbeAccessName(accessName, connect, register, unregister, read, tag, writeInt);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static void ProbeAccessName(string accessName, bool connect, bool register, bool unregister, bool read, string tag, int? writeInt)
|
||||
{
|
||||
Console.WriteLine($"access={accessName}");
|
||||
|
||||
try
|
||||
{
|
||||
var response = ASBDataV2Proxy.FindIDataEndpoint(accessName, DiscoveryScope.Global);
|
||||
int count = response?.Endpoints?.Count ?? 0;
|
||||
Console.WriteLine($"idata_v2_endpoints={count}");
|
||||
if (response?.Endpoints is not null)
|
||||
{
|
||||
for (int i = 0; i < response.Endpoints.Count; i++)
|
||||
{
|
||||
var endpoint = response.Endpoints[i];
|
||||
Console.WriteLine($"idata_v2_endpoint[{i}].address={endpoint.Address?.Uri}");
|
||||
Console.WriteLine($"idata_v2_endpoint[{i}].listen={string.Join(",", endpoint.ListenUris.Select(uri => uri.ToString()))}");
|
||||
Console.WriteLine($"idata_v2_endpoint[{i}].contracts={string.Join(",", endpoint.ContractTypeNames.Select(name => name.ToString()))}");
|
||||
Console.WriteLine($"idata_v2_endpoint[{i}].scopes={string.Join(",", endpoint.Scopes.Select(uri => uri.ToString()))}");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"idata_v2_discovery_error={ex.GetType().FullName}: {ex.Message}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
object? proxy = IDataProxySelector.SelectProxyForLatestEndpoint(accessName, new AsbMxDataSettings(), out string selectorError);
|
||||
Console.WriteLine($"selector_proxy={proxy?.GetType().FullName ?? "<null>"}");
|
||||
Console.WriteLine($"selector_error={selectorError.Replace(Environment.NewLine, " ")}");
|
||||
|
||||
if (connect && proxy is ASBDataV2Proxy v2)
|
||||
{
|
||||
bool connected = v2.Connect(out string connectError);
|
||||
Console.WriteLine($"connect_v2={connected}");
|
||||
Console.WriteLine($"connect_v2_error={connectError.Replace(Environment.NewLine, " ")}");
|
||||
Console.WriteLine($"connect_v2_state={v2.ChannelState}");
|
||||
if (connected)
|
||||
{
|
||||
var result = v2.PublishWriteComplete(out var completeWrites);
|
||||
Console.WriteLine($"publish_write_complete_error=0x{result.ErrorCode:X8}");
|
||||
Console.WriteLine($"publish_write_complete_count={completeWrites?.Length ?? 0}");
|
||||
V2ItemIdentity? registeredItem = null;
|
||||
if (register)
|
||||
{
|
||||
registeredItem = ProbeRegister(v2, tag);
|
||||
}
|
||||
|
||||
if (read)
|
||||
{
|
||||
ProbeRead(v2, tag);
|
||||
}
|
||||
|
||||
if (writeInt.HasValue)
|
||||
{
|
||||
ProbeWriteInt(v2, tag, writeInt.Value);
|
||||
ProbeRead(v2, tag);
|
||||
result = v2.PublishWriteComplete(out completeWrites);
|
||||
Console.WriteLine($"publish_write_complete_after_write_error=0x{result.ErrorCode:X8}");
|
||||
Console.WriteLine($"publish_write_complete_after_write_count={completeWrites?.Length ?? 0}");
|
||||
if (completeWrites is not null)
|
||||
{
|
||||
for (int i = 0; i < completeWrites.Length; i++)
|
||||
{
|
||||
Console.WriteLine($"publish_write_complete_after_write[{i}]=handle:{completeWrites[i].WriteHandle} handle_specified:{completeWrites[i].WriteHandleSpecified} status_items:{completeWrites[i].Status?.Length ?? 0}");
|
||||
PrintStatuses($"publish_write_complete_after_write[{i}].status", completeWrites[i].Status);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (unregister)
|
||||
{
|
||||
ProbeUnregister(v2, tag, registeredItem);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (connect && proxy is ASBDataProxy v1)
|
||||
{
|
||||
bool connected = v1.Connect(out string connectError);
|
||||
Console.WriteLine($"connect_v1={connected}");
|
||||
Console.WriteLine($"connect_v1_error={connectError.Replace(Environment.NewLine, " ")}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"selector_error_exception={ex.GetType().FullName}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static V2ItemIdentity? ProbeRegister(ASBDataV2Proxy proxy, string tag)
|
||||
{
|
||||
V2ItemIdentity[] items = [CreateAbsoluteItem(tag)];
|
||||
var result = proxy.RegisterItems(out V2ItemStatus[] statuses, out V2ItemRegistration[] capabilities, RequireId: true, RegisterOnly: true, items);
|
||||
Console.WriteLine($"register_error=0x{result.ErrorCode:X8} status=0x{result.Status:X8} specific=0x{result.SpecificErrorCode:X8}");
|
||||
PrintStatuses("register_status", statuses);
|
||||
if (capabilities is not null)
|
||||
{
|
||||
for (int i = 0; i < capabilities.Length; i++)
|
||||
{
|
||||
Console.WriteLine($"register_capability[{i}]=id:{capabilities[i].Id} id_specified:{capabilities[i].IdSpecified} write_capability:{capabilities[i].WriteCapability} write_specified:{capabilities[i].WriteCapabilitySpecified}");
|
||||
}
|
||||
}
|
||||
|
||||
return statuses is { Length: > 0 } ? statuses[0].Item : null;
|
||||
}
|
||||
|
||||
private static void ProbeUnregister(ASBDataV2Proxy proxy, string tag, V2ItemIdentity? registeredItem)
|
||||
{
|
||||
V2ItemIdentity[] items = [registeredItem ?? CreateAbsoluteItem(tag)];
|
||||
var result = proxy.UnregisterItems(out V2ItemStatus[] statuses, items);
|
||||
Console.WriteLine($"unregister_error=0x{result.ErrorCode:X8} status=0x{result.Status:X8} specific=0x{result.SpecificErrorCode:X8}");
|
||||
PrintStatuses("unregister_status", statuses);
|
||||
}
|
||||
|
||||
private static void ProbeRead(ASBDataV2Proxy proxy, string tag)
|
||||
{
|
||||
V2ItemIdentity[] items = [CreateAbsoluteItem(tag)];
|
||||
var result = proxy.Read(out V2ItemStatus[] statuses, out V2RuntimeValue[] values, items);
|
||||
Console.WriteLine($"read_error=0x{result.ErrorCode:X8} status=0x{result.Status:X8} specific=0x{result.SpecificErrorCode:X8}");
|
||||
PrintStatuses("read_status", statuses);
|
||||
if (values is not null)
|
||||
{
|
||||
for (int i = 0; i < values.Length; i++)
|
||||
{
|
||||
V2RuntimeValue value = values[i];
|
||||
PrintVariant($"read_value[{i}]", value.Value);
|
||||
Console.WriteLine($"read_value[{i}].timestamp={value.Timestamp:o} timestamp_specified={value.TimestampSpecified}");
|
||||
Console.WriteLine($"read_value[{i}].status_count={value.Status.Count} status_payload_len={value.Status.Payload?.Length ?? 0}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ProbeWriteInt(ASBDataV2Proxy proxy, string tag, int value)
|
||||
{
|
||||
V2ItemIdentity[] items = [CreateAbsoluteItem(tag)];
|
||||
V2WriteValue[] values =
|
||||
[
|
||||
new V2WriteValue
|
||||
{
|
||||
Value = V2VariantFactory.MakeVariantValue(value),
|
||||
Comment = "AsbProxyProbe write-int",
|
||||
},
|
||||
];
|
||||
|
||||
const uint writeHandle = 0xA5B20001;
|
||||
var result = proxy.Write(out V2ItemStatus[] statuses, items, values, writeHandle);
|
||||
Console.WriteLine($"write_error=0x{result.ErrorCode:X8} status=0x{result.Status:X8} specific=0x{result.SpecificErrorCode:X8} handle=0x{writeHandle:X8}");
|
||||
PrintStatuses("write_status", statuses);
|
||||
}
|
||||
|
||||
private static V2ItemIdentity CreateAbsoluteItem(string tag)
|
||||
{
|
||||
return new V2ItemIdentity
|
||||
{
|
||||
Type = (ushort)ArchestrAServices.ASBIDataV2Contract.ItemIdentityType.Name,
|
||||
ReferenceType = (ushort)ArchestrAServices.ASBIDataV2Contract.ItemReferenceType.Absolute,
|
||||
Name = tag,
|
||||
ContextName = string.Empty,
|
||||
};
|
||||
}
|
||||
|
||||
private static void DumpRegisterPayload(string tag)
|
||||
{
|
||||
V2ItemIdentity[] items = [CreateAbsoluteItem(tag)];
|
||||
using var stream = new System.IO.MemoryStream();
|
||||
var writer = new System.IO.BinaryWriter(stream);
|
||||
items[0].WriteArrayToStream(items, ref writer);
|
||||
byte[] payload = stream.ToArray();
|
||||
Console.WriteLine($"register_payload_len={payload.Length}");
|
||||
Console.WriteLine($"register_payload_b64={Convert.ToBase64String(payload)}");
|
||||
}
|
||||
|
||||
private static void PrintStatuses(string prefix, V2ItemStatus[]? statuses)
|
||||
{
|
||||
if (statuses is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < statuses.Length; i++)
|
||||
{
|
||||
V2ItemStatus status = statuses[i];
|
||||
Console.WriteLine($"{prefix}[{i}]=item:{status.Item.Name} id:{status.Item.Id} id_specified:{status.Item.IdSpecified} error:0x{status.ErrorCode:X8} error_specified:{status.ErrorCodeSpecified} status_count:{status.Status.Count} status_payload_len:{status.Status.Payload?.Length ?? 0}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void PrintVariant(string prefix, V2Variant variant)
|
||||
{
|
||||
string preview = string.Empty;
|
||||
if (variant.Payload is { Length: > 0 })
|
||||
{
|
||||
preview = variant.Type switch
|
||||
{
|
||||
0 when variant.Payload.Length >= 1 => variant.Payload[0].ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
1 when variant.Payload.Length >= 1 => ((char)variant.Payload[0]).ToString(),
|
||||
2 when variant.Payload.Length >= 2 => BitConverter.ToInt16(variant.Payload, 0).ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
3 when variant.Payload.Length >= 2 => BitConverter.ToUInt16(variant.Payload, 0).ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
4 when variant.Payload.Length >= 4 => BitConverter.ToInt32(variant.Payload, 0).ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
5 when variant.Payload.Length >= 4 => BitConverter.ToUInt32(variant.Payload, 0).ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
6 when variant.Payload.Length >= 8 => BitConverter.ToInt64(variant.Payload, 0).ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
7 when variant.Payload.Length >= 8 => BitConverter.ToUInt64(variant.Payload, 0).ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
8 when variant.Payload.Length >= 4 => BitConverter.ToSingle(variant.Payload, 0).ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
9 when variant.Payload.Length >= 8 => BitConverter.ToDouble(variant.Payload, 0).ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
10 => Encoding.Unicode.GetString(variant.Payload),
|
||||
11 when variant.Payload.Length >= 8 => DateTime.FromFileTimeUtc(BitConverter.ToInt64(variant.Payload, 0)).ToString("o"),
|
||||
17 when variant.Payload.Length >= 1 => BitConverter.ToBoolean(variant.Payload, 0).ToString(),
|
||||
18 when variant.Payload.Length >= 1 => ((sbyte)variant.Payload[0]).ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
_ => BitConverter.ToString(variant.Payload).Replace("-", string.Empty),
|
||||
};
|
||||
}
|
||||
|
||||
Console.WriteLine($"{prefix}=type:{variant.Type} length:{variant.Length} payload_len:{variant.Payload?.Length ?? 0} preview:{preview}");
|
||||
}
|
||||
|
||||
private static string[] GetStrings(string[] args, string name)
|
||||
{
|
||||
string prefix = name + "=";
|
||||
return args
|
||||
.Where(arg => arg.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
.Select(arg => arg.Substring(prefix.Length))
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static string? GetString(string[] args, string name)
|
||||
{
|
||||
string prefix = name + "=";
|
||||
return args.FirstOrDefault(arg => arg.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))?.Substring(prefix.Length);
|
||||
}
|
||||
|
||||
private static int? GetInt(string[] args, string name)
|
||||
{
|
||||
string? value = GetString(args, name);
|
||||
return int.TryParse(value, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out int result)
|
||||
? result
|
||||
: null;
|
||||
}
|
||||
|
||||
private static bool HasFlag(string[] args, string name)
|
||||
{
|
||||
return args.Any(arg => arg.Equals(name, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MxAsbClient\MxAsbClient.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<PlatformTarget>x64</PlatformTarget>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,830 @@
|
||||
using MxAsbClient;
|
||||
using System.Globalization;
|
||||
|
||||
string endpoint = GetArg(args, "--endpoint")
|
||||
?? "net.tcp://desktop-6jl3kko/ASBService/Default_ZB_MxDataProvider/IDataV2";
|
||||
string[] tags = GetArgs(args, "--tag");
|
||||
if (tags.Length == 0)
|
||||
{
|
||||
tags = ["TestChildObject.TestInt"];
|
||||
}
|
||||
|
||||
string tag = tags[0];
|
||||
string? solution = GetArg(args, "--solution");
|
||||
bool dumpMessages = HasArg(args, "--dump-messages");
|
||||
bool subscribe = HasArg(args, "--subscribe");
|
||||
bool subscribeBuffered = HasArg(args, "--subscribe-buffered");
|
||||
bool compatibilitySubscribe = HasArg(args, "--compat-subscribe");
|
||||
bool probeConnectFailure = HasArg(args, "--probe-connect-failure");
|
||||
bool probeReconnect = HasArg(args, "--probe-reconnect");
|
||||
bool probeCanceledCleanup = HasArg(args, "--probe-canceled-cleanup");
|
||||
bool probeOperationCompleteCandidates = HasArg(args, "--probe-operation-complete-candidates");
|
||||
int publishCount = TryGetInt(args, "--publish-count") ?? 3;
|
||||
int subscribeSampleMs = TryGetInt(args, "--subscribe-sample-ms") ?? 1000;
|
||||
int publishDelayMs = TryGetInt(args, "--publish-delay-ms") ?? 500;
|
||||
int reconnectAttempts = TryGetInt(args, "--reconnect-attempts") ?? 2;
|
||||
int reconnectDelayMs = TryGetInt(args, "--reconnect-delay-ms") ?? 250;
|
||||
int cleanupDisconnectTimeoutMs = TryGetInt(args, "--cleanup-disconnect-timeout-ms") ?? 30000;
|
||||
int cleanupCloseTimeoutMs = TryGetInt(args, "--cleanup-close-timeout-ms") ?? 30000;
|
||||
bool waitWriteComplete = HasArg(args, "--wait-write-complete");
|
||||
int writeCompleteTimeoutMs = TryGetInt(args, "--write-complete-timeout-ms") ?? 5000;
|
||||
int writeCompletePollMs = TryGetInt(args, "--write-complete-poll-ms") ?? 250;
|
||||
int writeReadbackDelayMs = TryGetInt(args, "--write-readback-delay-ms") ?? 0;
|
||||
Variant? writeVariant = GetWriteVariant(args);
|
||||
bool probeErrorCases = HasArg(args, "--probe-error-cases");
|
||||
bool probeInvalidTargets = probeErrorCases || HasArg(args, "--probe-invalid-targets");
|
||||
bool probeWrongTypeWrite = probeErrorCases || HasArg(args, "--probe-wrong-type-write");
|
||||
bool probeInvalidCleanup = probeErrorCases || HasArg(args, "--probe-invalid-cleanup");
|
||||
bool probeEmptyPublish = probeErrorCases || HasArg(args, "--probe-empty-publish");
|
||||
string invalidTag = GetArg(args, "--invalid-tag") ?? "DefinitelyMissingObject.DefinitelyMissingAttribute";
|
||||
string wrongTypeWriteTag = GetArg(args, "--wrong-type-write-tag") ?? tag;
|
||||
int errorPublishCount = TryGetInt(args, "--error-publish-count") ?? 2;
|
||||
int errorWriteCompleteTimeoutMs = TryGetInt(args, "--error-write-complete-timeout-ms") ?? 2000;
|
||||
int errorWriteCompletePollMs = TryGetInt(args, "--error-write-complete-poll-ms") ?? 250;
|
||||
|
||||
Console.WriteLine($"process=x64:{Environment.Is64BitProcess}");
|
||||
Console.WriteLine($"endpoint={endpoint}");
|
||||
Console.WriteLine($"tags={string.Join(",", tags)}");
|
||||
if (args.Any(arg => arg.Equals("--dump-register-payload", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
byte[] payload = AsbPayloadDebug.SerializeItemsForDebug(tag);
|
||||
Console.WriteLine($"register_payload_len={payload.Length}");
|
||||
Console.WriteLine($"register_payload_b64={Convert.ToBase64String(payload)}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (probeConnectFailure)
|
||||
{
|
||||
try
|
||||
{
|
||||
using MxAsbDataClient connectFailureClient = MxAsbDataClient.Connect(endpoint, solution, Console.WriteLine, dumpMessages);
|
||||
Console.WriteLine("connect_failure_observed=False");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("connect_failure_observed=True");
|
||||
Console.WriteLine($"connect_failure_exception={FormatException(ex)}");
|
||||
if (ex.InnerException is not null)
|
||||
{
|
||||
Console.WriteLine($"connect_failure_inner_exception={FormatException(ex.InnerException)}");
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (compatibilitySubscribe)
|
||||
{
|
||||
RunCompatibilitySubscribe(endpoint, solution, tags, dumpMessages, publishCount, subscribeSampleMs, publishDelayMs);
|
||||
return;
|
||||
}
|
||||
|
||||
using MxAsbDataClient client = MxAsbDataClient.Connect(endpoint, solution, Console.WriteLine, dumpMessages);
|
||||
int publishedEventCount = 0;
|
||||
client.PublishedValueReceived += (_, value) =>
|
||||
{
|
||||
publishedEventCount++;
|
||||
Console.WriteLine($"published_event[{publishedEventCount - 1}]=item:{value.ItemName ?? string.Empty} id:{value.ItemId} type:{value.VariantType} quality:{FormatNullableHex(value.Quality)} timestamp:{value.TimestampUtc:O} preview:{value.Preview}");
|
||||
};
|
||||
Console.WriteLine("connect=True");
|
||||
|
||||
RegisterItemsResponse register = client.RegisterMany(tags);
|
||||
Console.WriteLine($"register_error=0x{register.Result.ErrorCode:X8} status=0x{register.Result.Status:X8} specific=0x{register.Result.SpecificErrorCode:X8}");
|
||||
PrintStatuses("register_status", register.Status);
|
||||
ItemIdentity[] registeredItems = register.Status?.Select(status => status.Item).ToArray() ?? [];
|
||||
IReadOnlyDictionary<ulong, string> itemNamesById = AsbPublishMapper.CreateItemNameMap(register.Status);
|
||||
|
||||
ReadResponse read = client.ReadMany(tags);
|
||||
Console.WriteLine($"read_error=0x{read.Result.ErrorCode:X8} status=0x{read.Result.Status:X8} specific=0x{read.Result.SpecificErrorCode:X8}");
|
||||
PrintStatuses("read_status", read.Status);
|
||||
PrintValues("read_value", read.Values);
|
||||
|
||||
if (probeReconnect)
|
||||
{
|
||||
AsbReconnectResult reconnect = client.Reconnect(new AsbReconnectOptions
|
||||
{
|
||||
MaxAttempts = reconnectAttempts,
|
||||
Delay = TimeSpan.FromMilliseconds(reconnectDelayMs),
|
||||
CleanupOptions = new AsbClientCleanupOptions
|
||||
{
|
||||
DisconnectTimeout = TimeSpan.FromMilliseconds(cleanupDisconnectTimeoutMs),
|
||||
CloseTimeout = TimeSpan.FromMilliseconds(cleanupCloseTimeoutMs),
|
||||
},
|
||||
});
|
||||
Console.WriteLine($"reconnect_succeeded={reconnect.Succeeded} attempts={reconnect.Attempts.Count}");
|
||||
PrintCleanup("reconnect_cleanup", reconnect.CleanupResult);
|
||||
for (int i = 0; i < reconnect.Attempts.Count; i++)
|
||||
{
|
||||
AsbReconnectAttempt attempt = reconnect.Attempts[i];
|
||||
Console.WriteLine($"reconnect_attempt[{i}]=attempt:{attempt.Attempt} succeeded:{attempt.Succeeded} exception:{attempt.Exception?.GetType().Name ?? string.Empty}");
|
||||
}
|
||||
|
||||
if (reconnect.Client is not null)
|
||||
{
|
||||
using MxAsbDataClient reconnectedClient = reconnect.Client;
|
||||
RegisterItemsResponse reconnectRegister = reconnectedClient.RegisterMany(tags);
|
||||
Console.WriteLine($"reconnect_register_error=0x{reconnectRegister.Result.ErrorCode:X8} status=0x{reconnectRegister.Result.Status:X8} specific=0x{reconnectRegister.Result.SpecificErrorCode:X8}");
|
||||
PrintStatuses("reconnect_register_status", reconnectRegister.Status);
|
||||
|
||||
ReadResponse reconnectRead = reconnectedClient.ReadMany(tags);
|
||||
Console.WriteLine($"reconnect_read_error=0x{reconnectRead.Result.ErrorCode:X8} status=0x{reconnectRead.Result.Status:X8} specific=0x{reconnectRead.Result.SpecificErrorCode:X8}");
|
||||
PrintStatuses("reconnect_read_status", reconnectRead.Status);
|
||||
PrintValues("reconnect_read_value", reconnectRead.Values);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (probeCanceledCleanup)
|
||||
{
|
||||
AsbClientCleanupResult cleanup = client.Cleanup(new AsbClientCleanupOptions
|
||||
{
|
||||
DisconnectTimeout = TimeSpan.FromMilliseconds(cleanupDisconnectTimeoutMs),
|
||||
CloseTimeout = TimeSpan.FromMilliseconds(cleanupCloseTimeoutMs),
|
||||
CancellationToken = new CancellationToken(canceled: true),
|
||||
});
|
||||
PrintCleanup("canceled_cleanup", cleanup);
|
||||
return;
|
||||
}
|
||||
|
||||
if (probeInvalidTargets)
|
||||
{
|
||||
RunInvalidTargetProbe(client, invalidTag, errorWriteCompleteTimeoutMs, errorWriteCompletePollMs);
|
||||
}
|
||||
|
||||
if (probeWrongTypeWrite)
|
||||
{
|
||||
RunWrongTypeWriteProbe(client, wrongTypeWriteTag, errorWriteCompleteTimeoutMs, errorWriteCompletePollMs);
|
||||
}
|
||||
|
||||
if (probeInvalidCleanup)
|
||||
{
|
||||
RunInvalidCleanupProbe(client, subscribeSampleMs);
|
||||
}
|
||||
|
||||
if (probeEmptyPublish)
|
||||
{
|
||||
RunEmptyPublishProbe(client, errorPublishCount, subscribeSampleMs, publishDelayMs);
|
||||
}
|
||||
|
||||
if (probeOperationCompleteCandidates)
|
||||
{
|
||||
RunOperationCompleteCandidateProbe(client, tags, subscribeSampleMs, publishDelayMs);
|
||||
return;
|
||||
}
|
||||
|
||||
if (subscribe)
|
||||
{
|
||||
long subscriptionId = 0;
|
||||
try
|
||||
{
|
||||
CreateSubscriptionResponse create = client.CreateSubscription(maxQueueSize: 128, sampleInterval: (ulong)subscribeSampleMs);
|
||||
subscriptionId = create.SubscriptionId;
|
||||
Console.WriteLine($"create_subscription_error=0x{create.Result.ErrorCode:X8} status=0x{create.Result.Status:X8} specific=0x{create.Result.SpecificErrorCode:X8} subscription_id={subscriptionId}");
|
||||
|
||||
AddMonitoredItemsResponse add = client.AddMonitoredItems(subscriptionId, tags, (ulong)subscribeSampleMs, buffered: subscribeBuffered);
|
||||
Console.WriteLine($"add_monitored_error=0x{add.Result.ErrorCode:X8} status=0x{add.Result.Status:X8} specific=0x{add.Result.SpecificErrorCode:X8}");
|
||||
PrintStatuses("add_monitored_status", add.Status);
|
||||
itemNamesById = AsbPublishMapper.CreateItemNameMap(register.Status, add.Status);
|
||||
ItemIdentity[] monitoredItems = add.Status?.Select(status => status.Item).ToArray() ?? [];
|
||||
|
||||
for (int i = 0; i < publishCount; i++)
|
||||
{
|
||||
if (i > 0 && publishDelayMs > 0)
|
||||
{
|
||||
Thread.Sleep(TimeSpan.FromMilliseconds(publishDelayMs));
|
||||
}
|
||||
|
||||
AsbPublishResult mapped = client.PublishValues(subscriptionId);
|
||||
PublishResponse publish = mapped.Response;
|
||||
Console.WriteLine($"publish[{i}]_error=0x{publish.Result.ErrorCode:X8} status=0x{publish.Result.Status:X8} specific=0x{publish.Result.SpecificErrorCode:X8}");
|
||||
PrintStatuses($"publish[{i}]_status", publish.Status);
|
||||
PrintMonitoredValues($"publish[{i}]_value", publish.Values);
|
||||
PrintPublishedValues($"publish[{i}]_mapped", mapped.Values);
|
||||
}
|
||||
|
||||
if (monitoredItems.Length > 0)
|
||||
{
|
||||
DeleteMonitoredItemsResponse deleteItems = client.DeleteMonitoredItems(subscriptionId, monitoredItems);
|
||||
Console.WriteLine($"delete_monitored_error=0x{deleteItems.Result.ErrorCode:X8} status=0x{deleteItems.Result.Status:X8} specific=0x{deleteItems.Result.SpecificErrorCode:X8}");
|
||||
PrintStatuses("delete_monitored_status", deleteItems.Status);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (subscriptionId != 0)
|
||||
{
|
||||
DeleteSubscriptionResponse delete = client.DeleteSubscription(subscriptionId);
|
||||
Console.WriteLine($"delete_subscription_error=0x{delete.Result.ErrorCode:X8} status=0x{delete.Result.Status:X8} specific=0x{delete.Result.SpecificErrorCode:X8} subscription_id={subscriptionId}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (writeVariant.HasValue)
|
||||
{
|
||||
const uint writeHandle = 0xA5B21001;
|
||||
WriteResponse write = client.Write(tag, writeVariant.Value, writeHandle, "MxAsbClient probe write");
|
||||
Console.WriteLine($"write_error=0x{write.Result.ErrorCode:X8} status=0x{write.Result.Status:X8} specific=0x{write.Result.SpecificErrorCode:X8} handle=0x{writeHandle:X8}");
|
||||
PrintStatuses("write_status", write.Status);
|
||||
|
||||
if (waitWriteComplete)
|
||||
{
|
||||
AsbWriteCompletionOptions options = new()
|
||||
{
|
||||
Timeout = TimeSpan.FromMilliseconds(writeCompleteTimeoutMs),
|
||||
PollInterval = TimeSpan.FromMilliseconds(writeCompletePollMs),
|
||||
ReadbackDelay = TimeSpan.FromMilliseconds(writeReadbackDelayMs),
|
||||
};
|
||||
AsbWriteCompletionReadbackResult completionAndReadback = client.WaitForWriteCompleteAndRead(tag, writeHandle, options);
|
||||
AsbWriteCompletionResult completion = completionAndReadback.Completion;
|
||||
Console.WriteLine($"write_completion handle=0x{completion.WriteHandle:X8} completed={completion.Completed} timed_out={completion.TimedOut} elapsed_ms={(long)completion.Elapsed.TotalMilliseconds} polls={completion.PollCount} raw_count={completion.CompleteWrites.Count}");
|
||||
for (int i = 0; i < completion.Responses.Count; i++)
|
||||
{
|
||||
PublishWriteCompleteResponse response = completion.Responses[i];
|
||||
Console.WriteLine($"write_completion_poll[{i}]_error=0x{response.Result.ErrorCode:X8} status=0x{response.Result.Status:X8} specific=0x{response.Result.SpecificErrorCode:X8} count={response.CompleteWrites?.Length ?? 0}");
|
||||
}
|
||||
|
||||
PrintWriteCompletes("write_completion_raw", completion.CompleteWrites);
|
||||
if (completion.MatchingComplete.HasValue)
|
||||
{
|
||||
PrintWriteCompletes("write_completion_match", [completion.MatchingComplete.Value]);
|
||||
}
|
||||
|
||||
if (writeReadbackDelayMs > 0 && completion.Completed)
|
||||
{
|
||||
Console.WriteLine($"read_after_write_delay_ms={writeReadbackDelayMs}");
|
||||
}
|
||||
|
||||
if (completionAndReadback.Readback is not null)
|
||||
{
|
||||
read = completionAndReadback.Readback;
|
||||
Console.WriteLine($"read_after_write_error=0x{read.Result.ErrorCode:X8} status=0x{read.Result.Status:X8} specific=0x{read.Result.SpecificErrorCode:X8}");
|
||||
PrintStatuses("read_after_write_status", read.Status);
|
||||
PrintValues("read_after_write_value", read.Values);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
PublishWriteCompleteResponse complete = client.PublishWriteComplete();
|
||||
Console.WriteLine($"publish_write_complete_error=0x{complete.Result.ErrorCode:X8} status=0x{complete.Result.Status:X8} specific=0x{complete.Result.SpecificErrorCode:X8}");
|
||||
Console.WriteLine($"publish_write_complete_count={complete.CompleteWrites?.Length ?? 0}");
|
||||
PrintWriteCompletes("publish_write_complete", complete.CompleteWrites ?? []);
|
||||
|
||||
if (writeReadbackDelayMs > 0)
|
||||
{
|
||||
Console.WriteLine($"read_after_write_delay_ms={writeReadbackDelayMs}");
|
||||
Thread.Sleep(TimeSpan.FromMilliseconds(writeReadbackDelayMs));
|
||||
}
|
||||
|
||||
read = client.Read(tag);
|
||||
Console.WriteLine($"read_after_write_error=0x{read.Result.ErrorCode:X8} status=0x{read.Result.Status:X8} specific=0x{read.Result.SpecificErrorCode:X8}");
|
||||
PrintStatuses("read_after_write_status", read.Status);
|
||||
PrintValues("read_after_write_value", read.Values);
|
||||
}
|
||||
}
|
||||
|
||||
UnregisterItemsResponse unregister = registeredItems.Length > 0
|
||||
? client.UnregisterMany(registeredItems)
|
||||
: client.Unregister(tag);
|
||||
Console.WriteLine($"unregister_error=0x{unregister.Result.ErrorCode:X8} status=0x{unregister.Result.Status:X8} specific=0x{unregister.Result.SpecificErrorCode:X8}");
|
||||
PrintStatuses("unregister_status", unregister.Status);
|
||||
|
||||
static void PrintStatuses(string prefix, ItemStatus[]? statuses)
|
||||
{
|
||||
if (statuses is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < statuses.Length; i++)
|
||||
{
|
||||
ItemStatus status = statuses[i];
|
||||
AsbItemStatusSummary summary = AsbResultMapper.ToItemSummary(status);
|
||||
Console.WriteLine($"{prefix}[{i}]=item:{status.Item.Name} id:{status.Item.Id} id_specified:{status.Item.IdSpecified} error:0x{status.ErrorCode:X8} error_name:{summary.Error} error_specified:{status.ErrorCodeSpecified} status_count:{status.Status.Count} status_payload_len:{status.Status.Payload?.Length ?? 0} status:{FormatStatusElements(summary.Status)}");
|
||||
}
|
||||
}
|
||||
|
||||
static void PrintValues(string prefix, RuntimeValue[]? values)
|
||||
{
|
||||
if (values is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < values.Length; i++)
|
||||
{
|
||||
RuntimeValue value = values[i];
|
||||
Console.WriteLine($"{prefix}[{i}]=type:{value.Value.Type} length:{value.Value.Length} payload_len:{value.Value.Payload?.Length ?? 0} preview:{MxAsbDataClient.FormatVariant(value.Value)}");
|
||||
Console.WriteLine($"{prefix}[{i}].timestamp={value.Timestamp:o} timestamp_specified={value.TimestampSpecified}");
|
||||
Console.WriteLine($"{prefix}[{i}].status_count={value.Status.Count} status_payload_len={value.Status.Payload?.Length ?? 0} status:{FormatStatusElements(AsbPublishMapper.DecodeStatus(value.Status))}");
|
||||
}
|
||||
}
|
||||
|
||||
static void PrintMonitoredValues(string prefix, MonitoredItemValue[]? values)
|
||||
{
|
||||
if (values is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < values.Length; i++)
|
||||
{
|
||||
MonitoredItemValue item = values[i];
|
||||
RuntimeValue value = item.Value;
|
||||
Console.WriteLine($"{prefix}[{i}]=item:{item.Item.Name} id:{item.Item.Id} id_specified:{item.Item.IdSpecified} type:{value.Value.Type} length:{value.Value.Length} payload_len:{value.Value.Payload?.Length ?? 0} preview:{MxAsbDataClient.FormatVariant(value.Value)}");
|
||||
Console.WriteLine($"{prefix}[{i}].timestamp={value.Timestamp:o} timestamp_specified={value.TimestampSpecified}");
|
||||
Console.WriteLine($"{prefix}[{i}].status_count={value.Status.Count} status_payload_len={value.Status.Payload?.Length ?? 0}");
|
||||
Console.WriteLine($"{prefix}[{i}].userdata_type={item.UserData.Type} userdata_length={item.UserData.Length} userdata_payload_len={item.UserData.Payload?.Length ?? 0}");
|
||||
}
|
||||
}
|
||||
|
||||
static void PrintPublishedValues(string prefix, IReadOnlyList<AsbPublishedValue> values)
|
||||
{
|
||||
for (int i = 0; i < values.Count; i++)
|
||||
{
|
||||
AsbPublishedValue value = values[i];
|
||||
Console.WriteLine($"{prefix}[{i}]=item:{value.ItemName ?? string.Empty} id:{value.ItemId} type:{value.VariantType} quality:{FormatNullableHex(value.Quality)} timestamp:{value.TimestampUtc:O} preview:{value.Preview}");
|
||||
Console.WriteLine($"{prefix}[{i}].status={FormatStatusElements(value.Status)} raw_count:{value.RawStatus.Count} raw_payload_len:{value.RawStatus.Payload?.Length ?? 0}");
|
||||
}
|
||||
}
|
||||
|
||||
static void PrintWriteCompletes(string prefix, IReadOnlyList<ItemWriteComplete> writes)
|
||||
{
|
||||
for (int i = 0; i < writes.Count; i++)
|
||||
{
|
||||
ItemWriteComplete item = writes[i];
|
||||
Console.WriteLine($"{prefix}[{i}]=handle:{item.WriteHandle} handle_hex:0x{item.WriteHandle:X8} handle_specified:{item.WriteHandleSpecified} status_items:{item.Status?.Length ?? 0}");
|
||||
PrintStatuses($"{prefix}[{i}].status", item.Status);
|
||||
}
|
||||
}
|
||||
|
||||
static void PrintPublishWriteComplete(string prefix, PublishWriteCompleteResponse response)
|
||||
{
|
||||
Console.WriteLine($"{prefix}_write_complete_error=0x{response.Result.ErrorCode:X8} status=0x{response.Result.Status:X8} specific=0x{response.Result.SpecificErrorCode:X8} count={response.CompleteWrites?.Length ?? 0}");
|
||||
PrintWriteCompletes($"{prefix}_write_complete", response.CompleteWrites ?? []);
|
||||
}
|
||||
|
||||
static string FormatStatusElements(IReadOnlyList<AsbStatusElement> status)
|
||||
{
|
||||
return status.Count == 0
|
||||
? string.Empty
|
||||
: string.Join("|", status.Select(item => $"{item.Type}:{item.Value}"));
|
||||
}
|
||||
|
||||
static string FormatNullableHex(ushort? value)
|
||||
{
|
||||
return value.HasValue ? $"0x{value.Value:X4}" : string.Empty;
|
||||
}
|
||||
|
||||
static void RunInvalidTargetProbe(MxAsbDataClient client, string invalidTag, int writeCompleteTimeoutMs, int writeCompletePollMs)
|
||||
{
|
||||
Console.WriteLine($"probe_invalid_targets tag={invalidTag}");
|
||||
|
||||
RegisterItemsResponse register = client.RegisterMany([invalidTag]);
|
||||
Console.WriteLine($"invalid_register_error=0x{register.Result.ErrorCode:X8} status=0x{register.Result.Status:X8} specific=0x{register.Result.SpecificErrorCode:X8}");
|
||||
PrintStatuses("invalid_register_status", register.Status);
|
||||
|
||||
ReadResponse read = client.ReadMany([invalidTag]);
|
||||
Console.WriteLine($"invalid_read_error=0x{read.Result.ErrorCode:X8} status=0x{read.Result.Status:X8} specific=0x{read.Result.SpecificErrorCode:X8}");
|
||||
PrintStatuses("invalid_read_status", read.Status);
|
||||
PrintValues("invalid_read_value", read.Values);
|
||||
|
||||
const uint writeHandle = 0xA5B2E001;
|
||||
WriteResponse write = client.Write(invalidTag, AsbVariantFactory.FromInt32(1), writeHandle, "MxAsbClient probe invalid-target write");
|
||||
Console.WriteLine($"invalid_write_error=0x{write.Result.ErrorCode:X8} status=0x{write.Result.Status:X8} specific=0x{write.Result.SpecificErrorCode:X8} handle=0x{writeHandle:X8}");
|
||||
PrintStatuses("invalid_write_status", write.Status);
|
||||
|
||||
PrintWriteCompletionProbe(client, "invalid_write_completion", writeHandle, writeCompleteTimeoutMs, writeCompletePollMs);
|
||||
|
||||
ItemIdentity[] registeredItems = register.Status?.Select(status => status.Item).ToArray() ?? [];
|
||||
UnregisterItemsResponse unregister = registeredItems.Length > 0
|
||||
? client.UnregisterMany(registeredItems)
|
||||
: client.Unregister(invalidTag);
|
||||
Console.WriteLine($"invalid_unregister_error=0x{unregister.Result.ErrorCode:X8} status=0x{unregister.Result.Status:X8} specific=0x{unregister.Result.SpecificErrorCode:X8}");
|
||||
PrintStatuses("invalid_unregister_status", unregister.Status);
|
||||
}
|
||||
|
||||
static void RunWrongTypeWriteProbe(MxAsbDataClient client, string tag, int writeCompleteTimeoutMs, int writeCompletePollMs)
|
||||
{
|
||||
Console.WriteLine($"probe_wrong_type_write tag={tag}");
|
||||
const uint writeHandle = 0xA5B2E002;
|
||||
WriteResponse write = client.Write(tag, AsbVariantFactory.FromString("wrong-type-write-probe"), writeHandle, "MxAsbClient probe wrong-type write");
|
||||
Console.WriteLine($"wrong_type_write_error=0x{write.Result.ErrorCode:X8} status=0x{write.Result.Status:X8} specific=0x{write.Result.SpecificErrorCode:X8} handle=0x{writeHandle:X8}");
|
||||
PrintStatuses("wrong_type_write_status", write.Status);
|
||||
|
||||
PrintWriteCompletionProbe(client, "wrong_type_write_completion", writeHandle, writeCompleteTimeoutMs, writeCompletePollMs);
|
||||
|
||||
ReadResponse read = client.Read(tag);
|
||||
Console.WriteLine($"wrong_type_read_after_error=0x{read.Result.ErrorCode:X8} status=0x{read.Result.Status:X8} specific=0x{read.Result.SpecificErrorCode:X8}");
|
||||
PrintStatuses("wrong_type_read_after_status", read.Status);
|
||||
PrintValues("wrong_type_read_after_value", read.Values);
|
||||
}
|
||||
|
||||
static void PrintWriteCompletionProbe(
|
||||
MxAsbDataClient client,
|
||||
string prefix,
|
||||
uint writeHandle,
|
||||
int writeCompleteTimeoutMs,
|
||||
int writeCompletePollMs)
|
||||
{
|
||||
AsbWriteCompletionOptions options = new()
|
||||
{
|
||||
Timeout = TimeSpan.FromMilliseconds(writeCompleteTimeoutMs),
|
||||
PollInterval = TimeSpan.FromMilliseconds(writeCompletePollMs),
|
||||
};
|
||||
AsbWriteCompletionResult completion = client.WaitForWriteComplete(writeHandle, options);
|
||||
Console.WriteLine($"{prefix} handle=0x{completion.WriteHandle:X8} completed={completion.Completed} timed_out={completion.TimedOut} elapsed_ms={(long)completion.Elapsed.TotalMilliseconds} polls={completion.PollCount} raw_count={completion.CompleteWrites.Count}");
|
||||
for (int i = 0; i < completion.Responses.Count; i++)
|
||||
{
|
||||
PublishWriteCompleteResponse response = completion.Responses[i];
|
||||
Console.WriteLine($"{prefix}_poll[{i}]_error=0x{response.Result.ErrorCode:X8} status=0x{response.Result.Status:X8} specific=0x{response.Result.SpecificErrorCode:X8} count={response.CompleteWrites?.Length ?? 0}");
|
||||
}
|
||||
|
||||
PrintWriteCompletes($"{prefix}_raw", completion.CompleteWrites);
|
||||
if (completion.MatchingComplete.HasValue)
|
||||
{
|
||||
PrintWriteCompletes($"{prefix}_match", [completion.MatchingComplete.Value]);
|
||||
}
|
||||
}
|
||||
|
||||
static void RunInvalidCleanupProbe(MxAsbDataClient client, int subscribeSampleMs)
|
||||
{
|
||||
Console.WriteLine("probe_invalid_cleanup=True");
|
||||
long subscriptionId = 0;
|
||||
try
|
||||
{
|
||||
CreateSubscriptionResponse create = client.CreateSubscription(maxQueueSize: 128, sampleInterval: (ulong)subscribeSampleMs);
|
||||
subscriptionId = create.SubscriptionId;
|
||||
Console.WriteLine($"invalid_cleanup_create_subscription_error=0x{create.Result.ErrorCode:X8} status=0x{create.Result.Status:X8} specific=0x{create.Result.SpecificErrorCode:X8} subscription_id={subscriptionId}");
|
||||
|
||||
ItemIdentity invalidItem = CreateInvalidItemIdentity();
|
||||
DeleteMonitoredItemsResponse deleteItems = client.DeleteMonitoredItems(subscriptionId, [invalidItem]);
|
||||
Console.WriteLine($"invalid_cleanup_delete_monitored_error=0x{deleteItems.Result.ErrorCode:X8} status=0x{deleteItems.Result.Status:X8} specific=0x{deleteItems.Result.SpecificErrorCode:X8}");
|
||||
PrintStatuses("invalid_cleanup_delete_monitored_status", deleteItems.Status);
|
||||
|
||||
long invalidSubscriptionId = subscriptionId == long.MaxValue ? subscriptionId - 1 : subscriptionId + 987654321;
|
||||
DeleteSubscriptionResponse deleteInvalid = client.DeleteSubscription(invalidSubscriptionId);
|
||||
Console.WriteLine($"invalid_cleanup_delete_subscription_error=0x{deleteInvalid.Result.ErrorCode:X8} status=0x{deleteInvalid.Result.Status:X8} specific=0x{deleteInvalid.Result.SpecificErrorCode:X8} subscription_id={invalidSubscriptionId}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (subscriptionId != 0)
|
||||
{
|
||||
DeleteSubscriptionResponse delete = client.DeleteSubscription(subscriptionId);
|
||||
Console.WriteLine($"invalid_cleanup_delete_valid_subscription_error=0x{delete.Result.ErrorCode:X8} status=0x{delete.Result.Status:X8} specific=0x{delete.Result.SpecificErrorCode:X8} subscription_id={subscriptionId}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void RunEmptyPublishProbe(MxAsbDataClient client, int publishCount, int subscribeSampleMs, int publishDelayMs)
|
||||
{
|
||||
Console.WriteLine("probe_empty_publish=True");
|
||||
long subscriptionId = 0;
|
||||
try
|
||||
{
|
||||
CreateSubscriptionResponse create = client.CreateSubscription(maxQueueSize: 128, sampleInterval: (ulong)subscribeSampleMs);
|
||||
subscriptionId = create.SubscriptionId;
|
||||
Console.WriteLine($"empty_publish_create_subscription_error=0x{create.Result.ErrorCode:X8} status=0x{create.Result.Status:X8} specific=0x{create.Result.SpecificErrorCode:X8} subscription_id={subscriptionId}");
|
||||
|
||||
for (int i = 0; i < publishCount; i++)
|
||||
{
|
||||
if (i > 0 && publishDelayMs > 0)
|
||||
{
|
||||
Thread.Sleep(TimeSpan.FromMilliseconds(publishDelayMs));
|
||||
}
|
||||
|
||||
AsbPublishResult mapped = client.PublishValues(subscriptionId);
|
||||
PublishResponse publish = mapped.Response;
|
||||
Console.WriteLine($"empty_publish[{i}]_error=0x{publish.Result.ErrorCode:X8} status=0x{publish.Result.Status:X8} specific=0x{publish.Result.SpecificErrorCode:X8}");
|
||||
PrintStatuses($"empty_publish[{i}]_status", publish.Status);
|
||||
PrintMonitoredValues($"empty_publish[{i}]_value", publish.Values);
|
||||
PrintPublishedValues($"empty_publish[{i}]_mapped", mapped.Values);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (subscriptionId != 0)
|
||||
{
|
||||
DeleteSubscriptionResponse delete = client.DeleteSubscription(subscriptionId);
|
||||
Console.WriteLine($"empty_publish_delete_subscription_error=0x{delete.Result.ErrorCode:X8} status=0x{delete.Result.Status:X8} specific=0x{delete.Result.SpecificErrorCode:X8} subscription_id={subscriptionId}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void RunOperationCompleteCandidateProbe(MxAsbDataClient client, string[] tags, int subscribeSampleMs, int publishDelayMs)
|
||||
{
|
||||
Console.WriteLine("probe_operation_complete_candidates=True");
|
||||
PrintPublishWriteComplete("operation_candidate_initial", client.PublishWriteComplete());
|
||||
|
||||
long subscriptionId = 0;
|
||||
try
|
||||
{
|
||||
CreateSubscriptionResponse create = client.CreateSubscription(maxQueueSize: 128, sampleInterval: (ulong)subscribeSampleMs);
|
||||
subscriptionId = create.SubscriptionId;
|
||||
Console.WriteLine($"operation_candidate_create_subscription_error=0x{create.Result.ErrorCode:X8} status=0x{create.Result.Status:X8} specific=0x{create.Result.SpecificErrorCode:X8} subscription_id={subscriptionId}");
|
||||
PrintPublishWriteComplete("operation_candidate_after_create_subscription", client.PublishWriteComplete());
|
||||
|
||||
AddMonitoredItemsResponse add = client.AddMonitoredItems(subscriptionId, tags, (ulong)subscribeSampleMs);
|
||||
Console.WriteLine($"operation_candidate_add_monitored_error=0x{add.Result.ErrorCode:X8} status=0x{add.Result.Status:X8} specific=0x{add.Result.SpecificErrorCode:X8}");
|
||||
PrintStatuses("operation_candidate_add_monitored_status", add.Status);
|
||||
PrintPublishWriteComplete("operation_candidate_after_add_monitored", client.PublishWriteComplete());
|
||||
|
||||
if (publishDelayMs > 0)
|
||||
{
|
||||
Thread.Sleep(TimeSpan.FromMilliseconds(publishDelayMs));
|
||||
}
|
||||
|
||||
PublishResponse publish = client.Publish(subscriptionId);
|
||||
Console.WriteLine($"operation_candidate_publish_error=0x{publish.Result.ErrorCode:X8} status=0x{publish.Result.Status:X8} specific=0x{publish.Result.SpecificErrorCode:X8}");
|
||||
PrintStatuses("operation_candidate_publish_status", publish.Status);
|
||||
PrintMonitoredValues("operation_candidate_publish_value", publish.Values);
|
||||
PrintPublishWriteComplete("operation_candidate_after_publish", client.PublishWriteComplete());
|
||||
|
||||
ItemIdentity[] monitoredItems = add.Status?.Select(status => status.Item).ToArray() ?? [];
|
||||
if (monitoredItems.Length > 0)
|
||||
{
|
||||
DeleteMonitoredItemsResponse deleteItems = client.DeleteMonitoredItems(subscriptionId, monitoredItems);
|
||||
Console.WriteLine($"operation_candidate_delete_monitored_error=0x{deleteItems.Result.ErrorCode:X8} status=0x{deleteItems.Result.Status:X8} specific=0x{deleteItems.Result.SpecificErrorCode:X8}");
|
||||
PrintStatuses("operation_candidate_delete_monitored_status", deleteItems.Status);
|
||||
PrintPublishWriteComplete("operation_candidate_after_delete_monitored", client.PublishWriteComplete());
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (subscriptionId != 0)
|
||||
{
|
||||
DeleteSubscriptionResponse delete = client.DeleteSubscription(subscriptionId);
|
||||
Console.WriteLine($"operation_candidate_delete_subscription_error=0x{delete.Result.ErrorCode:X8} status=0x{delete.Result.Status:X8} specific=0x{delete.Result.SpecificErrorCode:X8} subscription_id={subscriptionId}");
|
||||
PrintPublishWriteComplete("operation_candidate_after_delete_subscription", client.PublishWriteComplete());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static ItemIdentity CreateInvalidItemIdentity()
|
||||
{
|
||||
return new ItemIdentity
|
||||
{
|
||||
Type = (ushort)ItemIdentityType.Name,
|
||||
ReferenceType = (ushort)ItemReferenceType.Absolute,
|
||||
Name = "DefinitelyMissingObject.DefinitelyMissingAttribute",
|
||||
ContextName = string.Empty,
|
||||
Id = ulong.MaxValue,
|
||||
IdSpecified = true,
|
||||
};
|
||||
}
|
||||
|
||||
static void RunCompatibilitySubscribe(
|
||||
string endpoint,
|
||||
string? solution,
|
||||
string[] tags,
|
||||
bool dumpMessages,
|
||||
int publishCount,
|
||||
int subscribeSampleMs,
|
||||
int publishDelayMs)
|
||||
{
|
||||
using MxAsbCompatibilityServer server = new();
|
||||
int dataChangeCount = 0;
|
||||
server.DataChanged += (_, evt) =>
|
||||
{
|
||||
dataChangeCount++;
|
||||
Console.WriteLine($"compat_data_change[{dataChangeCount - 1}]=server:{evt.ServerHandle} item:{evt.ItemHandle} quality:0x{evt.Quality:X4} timestamp:{evt.TimestampUtc:O} value:{FormatObject(evt.Value)} status:{FormatStatusElements(evt.Status)}");
|
||||
};
|
||||
|
||||
int serverHandle = server.Register(endpoint, solution, Console.WriteLine, dumpMessages);
|
||||
Console.WriteLine($"compat_register_server={serverHandle}");
|
||||
|
||||
AsbSubscriptionOptions subscriptionOptions = new()
|
||||
{
|
||||
MaxQueueSize = 128,
|
||||
SampleInterval = (ulong)subscribeSampleMs,
|
||||
};
|
||||
AsbMonitoredItemOptions monitoredItemOptions = new()
|
||||
{
|
||||
SampleInterval = (ulong)subscribeSampleMs,
|
||||
};
|
||||
|
||||
int[] itemHandles = tags.Select(tag =>
|
||||
{
|
||||
int itemHandle = server.AddItem(serverHandle, tag);
|
||||
Console.WriteLine($"compat_add_item tag:{tag} item:{itemHandle}");
|
||||
server.Advise(serverHandle, itemHandle, subscriptionOptions, monitoredItemOptions);
|
||||
Console.WriteLine($"compat_advise item:{itemHandle}");
|
||||
return itemHandle;
|
||||
}).ToArray();
|
||||
|
||||
for (int i = 0; i < publishCount; i++)
|
||||
{
|
||||
if (i > 0 && publishDelayMs > 0)
|
||||
{
|
||||
Thread.Sleep(TimeSpan.FromMilliseconds(publishDelayMs));
|
||||
}
|
||||
|
||||
AsbPublishResult result = server.Poll(serverHandle);
|
||||
Console.WriteLine($"compat_poll[{i}] error=0x{result.Response.Result.ErrorCode:X8} values:{result.Values.Count}");
|
||||
}
|
||||
|
||||
foreach (int itemHandle in itemHandles)
|
||||
{
|
||||
server.RemoveItem(serverHandle, itemHandle);
|
||||
Console.WriteLine($"compat_remove_item item:{itemHandle}");
|
||||
}
|
||||
|
||||
server.Unregister(serverHandle);
|
||||
Console.WriteLine($"compat_unregister_server={serverHandle} data_changes={dataChangeCount}");
|
||||
}
|
||||
|
||||
static string FormatObject(object? value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
null => string.Empty,
|
||||
Array array => string.Join(",", array.Cast<object?>()),
|
||||
DateTime dateTime => dateTime.ToString("O", CultureInfo.InvariantCulture),
|
||||
TimeSpan timeSpan => timeSpan.ToString("c", CultureInfo.InvariantCulture),
|
||||
IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture),
|
||||
_ => value.ToString() ?? string.Empty,
|
||||
};
|
||||
}
|
||||
|
||||
static void PrintCleanup(string prefix, AsbClientCleanupResult? cleanup)
|
||||
{
|
||||
if (cleanup is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine($"{prefix}=completed:{cleanup.Completed} succeeded:{cleanup.Succeeded} abort:{cleanup.UsedAbortFallback} requires_new:{cleanup.RequiresNewConnection} disconnect_attempted:{cleanup.DisconnectAttempted} disconnect_sent:{cleanup.DisconnectSent} disconnect_failure:{cleanup.DisconnectFailure ?? string.Empty}");
|
||||
PrintCommunicationCleanup($"{prefix}.channel", cleanup.Channel);
|
||||
PrintCommunicationCleanup($"{prefix}.factory", cleanup.Factory);
|
||||
}
|
||||
|
||||
static void PrintCommunicationCleanup(string prefix, CommunicationObjectCleanupResult cleanup)
|
||||
{
|
||||
Console.WriteLine($"{prefix}=initial:{cleanup.InitialState} final:{cleanup.FinalState} close_attempted:{cleanup.CloseAttempted} closed:{cleanup.Closed} abort_attempted:{cleanup.AbortAttempted} aborted:{cleanup.Aborted} close_failure:{cleanup.CloseFailure ?? string.Empty} abort_failure:{cleanup.AbortFailure ?? string.Empty}");
|
||||
}
|
||||
|
||||
static string? GetArg(string[] args, string name)
|
||||
{
|
||||
string prefix = name + "=";
|
||||
return args.FirstOrDefault(arg => arg.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))?.Substring(prefix.Length);
|
||||
}
|
||||
|
||||
static string[] GetArgs(string[] args, string name)
|
||||
{
|
||||
string prefix = name + "=";
|
||||
return args
|
||||
.Where(arg => arg.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
.Select(arg => arg.Substring(prefix.Length))
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
static int? TryGetInt(string[] args, string name)
|
||||
{
|
||||
return int.TryParse(GetArg(args, name), out int result) ? result : null;
|
||||
}
|
||||
|
||||
static Variant? GetWriteVariant(string[] args)
|
||||
{
|
||||
int? writeInt = TryGetInt(args, "--write-int");
|
||||
if (writeInt.HasValue)
|
||||
{
|
||||
return AsbVariantFactory.FromInt32(writeInt.Value);
|
||||
}
|
||||
|
||||
string? writeBool = GetArg(args, "--write-bool");
|
||||
if (bool.TryParse(writeBool, out bool boolResult))
|
||||
{
|
||||
return AsbVariantFactory.FromBoolean(boolResult);
|
||||
}
|
||||
|
||||
string? writeFloat = GetArg(args, "--write-float");
|
||||
if (float.TryParse(writeFloat, NumberStyles.Float, CultureInfo.InvariantCulture, out float floatResult))
|
||||
{
|
||||
return AsbVariantFactory.FromSingle(floatResult);
|
||||
}
|
||||
|
||||
string? writeDouble = GetArg(args, "--write-double");
|
||||
if (double.TryParse(writeDouble, NumberStyles.Float, CultureInfo.InvariantCulture, out double doubleResult))
|
||||
{
|
||||
return AsbVariantFactory.FromDouble(doubleResult);
|
||||
}
|
||||
|
||||
string? writeString = GetArg(args, "--write-string");
|
||||
if (writeString is not null)
|
||||
{
|
||||
return AsbVariantFactory.FromString(writeString);
|
||||
}
|
||||
|
||||
string? writeDateTime = GetArg(args, "--write-datetime");
|
||||
if (DateTime.TryParse(
|
||||
writeDateTime,
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
|
||||
out DateTime dateTimeResult))
|
||||
{
|
||||
return AsbVariantFactory.FromDateTime(dateTimeResult);
|
||||
}
|
||||
|
||||
if (TryParseArray(args, "--write-int-array", int.TryParse, out int[] intArray))
|
||||
{
|
||||
return AsbVariantFactory.FromInt32Array(intArray);
|
||||
}
|
||||
|
||||
if (TryParseArray(args, "--write-bool-array", bool.TryParse, out bool[] boolArray))
|
||||
{
|
||||
return AsbVariantFactory.FromBooleanArray(boolArray);
|
||||
}
|
||||
|
||||
if (TryParseFloatArray(args, "--write-float-array", out float[] floatArray))
|
||||
{
|
||||
return AsbVariantFactory.FromSingleArray(floatArray);
|
||||
}
|
||||
|
||||
if (TryParseDoubleArray(args, "--write-double-array", out double[] doubleArray))
|
||||
{
|
||||
return AsbVariantFactory.FromDoubleArray(doubleArray);
|
||||
}
|
||||
|
||||
string? writeStringArray = GetArg(args, "--write-string-array");
|
||||
if (writeStringArray is not null)
|
||||
{
|
||||
return AsbVariantFactory.FromStringArray(writeStringArray.Split('|'));
|
||||
}
|
||||
|
||||
if (TryParseDateTimeArray(args, "--write-datetime-array", out DateTime[] dateTimeArray))
|
||||
{
|
||||
return AsbVariantFactory.FromDateTimeArray(dateTimeArray);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static bool TryParseArray<T>(string[] args, string name, TryParse<T> parser, out T[] values)
|
||||
{
|
||||
string? raw = GetArg(args, name);
|
||||
if (raw is null)
|
||||
{
|
||||
values = [];
|
||||
return false;
|
||||
}
|
||||
|
||||
string[] parts = raw.Split(',', StringSplitOptions.TrimEntries);
|
||||
values = new T[parts.Length];
|
||||
for (int i = 0; i < parts.Length; i++)
|
||||
{
|
||||
if (!parser(parts[i], out values[i]))
|
||||
{
|
||||
values = [];
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool TryParseFloatArray(string[] args, string name, out float[] values)
|
||||
{
|
||||
return TryParseArray(args, name, TryParseFloat, out values);
|
||||
}
|
||||
|
||||
static bool TryParseDoubleArray(string[] args, string name, out double[] values)
|
||||
{
|
||||
return TryParseArray(args, name, TryParseDouble, out values);
|
||||
}
|
||||
|
||||
static bool TryParseDateTimeArray(string[] args, string name, out DateTime[] values)
|
||||
{
|
||||
return TryParseArray(args, name, TryParseDateTime, out values);
|
||||
}
|
||||
|
||||
static bool TryParseFloat(string value, out float result)
|
||||
{
|
||||
return float.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out result);
|
||||
}
|
||||
|
||||
static bool TryParseDouble(string value, out double result)
|
||||
{
|
||||
return double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out result);
|
||||
}
|
||||
|
||||
static bool TryParseDateTime(string value, out DateTime result)
|
||||
{
|
||||
return DateTime.TryParse(
|
||||
value,
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
|
||||
out result);
|
||||
}
|
||||
|
||||
static bool HasArg(string[] args, string name)
|
||||
{
|
||||
return args.Any(arg => arg.Equals(name, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
static string FormatException(Exception ex)
|
||||
{
|
||||
return $"{ex.GetType().Name}:0x{ex.HResult:X8}:{ex.Message}";
|
||||
}
|
||||
|
||||
delegate bool TryParse<T>(string value, out T result);
|
||||
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MxAsbClient\MxAsbClient.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<PlatformTarget>x64</PlatformTarget>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,779 @@
|
||||
using MxAsbClient;
|
||||
using System.ServiceModel;
|
||||
|
||||
RunVariantFactoryTests();
|
||||
RunMonitoredItemValueTests();
|
||||
RunPublishMapperTests();
|
||||
RunCompatibilitySurfaceTests();
|
||||
RunConnectionOptionsTests();
|
||||
RunWriteOptionsTests();
|
||||
RunCollectionArgumentGuardTests();
|
||||
RunResultMapperTests();
|
||||
RunWriteCompletionOptionsTests();
|
||||
RunSubscriptionOptionsTests();
|
||||
RunCleanupOptionsTests();
|
||||
RunCommunicationCleanupTests();
|
||||
RunReconnectSurfaceTests();
|
||||
|
||||
Console.WriteLine("MxAsbClient ASB contract tests passed.");
|
||||
|
||||
static void RunVariantFactoryTests()
|
||||
{
|
||||
AssertVariant("bool", AsbDataType.TypeBool, 1, AsbVariantFactory.FromBoolean(true), true);
|
||||
AssertVariant("int", AsbDataType.TypeInt32, 4, AsbVariantFactory.FromInt32(123), 123);
|
||||
AssertVariant("float", AsbDataType.TypeFloat, 4, AsbVariantFactory.FromSingle(1.25f), 1.25f);
|
||||
AssertVariant("double", AsbDataType.TypeDouble, 8, AsbVariantFactory.FromDouble(1.125d), 1.125d);
|
||||
AssertVariant("string", AsbDataType.TypeString, 10, AsbVariantFactory.FromString("Alpha"), "Alpha");
|
||||
|
||||
DateTime timestamp = new(2026, 4, 26, 12, 34, 56, DateTimeKind.Utc);
|
||||
AssertVariant("datetime", AsbDataType.TypeDateTime, 8, AsbVariantFactory.FromDateTime(timestamp), timestamp);
|
||||
|
||||
TimeSpan duration = TimeSpan.FromSeconds(12.5);
|
||||
AssertVariant("duration", AsbDataType.TypeDuration, 8, AsbVariantFactory.FromDuration(duration), duration);
|
||||
|
||||
AssertVariant("int[]", AsbDataType.TypeInt32Array, 12, AsbVariantFactory.FromInt32Array([1, 2, 3]), new[] { 1, 2, 3 });
|
||||
AssertVariant("bool[]", AsbDataType.TypeBoolArray, 3, AsbVariantFactory.FromBooleanArray([true, false, true]), new[] { true, false, true });
|
||||
AssertVariant("float[]", AsbDataType.TypeFloatArray, 8, AsbVariantFactory.FromSingleArray([1.25f, 2.5f]), new[] { 1.25f, 2.5f });
|
||||
AssertVariant("double[]", AsbDataType.TypeDoubleArray, 16, AsbVariantFactory.FromDoubleArray([1.125d, 2.25d]), new[] { 1.125d, 2.25d });
|
||||
AssertVariant("string[]", AsbDataType.TypeStringArray, 18, AsbVariantFactory.FromStringArray(["A", "", "BC"]), new[] { "A", "", "BC" });
|
||||
AssertVariant("datetime[]", AsbDataType.TypeDateTimeArray, 16, AsbVariantFactory.FromDateTimeArray([timestamp, timestamp.AddMinutes(1)]), new[] { timestamp, timestamp.AddMinutes(1) });
|
||||
AssertVariant("duration[]", AsbDataType.TypeDurationArray, 16, AsbVariantFactory.FromDurationArray([duration, duration.Add(TimeSpan.FromSeconds(1))]), new[] { duration, duration.Add(TimeSpan.FromSeconds(1)) });
|
||||
}
|
||||
|
||||
static void AssertVariant<T>(string name, AsbDataType type, int length, Variant variant, T expected)
|
||||
{
|
||||
AssertEqual(name + " type", (ushort)type, variant.Type);
|
||||
AssertEqual(name + " length", length, variant.Length);
|
||||
AssertEqual(name + " payload length", length, variant.Payload?.Length ?? 0);
|
||||
|
||||
object? decoded = MxAsbDataClient.DecodeVariant(variant);
|
||||
AssertValue(name + " decoded", expected, decoded);
|
||||
}
|
||||
|
||||
static void AssertValue<T>(string name, T expected, object? actual)
|
||||
{
|
||||
if (expected is Array expectedArray)
|
||||
{
|
||||
if (actual is not Array actualArray || expectedArray.Length != actualArray.Length)
|
||||
{
|
||||
throw new InvalidOperationException($"{name}: array mismatch.");
|
||||
}
|
||||
|
||||
for (int i = 0; i < expectedArray.Length; i++)
|
||||
{
|
||||
object? expectedItem = expectedArray.GetValue(i);
|
||||
object? actualItem = actualArray.GetValue(i);
|
||||
if (!Equals(expectedItem, actualItem))
|
||||
{
|
||||
throw new InvalidOperationException($"{name}[{i}]: expected {expectedItem}, got {actualItem}.");
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Equals(expected, actual))
|
||||
{
|
||||
throw new InvalidOperationException($"{name}: expected {expected}, got {actual}.");
|
||||
}
|
||||
}
|
||||
|
||||
static void AssertEqual<T>(string name, T expected, T actual)
|
||||
{
|
||||
if (!Equals(expected, actual))
|
||||
{
|
||||
throw new InvalidOperationException($"{name}: expected {expected}, got {actual}.");
|
||||
}
|
||||
}
|
||||
|
||||
static void AssertNotNull(string name, object? value)
|
||||
{
|
||||
if (value is null)
|
||||
{
|
||||
throw new InvalidOperationException($"{name}: expected non-null.");
|
||||
}
|
||||
}
|
||||
|
||||
static void AssertThrows<TException>(string name, Action action)
|
||||
where TException : Exception
|
||||
{
|
||||
try
|
||||
{
|
||||
action();
|
||||
}
|
||||
catch (TException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"{name}: expected {typeof(TException).Name}.");
|
||||
}
|
||||
|
||||
static void RunMonitoredItemValueTests()
|
||||
{
|
||||
MonitoredItemValue original = new()
|
||||
{
|
||||
Item = new ItemIdentity
|
||||
{
|
||||
Type = (ushort)ItemIdentityType.Name,
|
||||
ReferenceType = (ushort)ItemReferenceType.Absolute,
|
||||
Name = "TestChildObject.TestInt",
|
||||
ContextName = string.Empty,
|
||||
Id = 123,
|
||||
IdSpecified = true,
|
||||
},
|
||||
Value = new RuntimeValue
|
||||
{
|
||||
Timestamp = new DateTime(2026, 4, 26, 16, 0, 0, DateTimeKind.Utc),
|
||||
TimestampSpecified = true,
|
||||
Value = AsbVariantFactory.FromInt32(412),
|
||||
Status = new AsbStatus { Count = 0, Payload = [] },
|
||||
},
|
||||
UserData = AsbVariantFactory.Empty,
|
||||
};
|
||||
|
||||
using MemoryStream stream = new();
|
||||
using BinaryWriter writer = new(stream, System.Text.Encoding.UTF8, leaveOpen: true);
|
||||
original.WriteToStream(writer);
|
||||
writer.Flush();
|
||||
stream.Position = 0;
|
||||
|
||||
using BinaryReader reader = new(stream, System.Text.Encoding.UTF8, leaveOpen: true);
|
||||
MonitoredItemValue decoded = new();
|
||||
decoded.InitializeFromStream(reader);
|
||||
|
||||
AssertEqual("monitored item name", original.Item.Name, decoded.Item.Name);
|
||||
AssertEqual("monitored item id", original.Item.Id, decoded.Item.Id);
|
||||
AssertEqual("monitored value timestamp", original.Value.Timestamp, decoded.Value.Timestamp);
|
||||
AssertValue("monitored value decoded", 412, MxAsbDataClient.DecodeVariant(decoded.Value.Value));
|
||||
}
|
||||
|
||||
static void RunPublishMapperTests()
|
||||
{
|
||||
AsbStatus status = new()
|
||||
{
|
||||
Count = 7,
|
||||
Payload = [0x85, 0x06, 0x10, 0x00, 0x07, 0xC0, 0x00],
|
||||
};
|
||||
IReadOnlyList<AsbStatusElement> elements = AsbPublishMapper.DecodeStatus(status);
|
||||
AssertEqual("status count", 3, elements.Count);
|
||||
AssertEqual("status category type", AsbStatusElementType.MxStatusCategory, elements[0].Type);
|
||||
AssertEqual("status category value", (ushort)0, elements[0].Value);
|
||||
AssertEqual("status detail type", AsbStatusElementType.MxStatusDetail, elements[1].Type);
|
||||
AssertEqual("status detail value", (ushort)16, elements[1].Value);
|
||||
AssertEqual("status quality type", AsbStatusElementType.MxQuality, elements[2].Type);
|
||||
AssertEqual("status quality value", (ushort)0x00C0, elements[2].Value);
|
||||
|
||||
MonitoredItemValue item = new()
|
||||
{
|
||||
Item = new ItemIdentity
|
||||
{
|
||||
Type = (ushort)ItemIdentityType.Id,
|
||||
ReferenceType = (ushort)ItemReferenceType.Absolute,
|
||||
Id = 456,
|
||||
IdSpecified = true,
|
||||
},
|
||||
Value = new RuntimeValue
|
||||
{
|
||||
Timestamp = new DateTime(2026, 4, 26, 16, 5, 0, DateTimeKind.Utc),
|
||||
TimestampSpecified = true,
|
||||
Value = AsbVariantFactory.FromString("mapped"),
|
||||
Status = status,
|
||||
},
|
||||
UserData = AsbVariantFactory.Empty,
|
||||
};
|
||||
AsbPublishedValue mapped = AsbPublishMapper.ToPublishedValue(item, new Dictionary<ulong, string> { [456] = "Test.Tag" });
|
||||
AssertEqual("published item name", "Test.Tag", mapped.ItemName);
|
||||
AssertEqual("published quality", (ushort?)0x00C0, mapped.Quality);
|
||||
AssertEqual("published preview", "mapped", mapped.Preview);
|
||||
AssertEqual("published value", "mapped", mapped.Value);
|
||||
AssertEqual("published status summary quality", AsbStatusQuality.Good, mapped.StatusSummary.Quality);
|
||||
AssertEqual("published status summary detail", AsbMxStatusDetail.RequestTimedOut, mapped.StatusSummary.Detail);
|
||||
|
||||
AsbPublishResult result = new(
|
||||
new PublishResponse { Result = new ArchestrAResult { ErrorCode = 32, Success = false } },
|
||||
[mapped]);
|
||||
AssertEqual("publish result summary", AsbErrorCode.PublishComplete, result.Result.Error);
|
||||
AssertEqual("publish result success-like", true, result.Result.IsSuccessLike);
|
||||
AssertEqual("publish result has values", true, result.HasValues);
|
||||
|
||||
AsbPublishResult emptyResult = new(
|
||||
new PublishResponse { Result = new ArchestrAResult { ErrorCode = 0, Success = true } },
|
||||
[]);
|
||||
AssertEqual("empty publish result summary", AsbErrorCode.Success, emptyResult.Result.Error);
|
||||
AssertEqual("empty publish result success-like", true, emptyResult.Result.IsSuccessLike);
|
||||
AssertEqual("empty publish result has values", false, emptyResult.HasValues);
|
||||
}
|
||||
|
||||
static void RunCompatibilitySurfaceTests()
|
||||
{
|
||||
Type serverType = typeof(MxAsbCompatibilityServer);
|
||||
AssertNotNull("asb compat data-change event", serverType.GetEvent(nameof(MxAsbCompatibilityServer.DataChanged)));
|
||||
AssertNotNull("asb compat register", serverType.GetMethod(
|
||||
nameof(MxAsbCompatibilityServer.Register),
|
||||
[typeof(string), typeof(string), typeof(Action<string>), typeof(bool)]));
|
||||
AssertNotNull("asb compat register options", serverType.GetMethod(nameof(MxAsbCompatibilityServer.Register), [typeof(AsbConnectionOptions)]));
|
||||
AssertNotNull("asb compat unregister", serverType.GetMethod(nameof(MxAsbCompatibilityServer.Unregister)));
|
||||
AssertNotNull("asb compat add item", serverType.GetMethod(nameof(MxAsbCompatibilityServer.AddItem)));
|
||||
AssertNotNull("asb compat remove item", serverType.GetMethod(nameof(MxAsbCompatibilityServer.RemoveItem)));
|
||||
AssertNotNull("asb compat advise", serverType.GetMethod(
|
||||
nameof(MxAsbCompatibilityServer.Advise),
|
||||
[typeof(int), typeof(int), typeof(ulong), typeof(long)]));
|
||||
AssertNotNull("asb compat advise options", serverType.GetMethod(
|
||||
nameof(MxAsbCompatibilityServer.Advise),
|
||||
[typeof(int), typeof(int), typeof(AsbSubscriptionOptions), typeof(AsbMonitoredItemOptions)]));
|
||||
AssertNotNull("asb compat poll", serverType.GetMethod(nameof(MxAsbCompatibilityServer.Poll)));
|
||||
AssertNotNull("asb buffered add monitored", typeof(MxAsbDataClient).GetMethod(
|
||||
nameof(MxAsbDataClient.AddMonitoredItems),
|
||||
[typeof(long), typeof(IEnumerable<string>), typeof(ulong), typeof(bool), typeof(bool)]));
|
||||
AssertNotNull("asb options add monitored", typeof(MxAsbDataClient).GetMethod(
|
||||
nameof(MxAsbDataClient.AddMonitoredItems),
|
||||
[typeof(long), typeof(IEnumerable<string>), typeof(AsbMonitoredItemOptions)]));
|
||||
|
||||
MxAsbDataChangeEvent dataChange = new(
|
||||
ServerHandle: 1,
|
||||
ItemHandle: 2,
|
||||
Value: 412,
|
||||
Quality: 0x00C0,
|
||||
TimestampUtc: new DateTime(2026, 4, 26, 17, 0, 0, DateTimeKind.Utc),
|
||||
Status:
|
||||
[
|
||||
new AsbStatusElement(AsbStatusElementType.MxStatusCategory, 0),
|
||||
new AsbStatusElement(AsbStatusElementType.MxStatusDetail, 0),
|
||||
new AsbStatusElement(AsbStatusElementType.MxQuality, 0x00C0),
|
||||
]);
|
||||
AssertEqual("compat status summary quality", AsbStatusQuality.Good, dataChange.StatusSummary.Quality);
|
||||
AssertEqual("compat status summary error", AsbErrorCode.Success, dataChange.StatusSummary.StatusError);
|
||||
|
||||
using MxAsbCompatibilityServer server = new();
|
||||
AssertThrows<ArgumentException>("unknown asb server handle", () => server.AddItem(404, "TestChildObject.TestInt"));
|
||||
}
|
||||
|
||||
static void RunConnectionOptionsTests()
|
||||
{
|
||||
AssertNotNull("asb connect options overload", typeof(MxAsbDataClient).GetMethod(
|
||||
nameof(MxAsbDataClient.Connect),
|
||||
[typeof(AsbConnectionOptions)]));
|
||||
|
||||
AsbConnectionOptions defaultOptions = new();
|
||||
AssertEqual("default endpoint", string.Empty, defaultOptions.Endpoint);
|
||||
AssertEqual("default solution", null, defaultOptions.SolutionName);
|
||||
AssertEqual("default trace", null, defaultOptions.Trace);
|
||||
AssertEqual("default dump messages", false, defaultOptions.DumpMessages);
|
||||
|
||||
AsbConnectionOptions options = new()
|
||||
{
|
||||
Endpoint = "net.tcp://example/ASB/IDataV2",
|
||||
SolutionName = "Galaxy",
|
||||
Trace = _ => { },
|
||||
DumpMessages = true,
|
||||
};
|
||||
options.Validate();
|
||||
AssertEqual("connection endpoint", "net.tcp://example/ASB/IDataV2", options.Endpoint);
|
||||
AssertEqual("connection solution", "Galaxy", options.SolutionName);
|
||||
AssertNotNull("connection trace", options.Trace);
|
||||
AssertEqual("connection dump messages", true, options.DumpMessages);
|
||||
|
||||
AssertThrows<ArgumentException>(
|
||||
"empty endpoint options",
|
||||
() => new AsbConnectionOptions { Endpoint = " " }.Validate());
|
||||
AssertThrows<ArgumentNullException>(
|
||||
"null options connect",
|
||||
() => MxAsbDataClient.Connect((AsbConnectionOptions)null!));
|
||||
AssertThrows<ArgumentException>(
|
||||
"empty endpoint connect",
|
||||
() => MxAsbDataClient.Connect(" "));
|
||||
AssertEqual("payload debug non-public", false, typeof(AsbPayloadDebug).IsPublic);
|
||||
}
|
||||
|
||||
static void RunWriteOptionsTests()
|
||||
{
|
||||
AssertNotNull("asb write positional overload", typeof(MxAsbDataClient).GetMethod(
|
||||
nameof(MxAsbDataClient.Write),
|
||||
[typeof(string), typeof(Variant), typeof(uint), typeof(string)]));
|
||||
AssertNotNull("asb write options overload", typeof(MxAsbDataClient).GetMethod(
|
||||
nameof(MxAsbDataClient.Write),
|
||||
[typeof(string), typeof(Variant), typeof(AsbWriteOptions)]));
|
||||
|
||||
AsbWriteOptions defaultOptions = new();
|
||||
AssertEqual("write options default handle", 0u, defaultOptions.WriteHandle);
|
||||
AssertEqual("write options default comment", null, defaultOptions.Comment);
|
||||
|
||||
AsbWriteOptions options = new()
|
||||
{
|
||||
WriteHandle = 0xA5B21001,
|
||||
Comment = "contract write",
|
||||
};
|
||||
AssertEqual("write options handle", (uint)0xA5B21001, options.WriteHandle);
|
||||
AssertEqual("write options comment", "contract write", options.Comment);
|
||||
|
||||
MxAsbDataClient uninitializedClient =
|
||||
(MxAsbDataClient)System.Runtime.CompilerServices.RuntimeHelpers.GetUninitializedObject(typeof(MxAsbDataClient));
|
||||
AssertThrows<ArgumentNullException>(
|
||||
"write null options",
|
||||
() => uninitializedClient.Write("Test.Tag", AsbVariantFactory.Empty, (AsbWriteOptions)null!));
|
||||
}
|
||||
|
||||
static void RunCollectionArgumentGuardTests()
|
||||
{
|
||||
MxAsbDataClient uninitializedClient =
|
||||
(MxAsbDataClient)System.Runtime.CompilerServices.RuntimeHelpers.GetUninitializedObject(typeof(MxAsbDataClient));
|
||||
|
||||
AssertThrows<ArgumentNullException>(
|
||||
"register many null tags",
|
||||
() => uninitializedClient.RegisterMany(null!));
|
||||
AssertThrows<ArgumentNullException>(
|
||||
"unregister many null items",
|
||||
() => uninitializedClient.UnregisterMany(null!));
|
||||
AssertThrows<ArgumentNullException>(
|
||||
"read many null tags",
|
||||
() => uninitializedClient.ReadMany(null!));
|
||||
AssertThrows<ArgumentNullException>(
|
||||
"add monitored null tags",
|
||||
() => uninitializedClient.AddMonitoredItems(123, null!, AsbMonitoredItemOptions.Default));
|
||||
AssertThrows<ArgumentNullException>(
|
||||
"delete monitored null items",
|
||||
() => uninitializedClient.DeleteMonitoredItems(123, null!));
|
||||
}
|
||||
|
||||
static void RunResultMapperTests()
|
||||
{
|
||||
AsbResultSummary success = AsbResultMapper.ToSummary(new ArchestrAResult { ErrorCode = 0, Success = true });
|
||||
AssertEqual("success error", AsbErrorCode.Success, success.Error);
|
||||
AssertEqual("success-like success", true, success.IsSuccessLike);
|
||||
|
||||
AsbResultSummary publishComplete = AsbResultMapper.ToSummary(new ArchestrAResult { ErrorCode = 32, Success = false });
|
||||
AssertEqual("publish complete error", AsbErrorCode.PublishComplete, publishComplete.Error);
|
||||
AssertEqual("publish complete success", false, publishComplete.IsSuccess);
|
||||
AssertEqual("publish complete success-like", true, publishComplete.IsSuccessLike);
|
||||
|
||||
AsbResultSummary unknown = AsbResultMapper.ToSummary(new ArchestrAResult { ErrorCode = 12345, Success = false });
|
||||
AssertEqual("unknown error", AsbErrorCode.Unknown, unknown.Error);
|
||||
AssertEqual("unknown raw", 12345, unknown.RawErrorCode);
|
||||
|
||||
AsbResultSummary outOfRange = AsbResultMapper.ToSummary(new ArchestrAResult { ErrorCode = 65536, Success = false });
|
||||
AssertEqual("out-of-range error", AsbErrorCode.Unknown, outOfRange.Error);
|
||||
AssertEqual("out-of-range raw", 65536, outOfRange.RawErrorCode);
|
||||
|
||||
AsbItemStatusSummary item = AsbResultMapper.ToItemSummary(new ItemStatus
|
||||
{
|
||||
Item = new ItemIdentity { Name = "Bad.Tag", Id = 42, IdSpecified = true },
|
||||
ErrorCode = 10,
|
||||
Status = new AsbStatus { Count = 1, Payload = [0x85] },
|
||||
});
|
||||
AssertEqual("item status error", AsbErrorCode.InvalidMonitoredItems, item.Error);
|
||||
AssertEqual("item status success", false, item.IsSuccess);
|
||||
AssertEqual("item status element", AsbStatusElementType.MxStatusCategory, item.Status[0].Type);
|
||||
AssertEqual("item status summary category", AsbMxStatusCategory.Ok, item.StatusSummary.Category);
|
||||
|
||||
IReadOnlyList<AsbItemStatusSummary> nullItems = AsbResultMapper.ToItemSummaries(null);
|
||||
AssertEqual("item summaries null", 0, nullItems.Count);
|
||||
|
||||
IReadOnlyList<AsbItemStatusSummary> emptyItems = AsbResultMapper.ToItemSummaries([]);
|
||||
AssertEqual("item summaries empty", 0, emptyItems.Count);
|
||||
|
||||
IReadOnlyList<AsbItemStatusSummary> oneItem = AsbResultMapper.ToItemSummaries(
|
||||
[
|
||||
new ItemStatus
|
||||
{
|
||||
Item = new ItemIdentity { Name = "Good.Tag", Id = 43, IdSpecified = true },
|
||||
ErrorCode = 0,
|
||||
Status = StatusBytes(0x05, 0x00, 0x00, 0x06, 0x00, 0x00, 0x07, 0xC0, 0x00),
|
||||
},
|
||||
]);
|
||||
AssertEqual("item summaries one count", 1, oneItem.Count);
|
||||
AssertEqual("item summaries one item", "Good.Tag", oneItem[0].ItemName);
|
||||
AssertEqual("item summaries one error", AsbErrorCode.Success, oneItem[0].Error);
|
||||
AssertEqual("item summaries one status", AsbStatusQuality.Good, oneItem[0].StatusSummary.Quality);
|
||||
|
||||
AsbStatusSummary good = AsbResultMapper.ToStatusSummary(StatusBytes(
|
||||
0x05, 0x00, 0x00,
|
||||
0x06, 0x00, 0x00,
|
||||
0x07, 0xC0, 0x00));
|
||||
AssertEqual("good status category", AsbMxStatusCategory.Ok, good.Category);
|
||||
AssertEqual("good status detail", AsbMxStatusDetail.None, good.Detail);
|
||||
AssertEqual("good status quality", AsbStatusQuality.Good, good.Quality);
|
||||
AssertEqual("good status error", AsbErrorCode.Success, good.StatusError);
|
||||
AssertEqual("good status success-like", true, good.IsSuccessLike);
|
||||
|
||||
AsbStatusSummary badQuality = AsbResultMapper.ToStatusSummary(StatusBytes(
|
||||
0x05, 0x00, 0x00,
|
||||
0x06, 0x00, 0x00,
|
||||
0x07, 0x00, 0x00));
|
||||
AssertEqual("bad quality summary", AsbStatusQuality.Bad, badQuality.Quality);
|
||||
AssertEqual("bad quality raw", (ushort?)0x0000, badQuality.RawQuality);
|
||||
AssertEqual("bad quality success-like", false, badQuality.IsSuccessLike);
|
||||
|
||||
AsbStatusSummary timedOut = AsbResultMapper.ToStatusSummary(StatusBytes(
|
||||
0x05, 0x03, 0x00,
|
||||
0x06, 0x10, 0x00,
|
||||
0x07, 0x00, 0x00));
|
||||
AssertEqual("timed out category", AsbMxStatusCategory.CommunicationError, timedOut.Category);
|
||||
AssertEqual("timed out detail", AsbMxStatusDetail.RequestTimedOut, timedOut.Detail);
|
||||
AssertEqual("timed out error", AsbErrorCode.RequestTimedOut, timedOut.StatusError);
|
||||
AssertEqual("timed out quality", AsbStatusQuality.Bad, timedOut.Quality);
|
||||
|
||||
AsbStatusSummary noCommunication = AsbResultMapper.ToStatusSummary(StatusBytes(
|
||||
0x05, 0x03, 0x00,
|
||||
0x06, 0x11, 0x00,
|
||||
0x07, 0x00, 0x00));
|
||||
AssertEqual("no communication category", AsbMxStatusCategory.CommunicationError, noCommunication.Category);
|
||||
AssertEqual("no communication detail", AsbMxStatusDetail.PlatformCommunicationError, noCommunication.Detail);
|
||||
AssertEqual("no communication error", AsbErrorCode.BadNoCommunication, noCommunication.StatusError);
|
||||
|
||||
AsbStatusSummary accessDenied = AsbResultMapper.ToStatusSummary(StatusBytes(
|
||||
0x05, 0x06, 0x00,
|
||||
0x06, 0x21, 0x00,
|
||||
0x07, 0x00, 0x00));
|
||||
AssertEqual("access denied category", AsbMxStatusCategory.SecurityError, accessDenied.Category);
|
||||
AssertEqual("access denied detail", AsbMxStatusDetail.WriteAccessDenied, accessDenied.Detail);
|
||||
AssertEqual("access denied error", AsbErrorCode.WriteFailedAccessDenied, accessDenied.StatusError);
|
||||
AssertEqual("access denied success-like", false, accessDenied.IsSuccessLike);
|
||||
|
||||
AsbStatusSummary unknownStatus = AsbResultMapper.ToStatusSummary(StatusBytes(
|
||||
0x63, 0x77, 0x77,
|
||||
0x05, 0xEE, 0x02,
|
||||
0x06, 0xE7, 0x03,
|
||||
0x07, 0x80, 0x00));
|
||||
AssertEqual("unknown status category", AsbMxStatusCategory.Unknown, unknownStatus.Category);
|
||||
AssertEqual("unknown status raw category", (ushort?)750, unknownStatus.RawCategory);
|
||||
AssertEqual("unknown status detail", AsbMxStatusDetail.Unknown, unknownStatus.Detail);
|
||||
AssertEqual("unknown status raw detail", (ushort?)999, unknownStatus.RawDetail);
|
||||
AssertEqual("unknown status quality", AsbStatusQuality.Unknown, unknownStatus.Quality);
|
||||
AssertEqual("unknown status raw quality", (ushort?)0x0080, unknownStatus.RawQuality);
|
||||
AssertEqual("unknown status raw type", (AsbStatusElementType)99, unknownStatus.Elements[0].Type);
|
||||
AssertEqual("unknown status raw value", (ushort)0x7777, unknownStatus.Elements[0].Value);
|
||||
}
|
||||
|
||||
static void RunWriteCompletionOptionsTests()
|
||||
{
|
||||
AssertNotNull("asb wait write completion overload", typeof(MxAsbDataClient).GetMethod(
|
||||
nameof(MxAsbDataClient.WaitForWriteComplete),
|
||||
[typeof(uint), typeof(AsbWriteCompletionOptions)]));
|
||||
AssertNotNull("asb wait write completion readback overload", typeof(MxAsbDataClient).GetMethod(
|
||||
nameof(MxAsbDataClient.WaitForWriteCompleteAndRead),
|
||||
[typeof(string), typeof(uint), typeof(AsbWriteCompletionOptions)]));
|
||||
|
||||
AssertEqual("write completion default timeout", TimeSpan.FromSeconds(5), AsbWriteCompletionOptions.Default.Timeout);
|
||||
AssertEqual("write completion default poll", TimeSpan.FromMilliseconds(250), AsbWriteCompletionOptions.Default.PollInterval);
|
||||
AssertEqual("write completion default readback", TimeSpan.Zero, AsbWriteCompletionOptions.Default.ReadbackDelay);
|
||||
AssertEqual("write completion default cancellation", false, AsbWriteCompletionOptions.Default.CancellationToken.IsCancellationRequested);
|
||||
AsbWriteCompletionOptions.Default.ValidateReadback();
|
||||
AssertNotNull("write completion cancellation option", typeof(AsbWriteCompletionOptions).GetProperty(nameof(AsbWriteCompletionOptions.CancellationToken)));
|
||||
AssertEqual(
|
||||
"write completion canceled token",
|
||||
true,
|
||||
new AsbWriteCompletionOptions { CancellationToken = new CancellationToken(canceled: true) }.CancellationToken.IsCancellationRequested);
|
||||
new AsbWriteCompletionOptions { Timeout = TimeSpan.Zero, PollInterval = TimeSpan.FromMilliseconds(1) }.ValidatePolling();
|
||||
AssertThrows<ArgumentOutOfRangeException>(
|
||||
"write completion timeout",
|
||||
() => new AsbWriteCompletionOptions { Timeout = TimeSpan.FromMilliseconds(-1) }.ValidatePolling());
|
||||
AssertThrows<ArgumentOutOfRangeException>(
|
||||
"write completion poll interval",
|
||||
() => new AsbWriteCompletionOptions { PollInterval = TimeSpan.Zero }.ValidatePolling());
|
||||
AssertThrows<ArgumentOutOfRangeException>(
|
||||
"write completion readback delay",
|
||||
() => new AsbWriteCompletionOptions { ReadbackDelay = TimeSpan.FromMilliseconds(-1) }.ValidateReadback());
|
||||
|
||||
ItemWriteComplete matchingComplete = new()
|
||||
{
|
||||
WriteHandle = 0xA5B21001,
|
||||
Status =
|
||||
[
|
||||
new ItemStatus
|
||||
{
|
||||
Item = new ItemIdentity { Name = "Test.Tag", Id = 12, IdSpecified = true },
|
||||
ErrorCode = 0,
|
||||
Status = new AsbStatus { Count = 0, Payload = [] },
|
||||
},
|
||||
],
|
||||
};
|
||||
PublishWriteCompleteResponse response = new()
|
||||
{
|
||||
Result = new ArchestrAResult { ErrorCode = 32 },
|
||||
CompleteWrites = [matchingComplete],
|
||||
};
|
||||
AsbWriteCompletionResult completion = new(
|
||||
0xA5B21001,
|
||||
Completed: true,
|
||||
TimedOut: false,
|
||||
TimeSpan.FromMilliseconds(20),
|
||||
PollCount: 2,
|
||||
[response],
|
||||
[matchingComplete],
|
||||
matchingComplete);
|
||||
AssertEqual("write completion result handle", (uint)0xA5B21001, completion.WriteHandle);
|
||||
AssertEqual("write completion result completed", true, completion.Completed);
|
||||
AssertEqual("write completion result timed out", false, completion.TimedOut);
|
||||
AssertEqual("write completion result polls", 2, completion.PollCount);
|
||||
AssertEqual("write completion result responses", 1, completion.Responses.Count);
|
||||
AssertEqual("write completion result raw", 1, completion.CompleteWrites.Count);
|
||||
AssertEqual("write completion result match", (uint)0xA5B21001, completion.MatchingComplete?.WriteHandle);
|
||||
|
||||
AsbWriteCompletionReadbackResult readback = new(completion, Readback: null);
|
||||
AssertEqual("write completion readback completion", completion, readback.Completion);
|
||||
AssertEqual("write completion readback null", null, readback.Readback);
|
||||
}
|
||||
|
||||
static void RunSubscriptionOptionsTests()
|
||||
{
|
||||
AssertNotNull("asb create subscription options overload", typeof(MxAsbDataClient).GetMethod(
|
||||
nameof(MxAsbDataClient.CreateSubscription),
|
||||
[typeof(AsbSubscriptionOptions)]));
|
||||
|
||||
AssertEqual("subscription default queue", 128L, AsbSubscriptionOptions.Default.MaxQueueSize);
|
||||
AssertEqual("subscription default sample", (ulong)1000, AsbSubscriptionOptions.Default.SampleInterval);
|
||||
AsbSubscriptionOptions.Default.Validate();
|
||||
AsbSubscriptionOptions subscription = new() { MaxQueueSize = 128, SampleInterval = 1000 };
|
||||
subscription.Validate();
|
||||
AssertEqual("subscription queue", 128L, subscription.MaxQueueSize);
|
||||
AssertEqual("subscription sample", (ulong)1000, subscription.SampleInterval);
|
||||
AssertThrows<ArgumentOutOfRangeException>(
|
||||
"subscription max queue size",
|
||||
() => new AsbSubscriptionOptions { MaxQueueSize = 0 }.Validate());
|
||||
|
||||
AssertEqual("monitored item default sample", (ulong)1000, AsbMonitoredItemOptions.Default.SampleInterval);
|
||||
AssertEqual("monitored item default active", true, AsbMonitoredItemOptions.Default.Active);
|
||||
AssertEqual("monitored item default buffered", false, AsbMonitoredItemOptions.Default.Buffered);
|
||||
AsbMonitoredItemOptions monitored = new()
|
||||
{
|
||||
SampleInterval = 250,
|
||||
Active = false,
|
||||
Buffered = true,
|
||||
};
|
||||
AssertEqual("monitored item sample", (ulong)250, monitored.SampleInterval);
|
||||
AssertEqual("monitored item active", false, monitored.Active);
|
||||
AssertEqual("monitored item buffered", true, monitored.Buffered);
|
||||
}
|
||||
|
||||
static AsbStatus StatusBytes(params byte[] payload)
|
||||
{
|
||||
return new AsbStatus { Count = checked((sbyte)payload.Length), Payload = payload };
|
||||
}
|
||||
|
||||
static void RunReconnectSurfaceTests()
|
||||
{
|
||||
AssertNotNull("asb reconnect method", typeof(MxAsbDataClient).GetMethod(nameof(MxAsbDataClient.Reconnect)));
|
||||
AssertNotNull("asb channel state property", typeof(MxAsbDataClient).GetProperty(nameof(MxAsbDataClient.ChannelState)));
|
||||
AssertNotNull("asb disposed property", typeof(MxAsbDataClient).GetProperty(nameof(MxAsbDataClient.IsDisposed)));
|
||||
|
||||
AsbReconnectOptions.Default.Validate();
|
||||
AssertThrows<ArgumentOutOfRangeException>(
|
||||
"reconnect attempts",
|
||||
() => new AsbReconnectOptions { MaxAttempts = 0 }.Validate());
|
||||
AssertThrows<ArgumentOutOfRangeException>(
|
||||
"reconnect delay",
|
||||
() => new AsbReconnectOptions { Delay = TimeSpan.FromMilliseconds(-1) }.Validate());
|
||||
AssertThrows<ArgumentNullException>(
|
||||
"reconnect cleanup options",
|
||||
() => new AsbReconnectOptions { CleanupOptions = null! }.Validate());
|
||||
AssertThrows<ArgumentOutOfRangeException>(
|
||||
"reconnect cleanup disconnect timeout",
|
||||
() => new AsbReconnectOptions
|
||||
{
|
||||
CleanupOptions = new AsbClientCleanupOptions { DisconnectTimeout = TimeSpan.FromMilliseconds(-1) },
|
||||
}.Validate());
|
||||
|
||||
InvalidOperationException failure = new("failed");
|
||||
AsbReconnectResult result = new(
|
||||
Succeeded: false,
|
||||
Client: null,
|
||||
CleanupResult: null,
|
||||
[new AsbReconnectAttempt(1, Succeeded: false, failure)]);
|
||||
AssertEqual("reconnect last exception", failure, result.LastException);
|
||||
}
|
||||
|
||||
static void RunCleanupOptionsTests()
|
||||
{
|
||||
AssertNotNull("asb cleanup overload", typeof(MxAsbDataClient).GetMethod(nameof(MxAsbDataClient.Cleanup), [typeof(AsbClientCleanupOptions)]));
|
||||
|
||||
AsbClientCleanupOptions.Default.Validate();
|
||||
AssertThrows<ArgumentOutOfRangeException>(
|
||||
"cleanup disconnect timeout",
|
||||
() => new AsbClientCleanupOptions { DisconnectTimeout = TimeSpan.FromMilliseconds(-1) }.Validate());
|
||||
AssertThrows<ArgumentOutOfRangeException>(
|
||||
"cleanup close timeout",
|
||||
() => new AsbClientCleanupOptions { CloseTimeout = TimeSpan.FromMilliseconds(-1) }.Validate());
|
||||
AssertEqual(
|
||||
"cleanup cancellation token",
|
||||
true,
|
||||
new AsbClientCleanupOptions { CancellationToken = new CancellationToken(canceled: true) }.CancellationToken.IsCancellationRequested);
|
||||
}
|
||||
|
||||
static void RunCommunicationCleanupTests()
|
||||
{
|
||||
FakeCommunicationObject closed = new(CommunicationState.Closed);
|
||||
CommunicationObjectCleanupResult closedResult = AsbCommunicationCleanup.CloseOrAbort(
|
||||
closed,
|
||||
"closed",
|
||||
TimeSpan.FromSeconds(1),
|
||||
trace: null);
|
||||
AssertEqual("closed cleanup close attempted", false, closedResult.CloseAttempted);
|
||||
AssertEqual("closed cleanup closed", true, closedResult.Closed);
|
||||
AssertEqual("closed cleanup abort attempted", false, closedResult.AbortAttempted);
|
||||
AssertEqual("closed cleanup succeeded", true, closedResult.Succeeded);
|
||||
|
||||
FakeCommunicationObject faulted = new(CommunicationState.Faulted);
|
||||
CommunicationObjectCleanupResult faultedResult = AsbCommunicationCleanup.CloseOrAbort(
|
||||
faulted,
|
||||
"faulted",
|
||||
TimeSpan.FromSeconds(1),
|
||||
trace: null);
|
||||
AssertEqual("faulted cleanup close attempted", false, faultedResult.CloseAttempted);
|
||||
AssertEqual("faulted cleanup abort attempted", true, faultedResult.AbortAttempted);
|
||||
AssertEqual("faulted cleanup aborted", true, faultedResult.Aborted);
|
||||
AssertEqual("faulted cleanup succeeded", true, faultedResult.Succeeded);
|
||||
|
||||
FakeCommunicationObject closeFailure = new(CommunicationState.Opened)
|
||||
{
|
||||
ThrowOnClose = true,
|
||||
};
|
||||
CommunicationObjectCleanupResult closeFailureResult = AsbCommunicationCleanup.CloseOrAbort(
|
||||
closeFailure,
|
||||
"closeFailure",
|
||||
TimeSpan.FromMilliseconds(25),
|
||||
trace: null);
|
||||
AssertEqual("close failure timeout", TimeSpan.FromMilliseconds(25), closeFailure.CloseTimeout);
|
||||
AssertEqual("close failure close attempted", true, closeFailureResult.CloseAttempted);
|
||||
AssertNotNull("close failure captured", closeFailureResult.CloseFailure);
|
||||
AssertEqual("close failure abort attempted", true, closeFailureResult.AbortAttempted);
|
||||
AssertEqual("close failure aborted", true, closeFailureResult.Aborted);
|
||||
AssertEqual("close failure requires fallback", true, closeFailureResult.Succeeded is false);
|
||||
|
||||
FakeCommunicationObject abortFailure = new(CommunicationState.Faulted)
|
||||
{
|
||||
ThrowOnAbort = true,
|
||||
};
|
||||
CommunicationObjectCleanupResult abortFailureResult = AsbCommunicationCleanup.CloseOrAbort(
|
||||
abortFailure,
|
||||
"abortFailure",
|
||||
TimeSpan.FromSeconds(1),
|
||||
trace: null);
|
||||
AssertEqual("abort failure abort attempted", true, abortFailureResult.AbortAttempted);
|
||||
AssertEqual("abort failure aborted", false, abortFailureResult.Aborted);
|
||||
AssertNotNull("abort failure captured", abortFailureResult.AbortFailure);
|
||||
AssertEqual("abort failure succeeded", false, abortFailureResult.Succeeded);
|
||||
|
||||
FakeCommunicationObject canceledOpen = new(CommunicationState.Opened);
|
||||
CommunicationObjectCleanupResult canceledOpenResult = AsbCommunicationCleanup.AbortOnly(
|
||||
canceledOpen,
|
||||
"canceledOpen",
|
||||
trace: null);
|
||||
AssertEqual("canceled open close attempted", false, canceledOpenResult.CloseAttempted);
|
||||
AssertEqual("canceled open abort attempted", true, canceledOpenResult.AbortAttempted);
|
||||
AssertEqual("canceled open aborted", true, canceledOpenResult.Aborted);
|
||||
AssertEqual("canceled open closed", true, canceledOpenResult.Closed);
|
||||
|
||||
FakeCommunicationObject canceledClosed = new(CommunicationState.Closed);
|
||||
CommunicationObjectCleanupResult canceledClosedResult = AsbCommunicationCleanup.AbortOnly(
|
||||
canceledClosed,
|
||||
"canceledClosed",
|
||||
trace: null);
|
||||
AssertEqual("canceled closed close attempted", false, canceledClosedResult.CloseAttempted);
|
||||
AssertEqual("canceled closed abort attempted", false, canceledClosedResult.AbortAttempted);
|
||||
AssertEqual("canceled closed closed", true, canceledClosedResult.Closed);
|
||||
}
|
||||
|
||||
sealed class FakeCommunicationObject(CommunicationState initialState) : ICommunicationObject
|
||||
{
|
||||
public bool ThrowOnClose { get; init; }
|
||||
|
||||
public bool ThrowOnAbort { get; init; }
|
||||
|
||||
public TimeSpan? CloseTimeout { get; private set; }
|
||||
|
||||
public CommunicationState State { get; private set; } = initialState;
|
||||
|
||||
public event EventHandler? Closed;
|
||||
public event EventHandler? Closing;
|
||||
public event EventHandler? Faulted;
|
||||
public event EventHandler? Opened;
|
||||
public event EventHandler? Opening;
|
||||
|
||||
public void Abort()
|
||||
{
|
||||
if (ThrowOnAbort)
|
||||
{
|
||||
throw new InvalidOperationException("abort failed");
|
||||
}
|
||||
|
||||
State = CommunicationState.Closed;
|
||||
Closed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
public IAsyncResult BeginClose(AsyncCallback? callback, object? state)
|
||||
{
|
||||
Close();
|
||||
Task task = Task.CompletedTask;
|
||||
callback?.Invoke(task);
|
||||
return task;
|
||||
}
|
||||
|
||||
public IAsyncResult BeginClose(TimeSpan timeout, AsyncCallback? callback, object? state)
|
||||
{
|
||||
Close(timeout);
|
||||
Task task = Task.CompletedTask;
|
||||
callback?.Invoke(task);
|
||||
return task;
|
||||
}
|
||||
|
||||
public IAsyncResult BeginOpen(AsyncCallback? callback, object? state)
|
||||
{
|
||||
Open();
|
||||
Task task = Task.CompletedTask;
|
||||
callback?.Invoke(task);
|
||||
return task;
|
||||
}
|
||||
|
||||
public IAsyncResult BeginOpen(TimeSpan timeout, AsyncCallback? callback, object? state)
|
||||
{
|
||||
Open(timeout);
|
||||
Task task = Task.CompletedTask;
|
||||
callback?.Invoke(task);
|
||||
return task;
|
||||
}
|
||||
|
||||
public void Close()
|
||||
{
|
||||
Close(TimeSpan.Zero);
|
||||
}
|
||||
|
||||
public void Close(TimeSpan timeout)
|
||||
{
|
||||
CloseTimeout = timeout;
|
||||
if (ThrowOnClose)
|
||||
{
|
||||
throw new InvalidOperationException("close failed");
|
||||
}
|
||||
|
||||
Closing?.Invoke(this, EventArgs.Empty);
|
||||
State = CommunicationState.Closed;
|
||||
Closed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
public void EndClose(IAsyncResult result)
|
||||
{
|
||||
}
|
||||
|
||||
public void EndOpen(IAsyncResult result)
|
||||
{
|
||||
}
|
||||
|
||||
public void Open()
|
||||
{
|
||||
Open(TimeSpan.Zero);
|
||||
}
|
||||
|
||||
public void Open(TimeSpan timeout)
|
||||
{
|
||||
Opening?.Invoke(this, EventArgs.Empty);
|
||||
State = CommunicationState.Opened;
|
||||
Opened?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
public void RaiseFaulted()
|
||||
{
|
||||
State = CommunicationState.Faulted;
|
||||
Faulted?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
namespace MxAsbClient;
|
||||
|
||||
public sealed record AsbClientCleanupOptions
|
||||
{
|
||||
public static AsbClientCleanupOptions Default { get; } = new();
|
||||
|
||||
public TimeSpan DisconnectTimeout { get; init; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
public TimeSpan CloseTimeout { get; init; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
public CancellationToken CancellationToken { get; init; } = CancellationToken.None;
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (DisconnectTimeout < TimeSpan.Zero)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(DisconnectTimeout), "Disconnect timeout cannot be negative.");
|
||||
}
|
||||
|
||||
if (CloseTimeout < TimeSpan.Zero)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(CloseTimeout), "Close timeout cannot be negative.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record AsbClientCleanupResult(
|
||||
bool DisconnectAttempted,
|
||||
bool DisconnectSent,
|
||||
string? DisconnectFailure,
|
||||
CommunicationObjectCleanupResult Channel,
|
||||
CommunicationObjectCleanupResult Factory)
|
||||
{
|
||||
public bool Completed => Channel.Completed && Factory.Completed;
|
||||
public bool Succeeded => DisconnectFailure is null && Channel.Succeeded && Factory.Succeeded;
|
||||
public bool UsedAbortFallback => Channel.AbortAttempted || Factory.AbortAttempted;
|
||||
public bool RequiresNewConnection => UsedAbortFallback || DisconnectFailure is not null;
|
||||
}
|
||||
|
||||
public sealed record CommunicationObjectCleanupResult(
|
||||
string Name,
|
||||
string InitialState,
|
||||
string FinalState,
|
||||
bool CloseAttempted,
|
||||
bool Closed,
|
||||
string? CloseFailure,
|
||||
bool AbortAttempted,
|
||||
bool Aborted,
|
||||
string? AbortFailure)
|
||||
{
|
||||
public bool Completed => Closed || Aborted;
|
||||
public bool Succeeded => CloseFailure is null && AbortFailure is null && Completed;
|
||||
public bool ClosedGracefully => Closed && CloseFailure is null;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using System.ServiceModel;
|
||||
|
||||
namespace MxAsbClient;
|
||||
|
||||
internal static class AsbCommunicationCleanup
|
||||
{
|
||||
public static CommunicationObjectCleanupResult AbortOnly(
|
||||
ICommunicationObject communicationObject,
|
||||
string name,
|
||||
Action<string>? trace)
|
||||
{
|
||||
CommunicationState initialState = communicationObject.State;
|
||||
bool abortAttempted = false;
|
||||
bool aborted = false;
|
||||
string? abortFailure = null;
|
||||
|
||||
if (initialState is not CommunicationState.Closed)
|
||||
{
|
||||
(abortAttempted, aborted, abortFailure) = AbortBestEffort(communicationObject, name, trace);
|
||||
}
|
||||
|
||||
return new CommunicationObjectCleanupResult(
|
||||
name,
|
||||
initialState.ToString(),
|
||||
communicationObject.State.ToString(),
|
||||
CloseAttempted: false,
|
||||
Closed: communicationObject.State is CommunicationState.Closed,
|
||||
CloseFailure: null,
|
||||
AbortAttempted: abortAttempted,
|
||||
Aborted: aborted,
|
||||
AbortFailure: abortFailure);
|
||||
}
|
||||
|
||||
public static CommunicationObjectCleanupResult CloseOrAbort(
|
||||
ICommunicationObject communicationObject,
|
||||
string name,
|
||||
TimeSpan closeTimeout,
|
||||
Action<string>? trace)
|
||||
{
|
||||
CommunicationState initialState = communicationObject.State;
|
||||
bool closeAttempted = false;
|
||||
bool closed = false;
|
||||
string? closeFailure = null;
|
||||
bool abortAttempted = false;
|
||||
bool aborted = false;
|
||||
string? abortFailure = null;
|
||||
|
||||
if (initialState is CommunicationState.Closed)
|
||||
{
|
||||
return new CommunicationObjectCleanupResult(
|
||||
name,
|
||||
initialState.ToString(),
|
||||
communicationObject.State.ToString(),
|
||||
CloseAttempted: false,
|
||||
Closed: true,
|
||||
CloseFailure: null,
|
||||
AbortAttempted: false,
|
||||
Aborted: false,
|
||||
AbortFailure: null);
|
||||
}
|
||||
|
||||
if (initialState is CommunicationState.Faulted)
|
||||
{
|
||||
(abortAttempted, aborted, abortFailure) = AbortBestEffort(communicationObject, name, trace);
|
||||
}
|
||||
else
|
||||
{
|
||||
closeAttempted = true;
|
||||
try
|
||||
{
|
||||
communicationObject.Close(closeTimeout);
|
||||
closed = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
closeFailure = FormatCleanupFailure(ex);
|
||||
trace?.Invoke($"asb.cleanup.{name}.close.failed={closeFailure}");
|
||||
(abortAttempted, aborted, abortFailure) = AbortBestEffort(communicationObject, name, trace);
|
||||
}
|
||||
}
|
||||
|
||||
return new CommunicationObjectCleanupResult(
|
||||
name,
|
||||
initialState.ToString(),
|
||||
communicationObject.State.ToString(),
|
||||
closeAttempted,
|
||||
closed,
|
||||
closeFailure,
|
||||
abortAttempted,
|
||||
aborted,
|
||||
abortFailure);
|
||||
}
|
||||
|
||||
private static (bool Attempted, bool Aborted, string? Failure) AbortBestEffort(
|
||||
ICommunicationObject communicationObject,
|
||||
string name,
|
||||
Action<string>? trace)
|
||||
{
|
||||
try
|
||||
{
|
||||
communicationObject.Abort();
|
||||
return (Attempted: true, Aborted: true, Failure: null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
string failure = FormatCleanupFailure(ex);
|
||||
trace?.Invoke($"asb.cleanup.{name}.abort.failed={failure}");
|
||||
return (Attempted: true, Aborted: false, Failure: failure);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string FormatCleanupFailure(Exception ex)
|
||||
{
|
||||
return $"{ex.GetType().Name}: {ex.Message}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace MxAsbClient;
|
||||
|
||||
public sealed record AsbConnectionOptions
|
||||
{
|
||||
public string Endpoint { get; init; } = string.Empty;
|
||||
|
||||
public string? SolutionName { get; init; }
|
||||
|
||||
public Action<string>? Trace { get; init; }
|
||||
|
||||
public bool DumpMessages { get; init; }
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Endpoint))
|
||||
{
|
||||
throw new ArgumentException("ASB endpoint is required.", nameof(Endpoint));
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,75 @@
|
||||
using System.ServiceModel;
|
||||
using System.ServiceModel.Channels;
|
||||
using System.ServiceModel.Description;
|
||||
using System.ServiceModel.Dispatcher;
|
||||
|
||||
namespace MxAsbClient;
|
||||
|
||||
internal sealed class AsbMessageDumpBehavior : IEndpointBehavior
|
||||
{
|
||||
private readonly Action<string> trace;
|
||||
|
||||
public AsbMessageDumpBehavior(Action<string> trace)
|
||||
{
|
||||
this.trace = trace;
|
||||
}
|
||||
|
||||
public void AddBindingParameters(ServiceEndpoint endpoint, BindingParameterCollection bindingParameters)
|
||||
{
|
||||
}
|
||||
|
||||
public void ApplyClientBehavior(ServiceEndpoint endpoint, ClientRuntime clientRuntime)
|
||||
{
|
||||
clientRuntime.ClientMessageInspectors.Add(new Inspector(trace));
|
||||
}
|
||||
|
||||
public void ApplyDispatchBehavior(ServiceEndpoint endpoint, EndpointDispatcher endpointDispatcher)
|
||||
{
|
||||
}
|
||||
|
||||
public void Validate(ServiceEndpoint endpoint)
|
||||
{
|
||||
}
|
||||
|
||||
private sealed class Inspector : IClientMessageInspector
|
||||
{
|
||||
private readonly Action<string> trace;
|
||||
|
||||
public Inspector(Action<string> trace)
|
||||
{
|
||||
this.trace = trace;
|
||||
}
|
||||
|
||||
public object? BeforeSendRequest(ref Message request, IClientChannel channel)
|
||||
{
|
||||
MessageBuffer buffer = request.CreateBufferedCopy(int.MaxValue);
|
||||
Message copy = buffer.CreateMessage();
|
||||
request = buffer.CreateMessage();
|
||||
string action = copy.Headers.Action ?? string.Empty;
|
||||
if (action.Contains("registerItems", StringComparison.OrdinalIgnoreCase)
|
||||
|| action.Contains("writeIn", StringComparison.OrdinalIgnoreCase)
|
||||
|| action.Contains("readIn", StringComparison.OrdinalIgnoreCase)
|
||||
|| action.Contains("publishWriteComplete", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
trace("asb.request=" + copy);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public void AfterReceiveReply(ref Message reply, object? correlationState)
|
||||
{
|
||||
MessageBuffer buffer = reply.CreateBufferedCopy(int.MaxValue);
|
||||
Message copy = buffer.CreateMessage();
|
||||
reply = buffer.CreateMessage();
|
||||
string xml = copy.ToString();
|
||||
if (xml.Contains("RegisterItemsResponse", StringComparison.OrdinalIgnoreCase)
|
||||
|| xml.Contains("ReadResponse", StringComparison.OrdinalIgnoreCase)
|
||||
|| xml.Contains("WriteBasicResponse", StringComparison.OrdinalIgnoreCase)
|
||||
|| xml.Contains("PublishWriteCompleteResponse", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
trace("asb.reply=" + xml);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace MxAsbClient;
|
||||
|
||||
internal static class AsbPayloadDebug
|
||||
{
|
||||
public static byte[] SerializeItemsForDebug(string tag)
|
||||
{
|
||||
ItemIdentity[] items =
|
||||
[
|
||||
new ItemIdentity
|
||||
{
|
||||
Type = (ushort)ItemIdentityType.Name,
|
||||
ReferenceType = (ushort)ItemReferenceType.Absolute,
|
||||
Name = tag,
|
||||
ContextName = string.Empty,
|
||||
},
|
||||
];
|
||||
|
||||
using MemoryStream stream = new();
|
||||
BinaryWriter writer = new(stream);
|
||||
((IAsbCustomSerializableType)new ItemIdentity()).WriteArrayToStream(items, ref writer);
|
||||
return stream.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
namespace MxAsbClient;
|
||||
|
||||
public enum AsbStatusElementType : ushort
|
||||
{
|
||||
OpcDaStatus = 1,
|
||||
OpcUaStatus = 2,
|
||||
OpcUaVendorStatus = 3,
|
||||
ScadaStatus = 4,
|
||||
MxStatusCategory = 5,
|
||||
MxStatusDetail = 6,
|
||||
MxQuality = 7,
|
||||
Reserved1Status = 125,
|
||||
Reserved2Status = 126,
|
||||
Reserved3Status = 127,
|
||||
}
|
||||
|
||||
public sealed record AsbStatusElement(AsbStatusElementType Type, ushort Value);
|
||||
|
||||
public sealed record AsbPublishedValue(
|
||||
ulong ItemId,
|
||||
string? ItemName,
|
||||
ushort VariantType,
|
||||
object? Value,
|
||||
string Preview,
|
||||
DateTime TimestampUtc,
|
||||
bool TimestampSpecified,
|
||||
ushort? Quality,
|
||||
IReadOnlyList<AsbStatusElement> Status,
|
||||
AsbStatus RawStatus,
|
||||
Variant UserData)
|
||||
{
|
||||
public AsbStatusSummary StatusSummary => AsbResultMapper.ToStatusSummary(Status);
|
||||
}
|
||||
|
||||
public sealed record AsbPublishResult(PublishResponse Response, IReadOnlyList<AsbPublishedValue> Values)
|
||||
{
|
||||
public AsbResultSummary Result => AsbResultMapper.ToSummary(Response.Result);
|
||||
|
||||
public bool HasValues => Values.Count > 0;
|
||||
}
|
||||
|
||||
public static class AsbPublishMapper
|
||||
{
|
||||
public static IReadOnlyList<AsbPublishedValue> ToPublishedValues(
|
||||
PublishResponse response,
|
||||
IReadOnlyDictionary<ulong, string>? itemNamesById = null)
|
||||
{
|
||||
if (response.Values is null || response.Values.Length == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
AsbPublishedValue[] values = new AsbPublishedValue[response.Values.Length];
|
||||
for (int i = 0; i < response.Values.Length; i++)
|
||||
{
|
||||
values[i] = ToPublishedValue(response.Values[i], itemNamesById);
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
public static AsbPublishedValue ToPublishedValue(
|
||||
MonitoredItemValue itemValue,
|
||||
IReadOnlyDictionary<ulong, string>? itemNamesById = null)
|
||||
{
|
||||
RuntimeValue runtime = itemValue.Value;
|
||||
IReadOnlyList<AsbStatusElement> status = DecodeStatus(runtime.Status);
|
||||
ushort? quality = status.FirstOrDefault(item => item.Type == AsbStatusElementType.MxQuality)?.Value;
|
||||
string? itemName = string.IsNullOrWhiteSpace(itemValue.Item.Name)
|
||||
? ResolveItemName(itemValue.Item.Id, itemNamesById)
|
||||
: itemValue.Item.Name;
|
||||
|
||||
return new AsbPublishedValue(
|
||||
itemValue.Item.Id,
|
||||
itemName,
|
||||
runtime.Value.Type,
|
||||
MxAsbDataClient.DecodeVariant(runtime.Value),
|
||||
MxAsbDataClient.FormatVariant(runtime.Value),
|
||||
runtime.Timestamp.Kind == DateTimeKind.Utc ? runtime.Timestamp : runtime.Timestamp.ToUniversalTime(),
|
||||
runtime.TimestampSpecified,
|
||||
quality,
|
||||
status,
|
||||
runtime.Status,
|
||||
itemValue.UserData);
|
||||
}
|
||||
|
||||
public static IReadOnlyList<AsbStatusElement> DecodeStatus(AsbStatus status)
|
||||
{
|
||||
byte[] payload = status.Payload ?? [];
|
||||
int length = status.Count > 0 ? Math.Min(status.Count, payload.Length) : payload.Length;
|
||||
if (length == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
List<AsbStatusElement> elements = [];
|
||||
int offset = 0;
|
||||
while (offset < length)
|
||||
{
|
||||
byte marker = payload[offset++];
|
||||
AsbStatusElementType type = (AsbStatusElementType)(marker & 0x7F);
|
||||
if ((marker & 0x80) != 0)
|
||||
{
|
||||
elements.Add(new AsbStatusElement(type, 0));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (offset + sizeof(ushort) > length)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
ushort value = BitConverter.ToUInt16(payload, offset);
|
||||
offset += sizeof(ushort);
|
||||
elements.Add(new AsbStatusElement(type, value));
|
||||
}
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
public static IReadOnlyDictionary<ulong, string> CreateItemNameMap(params ItemStatus[]?[] statusGroups)
|
||||
{
|
||||
Dictionary<ulong, string> names = [];
|
||||
foreach (ItemStatus[]? statuses in statusGroups)
|
||||
{
|
||||
if (statuses is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (ItemStatus status in statuses)
|
||||
{
|
||||
if (status.Item.IdSpecified && !string.IsNullOrWhiteSpace(status.Item.Name))
|
||||
{
|
||||
names[status.Item.Id] = status.Item.Name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return names;
|
||||
}
|
||||
|
||||
private static string? ResolveItemName(ulong id, IReadOnlyDictionary<ulong, string>? itemNamesById)
|
||||
{
|
||||
return itemNamesById is not null && itemNamesById.TryGetValue(id, out string? name) ? name : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
namespace MxAsbClient;
|
||||
|
||||
public sealed record AsbReconnectOptions
|
||||
{
|
||||
public static AsbReconnectOptions Default { get; } = new();
|
||||
|
||||
public int MaxAttempts { get; init; } = 3;
|
||||
|
||||
public TimeSpan Delay { get; init; } = TimeSpan.FromMilliseconds(500);
|
||||
|
||||
public bool CleanupCurrentConnection { get; init; } = true;
|
||||
|
||||
public AsbClientCleanupOptions CleanupOptions { get; init; } = AsbClientCleanupOptions.Default;
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (MaxAttempts < 1)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(MaxAttempts), "Reconnect attempts must be at least one.");
|
||||
}
|
||||
|
||||
if (Delay < TimeSpan.Zero)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(Delay), "Reconnect delay cannot be negative.");
|
||||
}
|
||||
|
||||
ArgumentNullException.ThrowIfNull(CleanupOptions);
|
||||
CleanupOptions.Validate();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record AsbReconnectAttempt(
|
||||
int Attempt,
|
||||
bool Succeeded,
|
||||
Exception? Exception);
|
||||
|
||||
public sealed record AsbReconnectResult(
|
||||
bool Succeeded,
|
||||
MxAsbDataClient? Client,
|
||||
AsbClientCleanupResult? CleanupResult,
|
||||
IReadOnlyList<AsbReconnectAttempt> Attempts)
|
||||
{
|
||||
public Exception? LastException => Attempts.LastOrDefault(attempt => attempt.Exception is not null)?.Exception;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Win32;
|
||||
using System.Numerics;
|
||||
|
||||
namespace MxAsbClient;
|
||||
|
||||
internal static class AsbRegistry
|
||||
{
|
||||
private const string Entropy = "wonderware";
|
||||
|
||||
private static string RegistryPath => Environment.Is64BitProcess
|
||||
? @"SOFTWARE\Wow6432Node\ArchestrA\ArchestrAServices"
|
||||
: @"SOFTWARE\ArchestrA\ArchestrAServices";
|
||||
|
||||
public static string GetDefaultSolutionName()
|
||||
{
|
||||
return Registry.GetValue($@"HKEY_LOCAL_MACHINE\{RegistryPath}", "DefaultASBSolution", string.Empty)?.ToString() ?? string.Empty;
|
||||
}
|
||||
|
||||
public static string GetSolutionPassphrase(string? solutionName, Action<string>? trace = null)
|
||||
{
|
||||
trace?.Invoke("asb.stage=registry-solution");
|
||||
string effectiveSolution = string.IsNullOrWhiteSpace(solutionName) ? GetDefaultSolutionName() : solutionName!;
|
||||
if (string.IsNullOrWhiteSpace(effectiveSolution))
|
||||
{
|
||||
throw new InvalidOperationException("ASB default solution name was not found in the registry.");
|
||||
}
|
||||
|
||||
trace?.Invoke("asb.stage=registry-open-solution");
|
||||
using RegistryKey? key = Registry.LocalMachine.OpenSubKey($@"{RegistryPath}\{effectiveSolution}", writable: false);
|
||||
if (key?.GetValue("sharedsecret") is not byte[] protectedBytes)
|
||||
{
|
||||
throw new InvalidOperationException($"ASB sharedsecret was not found for solution '{effectiveSolution}'.");
|
||||
}
|
||||
|
||||
trace?.Invoke("asb.stage=registry-unprotect");
|
||||
byte[] clear = ProtectedData.Unprotect(protectedBytes, Encoding.Unicode.GetBytes(Entropy), DataProtectionScope.LocalMachine);
|
||||
trace?.Invoke("asb.stage=registry-passphrase-ready");
|
||||
return Encoding.Unicode.GetString(clear);
|
||||
}
|
||||
|
||||
public static AsbSolutionCryptoParameters GetCryptoParameters(string? solutionName)
|
||||
{
|
||||
string effectiveSolution = string.IsNullOrWhiteSpace(solutionName) ? GetDefaultSolutionName() : solutionName!;
|
||||
using RegistryKey? key = Registry.LocalMachine.OpenSubKey($@"{RegistryPath}\{effectiveSolution}", writable: false);
|
||||
if (key is null)
|
||||
{
|
||||
throw new InvalidOperationException($"ASB solution registry key was not found for '{effectiveSolution}'.");
|
||||
}
|
||||
|
||||
string primeText = key.GetValue("Prime")?.ToString() ?? AsbSolutionCryptoParameters.DefaultPrimeText;
|
||||
string generatorText = key.GetValue("Generator")?.ToString() ?? "22";
|
||||
string hashAlgorithm = key.GetValue("HashAlgorthim")?.ToString() ?? "MD5";
|
||||
int keySize = int.TryParse(key.GetValue("keySize")?.ToString(), out int parsedKeySize) ? parsedKeySize : 256;
|
||||
return new AsbSolutionCryptoParameters(
|
||||
BigInteger.Parse(primeText),
|
||||
BigInteger.Parse(generatorText),
|
||||
hashAlgorithm,
|
||||
keySize);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record AsbSolutionCryptoParameters(BigInteger Prime, BigInteger Generator, string HashAlgorithm, int KeySize)
|
||||
{
|
||||
public const string DefaultPrimeText = "179769313486231590770839156793787453197860296048756011706444423684197180216158519368947833795864925541502180565485980503646440548199239100050792877003355816639229553136239076508735759914822574862575007425302077447712589550957937778424442426617334727629299387668709205606050270810842907692932019128194";
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
namespace MxAsbClient;
|
||||
|
||||
public enum AsbErrorCode : ushort
|
||||
{
|
||||
Success = 0,
|
||||
InvalidConnectionId = 1,
|
||||
ApplicationAuthenticationError = 2,
|
||||
UserAuthenticationError = 3,
|
||||
UserAuthorizationError = 4,
|
||||
NotSupportedOperation = 5,
|
||||
MonitoredItemsNotFound = 6,
|
||||
InvalidSubscriptionId = 7,
|
||||
ItemAlreadyRegistered = 8,
|
||||
ItemAlreadyDeletedOrDoesNotExist = 9,
|
||||
InvalidMonitoredItems = 10,
|
||||
OperationFailed = 11,
|
||||
SpecificError = 12,
|
||||
BadNoCommunication = 13,
|
||||
BadNothingToDo = 14,
|
||||
BadTooManyOperations = 15,
|
||||
BadNodeIdInvalid = 16,
|
||||
BrowseFailed = 17,
|
||||
WriteFailedBadOutOfRange = 18,
|
||||
WriteFailedBadTypeMismatch = 19,
|
||||
WriteFailedBadDimensionMismatch = 20,
|
||||
WriteFailedAccessDenied = 21,
|
||||
WriteFailedSecuredWrite = 22,
|
||||
WriteFailedVerifiedWrite = 23,
|
||||
IndexOutOfRange = 24,
|
||||
RequestTimedOut = 25,
|
||||
DataTypeConversionNotSupported = 26,
|
||||
ItemCannotBeRegisteredNoName = 27,
|
||||
ItemCannotBeRegisteredNoId = 28,
|
||||
ItemAlreadyBeingMonitored = 29,
|
||||
SubscriptionIdAlreadyExist = 30,
|
||||
OperationWouldBlock = 31,
|
||||
PublishComplete = 32,
|
||||
WriteFailedUserNotHavingAccessRights = 33,
|
||||
WriteFailedVerifierNotHavingVerifyRights = 34,
|
||||
ObjectNotInitialized = 128,
|
||||
EndPointNotFound = 129,
|
||||
ConnectionClosed = 130,
|
||||
InvalidParameter = 131,
|
||||
MemoryAllocationError = 132,
|
||||
OperationNotComplete = 133,
|
||||
FileOperationFailed = 256,
|
||||
InvalidXmlFile = 272,
|
||||
RecordLookupError = 288,
|
||||
Unknown = ushort.MaxValue,
|
||||
}
|
||||
|
||||
public enum AsbStatusQuality
|
||||
{
|
||||
Unknown = 0,
|
||||
Bad = 1,
|
||||
Uncertain = 2,
|
||||
Good = 3,
|
||||
}
|
||||
|
||||
public enum AsbMxStatusCategory
|
||||
{
|
||||
Unknown = -1,
|
||||
Ok = 0,
|
||||
Pending = 1,
|
||||
Warning = 2,
|
||||
CommunicationError = 3,
|
||||
ConfigurationError = 4,
|
||||
OperationalError = 5,
|
||||
SecurityError = 6,
|
||||
SoftwareError = 7,
|
||||
OtherError = 8,
|
||||
}
|
||||
|
||||
public enum AsbMxStatusDetail
|
||||
{
|
||||
Unknown = -1,
|
||||
None = 0,
|
||||
RequestTimedOut = 16,
|
||||
PlatformCommunicationError = 17,
|
||||
WriteAccessDenied = 33,
|
||||
}
|
||||
|
||||
public sealed record AsbResultSummary(
|
||||
AsbErrorCode Error,
|
||||
int RawErrorCode,
|
||||
uint Status,
|
||||
uint SpecificErrorCode,
|
||||
bool IsSuccess,
|
||||
bool IsSuccessLike);
|
||||
|
||||
public sealed record AsbItemStatusSummary(
|
||||
string? ItemName,
|
||||
ulong ItemId,
|
||||
AsbErrorCode Error,
|
||||
ushort RawErrorCode,
|
||||
bool IsSuccess,
|
||||
IReadOnlyList<AsbStatusElement> Status)
|
||||
{
|
||||
public AsbStatusSummary StatusSummary { get; init; } = AsbResultMapper.ToStatusSummary(Status);
|
||||
}
|
||||
|
||||
public sealed record AsbStatusSummary(
|
||||
ushort? RawQuality,
|
||||
AsbStatusQuality Quality,
|
||||
ushort? RawCategory,
|
||||
AsbMxStatusCategory Category,
|
||||
ushort? RawDetail,
|
||||
AsbMxStatusDetail Detail,
|
||||
AsbErrorCode? StatusError,
|
||||
bool IsGoodQuality,
|
||||
bool IsSuccessLike,
|
||||
IReadOnlyList<AsbStatusElement> Elements);
|
||||
|
||||
public static class AsbResultMapper
|
||||
{
|
||||
public static AsbResultSummary ToSummary(ArchestrAResult result)
|
||||
{
|
||||
AsbErrorCode error = result.ErrorCode is >= 0 and <= ushort.MaxValue
|
||||
? ToErrorCode((ushort)result.ErrorCode)
|
||||
: AsbErrorCode.Unknown;
|
||||
|
||||
bool isSuccess = error == AsbErrorCode.Success || result.Success;
|
||||
return new AsbResultSummary(
|
||||
error,
|
||||
result.ErrorCode,
|
||||
result.Status,
|
||||
result.SpecificErrorCode,
|
||||
isSuccess,
|
||||
isSuccess || error == AsbErrorCode.PublishComplete);
|
||||
}
|
||||
|
||||
public static AsbItemStatusSummary ToItemSummary(ItemStatus status)
|
||||
{
|
||||
AsbErrorCode error = ToErrorCode(status.ErrorCode);
|
||||
IReadOnlyList<AsbStatusElement> elements = AsbPublishMapper.DecodeStatus(status.Status);
|
||||
return new AsbItemStatusSummary(
|
||||
status.Item.Name,
|
||||
status.Item.Id,
|
||||
error,
|
||||
status.ErrorCode,
|
||||
error == AsbErrorCode.Success,
|
||||
elements);
|
||||
}
|
||||
|
||||
public static IReadOnlyList<AsbItemStatusSummary> ToItemSummaries(ItemStatus[]? statuses)
|
||||
{
|
||||
if (statuses is null || statuses.Length == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
AsbItemStatusSummary[] summaries = new AsbItemStatusSummary[statuses.Length];
|
||||
for (int i = 0; i < statuses.Length; i++)
|
||||
{
|
||||
summaries[i] = ToItemSummary(statuses[i]);
|
||||
}
|
||||
|
||||
return summaries;
|
||||
}
|
||||
|
||||
public static AsbErrorCode ToErrorCode(ushort errorCode)
|
||||
{
|
||||
return Enum.IsDefined(typeof(AsbErrorCode), errorCode)
|
||||
? (AsbErrorCode)errorCode
|
||||
: AsbErrorCode.Unknown;
|
||||
}
|
||||
|
||||
public static AsbStatusSummary ToStatusSummary(AsbStatus status)
|
||||
{
|
||||
return ToStatusSummary(AsbPublishMapper.DecodeStatus(status));
|
||||
}
|
||||
|
||||
public static AsbStatusSummary ToStatusSummary(IReadOnlyList<AsbStatusElement> elements)
|
||||
{
|
||||
ushort? rawQuality = FirstValue(elements, AsbStatusElementType.MxQuality);
|
||||
ushort? rawCategory = FirstValue(elements, AsbStatusElementType.MxStatusCategory);
|
||||
ushort? rawDetail = FirstValue(elements, AsbStatusElementType.MxStatusDetail);
|
||||
|
||||
AsbStatusQuality quality = ToQuality(rawQuality);
|
||||
AsbMxStatusCategory category = ToMxStatusCategory(rawCategory);
|
||||
AsbMxStatusDetail detail = ToMxStatusDetail(rawDetail);
|
||||
AsbErrorCode? statusError = ToStatusError(category, detail);
|
||||
bool isGoodQuality = quality == AsbStatusQuality.Good;
|
||||
bool isSuccessLike = statusError == AsbErrorCode.Success
|
||||
&& (quality == AsbStatusQuality.Good || quality == AsbStatusQuality.Unknown);
|
||||
|
||||
return new AsbStatusSummary(
|
||||
rawQuality,
|
||||
quality,
|
||||
rawCategory,
|
||||
category,
|
||||
rawDetail,
|
||||
detail,
|
||||
statusError,
|
||||
isGoodQuality,
|
||||
isSuccessLike,
|
||||
elements);
|
||||
}
|
||||
|
||||
public static AsbStatusQuality ToQuality(ushort? quality)
|
||||
{
|
||||
if (quality is null)
|
||||
{
|
||||
return AsbStatusQuality.Unknown;
|
||||
}
|
||||
|
||||
return (quality.Value & 0x00C0) switch
|
||||
{
|
||||
0x00C0 => AsbStatusQuality.Good,
|
||||
0x0040 => AsbStatusQuality.Uncertain,
|
||||
0x0000 => AsbStatusQuality.Bad,
|
||||
_ => AsbStatusQuality.Unknown,
|
||||
};
|
||||
}
|
||||
|
||||
public static AsbMxStatusCategory ToMxStatusCategory(ushort? category)
|
||||
{
|
||||
return category switch
|
||||
{
|
||||
0 => AsbMxStatusCategory.Ok,
|
||||
1 => AsbMxStatusCategory.Pending,
|
||||
2 => AsbMxStatusCategory.Warning,
|
||||
3 => AsbMxStatusCategory.CommunicationError,
|
||||
4 => AsbMxStatusCategory.ConfigurationError,
|
||||
5 => AsbMxStatusCategory.OperationalError,
|
||||
6 => AsbMxStatusCategory.SecurityError,
|
||||
7 => AsbMxStatusCategory.SoftwareError,
|
||||
8 => AsbMxStatusCategory.OtherError,
|
||||
_ => AsbMxStatusCategory.Unknown,
|
||||
};
|
||||
}
|
||||
|
||||
public static AsbMxStatusDetail ToMxStatusDetail(ushort? detail)
|
||||
{
|
||||
return detail switch
|
||||
{
|
||||
0 => AsbMxStatusDetail.None,
|
||||
16 => AsbMxStatusDetail.RequestTimedOut,
|
||||
17 => AsbMxStatusDetail.PlatformCommunicationError,
|
||||
33 => AsbMxStatusDetail.WriteAccessDenied,
|
||||
_ => AsbMxStatusDetail.Unknown,
|
||||
};
|
||||
}
|
||||
|
||||
private static AsbErrorCode? ToStatusError(AsbMxStatusCategory category, AsbMxStatusDetail detail)
|
||||
{
|
||||
if (category == AsbMxStatusCategory.Ok && detail == AsbMxStatusDetail.None)
|
||||
{
|
||||
return AsbErrorCode.Success;
|
||||
}
|
||||
|
||||
return detail switch
|
||||
{
|
||||
AsbMxStatusDetail.RequestTimedOut => AsbErrorCode.RequestTimedOut,
|
||||
AsbMxStatusDetail.PlatformCommunicationError => AsbErrorCode.BadNoCommunication,
|
||||
AsbMxStatusDetail.WriteAccessDenied => AsbErrorCode.WriteFailedAccessDenied,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
private static ushort? FirstValue(IReadOnlyList<AsbStatusElement> elements, AsbStatusElementType type)
|
||||
{
|
||||
foreach (AsbStatusElement element in elements)
|
||||
{
|
||||
if (element.Type == type)
|
||||
{
|
||||
return element.Value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.Globalization;
|
||||
using System.Xml.Linq;
|
||||
using System.Xml.Serialization;
|
||||
|
||||
namespace MxAsbClient;
|
||||
|
||||
internal static class AsbSerialization
|
||||
{
|
||||
private static readonly Dictionary<Type, XmlSerializer> Serializers = [];
|
||||
private static readonly object LockObject = new();
|
||||
|
||||
public static string ToXml(this object value)
|
||||
{
|
||||
if (value is null)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
string text = string.Empty;
|
||||
using (StringWriter writer = new(CultureInfo.CurrentCulture))
|
||||
{
|
||||
lock (LockObject)
|
||||
{
|
||||
Type type = value.GetType();
|
||||
if (!Serializers.TryGetValue(type, out XmlSerializer? serializer))
|
||||
{
|
||||
serializer = new XmlSerializer(type, "urn:invensys.schemas");
|
||||
Serializers.Add(type, serializer);
|
||||
}
|
||||
|
||||
serializer.Serialize(writer, value);
|
||||
text = writer.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
using TextReader reader = new StringReader(text);
|
||||
XElement root = XDocument.Load(reader).Root ?? throw new InvalidOperationException("Serialized ASB message had no root element.");
|
||||
XAttribute? xsd = root.Attribute(XNamespace.Xmlns + "xsd");
|
||||
XAttribute? xsi = root.Attribute(XNamespace.Xmlns + "xsi");
|
||||
if (xsd is not null && xsi is not null)
|
||||
{
|
||||
root.ReplaceAttributes(xsi, xsd);
|
||||
}
|
||||
|
||||
using StringWriter normalized = new(CultureInfo.CurrentCulture);
|
||||
root.Save(normalized);
|
||||
return normalized.ToString() ?? string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace MxAsbClient;
|
||||
|
||||
public sealed record AsbSubscriptionOptions
|
||||
{
|
||||
public static AsbSubscriptionOptions Default { get; } = new();
|
||||
|
||||
public long MaxQueueSize { get; init; } = 128;
|
||||
|
||||
public ulong SampleInterval { get; init; } = 1000;
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (MaxQueueSize <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(MaxQueueSize), "ASB subscription max queue size must be greater than zero.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record AsbMonitoredItemOptions
|
||||
{
|
||||
public static AsbMonitoredItemOptions Default { get; } = new();
|
||||
|
||||
public ulong SampleInterval { get; init; } = 1000;
|
||||
|
||||
public bool Active { get; init; } = true;
|
||||
|
||||
public bool Buffered { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
using System.IO.Compression;
|
||||
using System.Numerics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace MxAsbClient;
|
||||
|
||||
internal sealed class AsbSystemAuthenticator
|
||||
{
|
||||
private static readonly byte[] PasswordSalt = Encoding.ASCII.GetBytes("ArchestrAService");
|
||||
private readonly BigInteger dhPrime;
|
||||
private readonly BigInteger dhGenerator;
|
||||
private readonly string hashAlgorithm;
|
||||
private readonly int keySize;
|
||||
private readonly byte[] solutionPassphrase;
|
||||
private readonly byte[] privateKey;
|
||||
private readonly byte[] localPublicKey;
|
||||
private byte[] remotePublicKey = [];
|
||||
private ulong nextMessageNumber = 1;
|
||||
|
||||
public AsbSystemAuthenticator(string passphrase, AsbSolutionCryptoParameters cryptoParameters, Action<string>? trace = null)
|
||||
{
|
||||
dhPrime = cryptoParameters.Prime;
|
||||
dhGenerator = cryptoParameters.Generator;
|
||||
hashAlgorithm = cryptoParameters.HashAlgorithm;
|
||||
keySize = cryptoParameters.KeySize;
|
||||
trace?.Invoke("asb.stage=authenticator-passphrase-bytes");
|
||||
solutionPassphrase = Encoding.UTF8.GetBytes(passphrase);
|
||||
trace?.Invoke("asb.stage=authenticator-create-private");
|
||||
BigInteger privateKeyValue = CreatePrivateKey();
|
||||
trace?.Invoke("asb.stage=authenticator-private-ready");
|
||||
privateKey = privateKeyValue.ToByteArray();
|
||||
trace?.Invoke("asb.stage=authenticator-modpow");
|
||||
localPublicKey = BigInteger.ModPow(dhGenerator, privateKeyValue, dhPrime).ToByteArray();
|
||||
trace?.Invoke("asb.stage=authenticator-public-ready");
|
||||
ConnectionId = Guid.NewGuid();
|
||||
}
|
||||
|
||||
public Guid ConnectionId { get; }
|
||||
|
||||
public byte[] LocalPublicKey => localPublicKey;
|
||||
|
||||
public bool UseApolloSigning { get; private set; }
|
||||
|
||||
public void AcceptConnectResponse(ConnectResponse response)
|
||||
{
|
||||
remotePublicKey = response.ServicePublicKey?.Data ?? throw new InvalidOperationException("ASB connect response did not contain a service public key.");
|
||||
UseApolloSigning = response.ConnectionLifetime?.Contains(":V2", StringComparison.OrdinalIgnoreCase) == true;
|
||||
}
|
||||
|
||||
public AuthenticationData CreateAuthenticationData()
|
||||
{
|
||||
byte[] clear = [.. localPublicKey, .. remotePublicKey];
|
||||
byte[] encrypted = Encrypt(clear, out byte[] iv);
|
||||
return new AuthenticationData
|
||||
{
|
||||
Data = encrypted,
|
||||
InitializationVector = iv,
|
||||
};
|
||||
}
|
||||
|
||||
public void Sign(ConnectedRequest request, bool forceHmac = false)
|
||||
{
|
||||
ConnectionValidator validator = new()
|
||||
{
|
||||
ConnectionId = ConnectionId,
|
||||
MessageNumber = nextMessageNumber++,
|
||||
MessageAuthenticationCode = [],
|
||||
SignatureInitializationVector = [],
|
||||
};
|
||||
|
||||
request.ConnectionValidator = validator;
|
||||
using HMAC? hmac = CreateHmac(forceHmac);
|
||||
if (hmac is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
byte[] hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(request.ToXml()));
|
||||
validator.MessageAuthenticationCode = Encrypt(hash, out byte[] iv);
|
||||
validator.SignatureInitializationVector = iv;
|
||||
}
|
||||
|
||||
private HMAC? CreateHmac(bool forceHmac)
|
||||
{
|
||||
return hashAlgorithm.ToLowerInvariant() switch
|
||||
{
|
||||
"md5" => new HMACMD5(CryptoKey),
|
||||
"sha1" => new HMACSHA1(CryptoKey),
|
||||
"sha512" => new HMACSHA512(CryptoKey),
|
||||
_ => forceHmac ? new HMACSHA1(CryptoKey) : null,
|
||||
};
|
||||
}
|
||||
|
||||
private byte[] Encrypt(byte[] clear, out byte[] iv)
|
||||
{
|
||||
if (UseApolloSigning)
|
||||
{
|
||||
return EncryptApollo(clear, out iv);
|
||||
}
|
||||
|
||||
return EncryptBaktun(clear, out iv);
|
||||
}
|
||||
|
||||
private byte[] EncryptApollo(byte[] clear, out byte[] iv)
|
||||
{
|
||||
using Aes aes = Aes.Create();
|
||||
aes.Key = DeriveAesKey();
|
||||
iv = aes.IV;
|
||||
using MemoryStream output = new();
|
||||
using (CryptoStream crypto = new(output, aes.CreateEncryptor(), CryptoStreamMode.Write))
|
||||
{
|
||||
crypto.Write(clear, 0, clear.Length);
|
||||
}
|
||||
|
||||
return output.ToArray();
|
||||
}
|
||||
|
||||
private byte[] EncryptBaktun(byte[] clear, out byte[] iv)
|
||||
{
|
||||
using Aes aes = Aes.Create();
|
||||
aes.Key = DeriveAesKey();
|
||||
iv = aes.IV;
|
||||
using MemoryStream output = new();
|
||||
using (CryptoStream crypto = new(output, aes.CreateEncryptor(), CryptoStreamMode.Write))
|
||||
{
|
||||
using DeflateStream deflate = new(crypto, CompressionMode.Compress);
|
||||
deflate.Write(clear, 0, clear.Length);
|
||||
}
|
||||
|
||||
return output.ToArray();
|
||||
}
|
||||
|
||||
private byte[] DeriveAesKey()
|
||||
{
|
||||
return Rfc2898DeriveBytes.Pbkdf2(
|
||||
Convert.ToBase64String(CryptoKey),
|
||||
PasswordSalt,
|
||||
iterations: 1000,
|
||||
HashAlgorithmName.SHA1,
|
||||
outputLength: 16);
|
||||
}
|
||||
|
||||
private byte[] CryptoKey
|
||||
{
|
||||
get
|
||||
{
|
||||
byte[] shared = BigInteger.ModPow(new BigInteger(remotePublicKey), new BigInteger(privateKey), dhPrime).ToByteArray();
|
||||
return [.. shared, .. solutionPassphrase];
|
||||
}
|
||||
}
|
||||
|
||||
private BigInteger CreatePrivateKey()
|
||||
{
|
||||
byte[] bytes = new byte[(keySize / 8) + 1];
|
||||
BigInteger value;
|
||||
do
|
||||
{
|
||||
RandomNumberGenerator.Fill(bytes);
|
||||
bytes[^1] = 0;
|
||||
value = new BigInteger(bytes);
|
||||
}
|
||||
while (value <= BigInteger.Zero || value >= dhPrime - BigInteger.One);
|
||||
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
namespace MxAsbClient;
|
||||
|
||||
public sealed record AsbWriteCompletionOptions
|
||||
{
|
||||
public static AsbWriteCompletionOptions Default { get; } = new();
|
||||
|
||||
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(5);
|
||||
|
||||
public TimeSpan PollInterval { get; init; } = TimeSpan.FromMilliseconds(250);
|
||||
|
||||
public TimeSpan ReadbackDelay { get; init; } = TimeSpan.Zero;
|
||||
|
||||
public CancellationToken CancellationToken { get; init; }
|
||||
|
||||
public void ValidatePolling()
|
||||
{
|
||||
if (Timeout < TimeSpan.Zero)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(Timeout), "Write completion timeout must not be negative.");
|
||||
}
|
||||
|
||||
if (PollInterval <= TimeSpan.Zero)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(PollInterval), "Write completion poll interval must be greater than zero.");
|
||||
}
|
||||
}
|
||||
|
||||
public void ValidateReadback()
|
||||
{
|
||||
ValidatePolling();
|
||||
if (ReadbackDelay < TimeSpan.Zero)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(ReadbackDelay), "Write completion readback delay must not be negative.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record AsbWriteCompletionResult(
|
||||
uint WriteHandle,
|
||||
bool Completed,
|
||||
bool TimedOut,
|
||||
TimeSpan Elapsed,
|
||||
int PollCount,
|
||||
IReadOnlyList<PublishWriteCompleteResponse> Responses,
|
||||
IReadOnlyList<ItemWriteComplete> CompleteWrites,
|
||||
ItemWriteComplete? MatchingComplete);
|
||||
|
||||
public sealed record AsbWriteCompletionReadbackResult(
|
||||
AsbWriteCompletionResult Completion,
|
||||
ReadResponse? Readback);
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace MxAsbClient;
|
||||
|
||||
public sealed record AsbWriteOptions
|
||||
{
|
||||
public uint WriteHandle { get; init; }
|
||||
|
||||
public string? Comment { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<PlatformTarget>x64</PlatformTarget>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="10.0.7" />
|
||||
<PackageReference Include="System.Security.Cryptography.Xml" Version="10.0.7" />
|
||||
<PackageReference Include="System.ServiceModel.NetTcp" Version="10.0.652802" />
|
||||
<PackageReference Include="System.ServiceModel.Primitives" Version="10.0.652802" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,295 @@
|
||||
namespace MxAsbClient;
|
||||
|
||||
public sealed record MxAsbDataChangeEvent(
|
||||
int ServerHandle,
|
||||
int ItemHandle,
|
||||
object? Value,
|
||||
ushort Quality,
|
||||
DateTime TimestampUtc,
|
||||
IReadOnlyList<AsbStatusElement> Status)
|
||||
{
|
||||
public AsbStatusSummary StatusSummary => AsbResultMapper.ToStatusSummary(Status);
|
||||
}
|
||||
|
||||
public sealed class MxAsbCompatibilityServer : IDisposable
|
||||
{
|
||||
private readonly object gate = new();
|
||||
private readonly Dictionary<int, AsbServerSession> sessions = [];
|
||||
private readonly Dictionary<int, AsbCompatibilityItem> items = [];
|
||||
private int nextServerHandle = 1;
|
||||
private int nextItemHandle = 1;
|
||||
private bool disposed;
|
||||
|
||||
public event EventHandler<MxAsbDataChangeEvent>? DataChanged;
|
||||
|
||||
public int Register(string endpoint, string? solutionName = null, Action<string>? trace = null, bool dumpMessages = false)
|
||||
{
|
||||
return Register(new AsbConnectionOptions
|
||||
{
|
||||
Endpoint = endpoint,
|
||||
SolutionName = solutionName,
|
||||
Trace = trace,
|
||||
DumpMessages = dumpMessages,
|
||||
});
|
||||
}
|
||||
|
||||
public int Register(AsbConnectionOptions options)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
MxAsbDataClient client = MxAsbDataClient.Connect(options);
|
||||
return RegisterClient(client);
|
||||
}
|
||||
|
||||
private int RegisterClient(MxAsbDataClient client)
|
||||
{
|
||||
int serverHandle;
|
||||
lock (gate)
|
||||
{
|
||||
serverHandle = nextServerHandle++;
|
||||
sessions.Add(serverHandle, new AsbServerSession(serverHandle, client));
|
||||
}
|
||||
|
||||
client.PublishedValueReceived += OnPublishedValueReceived;
|
||||
return serverHandle;
|
||||
}
|
||||
|
||||
public void Unregister(int serverHandle)
|
||||
{
|
||||
AsbServerSession session;
|
||||
lock (gate)
|
||||
{
|
||||
session = GetSessionLocked(serverHandle);
|
||||
sessions.Remove(serverHandle);
|
||||
foreach (int itemHandle in items
|
||||
.Where(pair => pair.Value.ServerHandle == serverHandle)
|
||||
.Select(pair => pair.Key)
|
||||
.ToArray())
|
||||
{
|
||||
items.Remove(itemHandle);
|
||||
}
|
||||
}
|
||||
|
||||
session.Client.PublishedValueReceived -= OnPublishedValueReceived;
|
||||
session.Dispose();
|
||||
}
|
||||
|
||||
public int AddItem(int serverHandle, string tag)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
lock (gate)
|
||||
{
|
||||
GetSessionLocked(serverHandle);
|
||||
int itemHandle = nextItemHandle++;
|
||||
items.Add(itemHandle, new AsbCompatibilityItem(serverHandle, itemHandle, tag));
|
||||
return itemHandle;
|
||||
}
|
||||
}
|
||||
|
||||
public void RemoveItem(int serverHandle, int itemHandle)
|
||||
{
|
||||
AsbServerSession session;
|
||||
AsbCompatibilityItem item;
|
||||
lock (gate)
|
||||
{
|
||||
session = GetSessionLocked(serverHandle);
|
||||
item = GetItemLocked(serverHandle, itemHandle);
|
||||
items.Remove(itemHandle);
|
||||
}
|
||||
|
||||
if (session.SubscriptionId.HasValue && item.MonitoredItem.HasValue)
|
||||
{
|
||||
session.Client.DeleteMonitoredItems(session.SubscriptionId.Value, [item.MonitoredItem.Value]);
|
||||
}
|
||||
}
|
||||
|
||||
public void Advise(int serverHandle, int itemHandle, ulong sampleInterval = 1000, long maxQueueSize = 128)
|
||||
{
|
||||
Advise(
|
||||
serverHandle,
|
||||
itemHandle,
|
||||
new AsbSubscriptionOptions
|
||||
{
|
||||
MaxQueueSize = maxQueueSize,
|
||||
SampleInterval = sampleInterval,
|
||||
},
|
||||
new AsbMonitoredItemOptions
|
||||
{
|
||||
SampleInterval = sampleInterval,
|
||||
});
|
||||
}
|
||||
|
||||
public void Advise(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
AsbSubscriptionOptions subscriptionOptions,
|
||||
AsbMonitoredItemOptions monitoredItemOptions)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
ArgumentNullException.ThrowIfNull(subscriptionOptions);
|
||||
ArgumentNullException.ThrowIfNull(monitoredItemOptions);
|
||||
subscriptionOptions.Validate();
|
||||
|
||||
AsbServerSession session;
|
||||
AsbCompatibilityItem item;
|
||||
lock (gate)
|
||||
{
|
||||
session = GetSessionLocked(serverHandle);
|
||||
item = GetItemLocked(serverHandle, itemHandle);
|
||||
}
|
||||
|
||||
if (!session.SubscriptionId.HasValue)
|
||||
{
|
||||
CreateSubscriptionResponse create = session.Client.CreateSubscription(subscriptionOptions);
|
||||
EnsureSucceeded(create.Result, nameof(MxAsbDataClient.CreateSubscription));
|
||||
session.SubscriptionId = create.SubscriptionId;
|
||||
}
|
||||
|
||||
AddMonitoredItemsResponse add = session.Client.AddMonitoredItems(session.SubscriptionId.Value, [item.Tag], monitoredItemOptions);
|
||||
EnsureSucceeded(add.Result, nameof(MxAsbDataClient.AddMonitoredItems));
|
||||
ItemStatus? status = add.Status?.FirstOrDefault();
|
||||
if (status.HasValue)
|
||||
{
|
||||
item.MonitoredItem = status.Value.Item;
|
||||
lock (gate)
|
||||
{
|
||||
items[itemHandle] = item;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public AsbPublishResult Poll(int serverHandle)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
AsbServerSession session;
|
||||
lock (gate)
|
||||
{
|
||||
session = GetSessionLocked(serverHandle);
|
||||
}
|
||||
|
||||
if (!session.SubscriptionId.HasValue)
|
||||
{
|
||||
throw new InvalidOperationException("The ASB server handle has no active subscription.");
|
||||
}
|
||||
|
||||
return session.Client.PublishValues(session.SubscriptionId.Value);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
AsbServerSession[] activeSessions;
|
||||
lock (gate)
|
||||
{
|
||||
activeSessions = sessions.Values.ToArray();
|
||||
sessions.Clear();
|
||||
items.Clear();
|
||||
}
|
||||
|
||||
foreach (AsbServerSession session in activeSessions)
|
||||
{
|
||||
session.Client.PublishedValueReceived -= OnPublishedValueReceived;
|
||||
session.Dispose();
|
||||
}
|
||||
|
||||
disposed = true;
|
||||
}
|
||||
|
||||
private void OnPublishedValueReceived(object? sender, AsbPublishedValue value)
|
||||
{
|
||||
int serverHandle;
|
||||
int itemHandle;
|
||||
lock (gate)
|
||||
{
|
||||
AsbServerSession? session = sessions.Values.FirstOrDefault(candidate => ReferenceEquals(candidate.Client, sender));
|
||||
if (session is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
AsbCompatibilityItem? item = items.Values.FirstOrDefault(candidate =>
|
||||
candidate.ServerHandle == session.ServerHandle &&
|
||||
candidate.MonitoredItem?.Id == value.ItemId);
|
||||
if (item is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
serverHandle = session.ServerHandle;
|
||||
itemHandle = item.ItemHandle;
|
||||
}
|
||||
|
||||
DataChanged?.Invoke(
|
||||
this,
|
||||
new MxAsbDataChangeEvent(
|
||||
serverHandle,
|
||||
itemHandle,
|
||||
value.Value,
|
||||
value.Quality ?? 0,
|
||||
value.TimestampUtc,
|
||||
value.Status));
|
||||
}
|
||||
|
||||
private AsbServerSession GetSessionLocked(int serverHandle)
|
||||
{
|
||||
if (!sessions.TryGetValue(serverHandle, out AsbServerSession? session))
|
||||
{
|
||||
throw new ArgumentException("Unknown ASB server handle.", nameof(serverHandle));
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
private AsbCompatibilityItem GetItemLocked(int serverHandle, int itemHandle)
|
||||
{
|
||||
if (!items.TryGetValue(itemHandle, out AsbCompatibilityItem? item) || item.ServerHandle != serverHandle)
|
||||
{
|
||||
throw new ArgumentException("Unknown ASB item handle.", nameof(itemHandle));
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
private static void EnsureSucceeded(ArchestrAResult result, string operation)
|
||||
{
|
||||
if (!result.Success)
|
||||
{
|
||||
throw new InvalidOperationException($"{operation} failed with error 0x{result.ErrorCode:X8}.");
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class AsbServerSession(int serverHandle, MxAsbDataClient client) : IDisposable
|
||||
{
|
||||
public int ServerHandle { get; } = serverHandle;
|
||||
public MxAsbDataClient Client { get; } = client;
|
||||
public long? SubscriptionId { get; set; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (SubscriptionId.HasValue)
|
||||
{
|
||||
try
|
||||
{
|
||||
Client.DeleteSubscription(SubscriptionId.Value);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort cleanup; channel disposal below still releases the client.
|
||||
}
|
||||
}
|
||||
|
||||
Client.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record AsbCompatibilityItem(
|
||||
int ServerHandle,
|
||||
int ItemHandle,
|
||||
string Tag)
|
||||
{
|
||||
public ItemIdentity? MonitoredItem { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,828 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.ServiceModel;
|
||||
using System.ServiceModel.Channels;
|
||||
|
||||
namespace MxAsbClient;
|
||||
|
||||
public sealed class MxAsbDataClient : IDisposable
|
||||
{
|
||||
private const int InvalidConnectionId = 1;
|
||||
private readonly object cleanupGate = new();
|
||||
private readonly ChannelFactory<IAsbDataV2> factory;
|
||||
private readonly IAsbDataV2 channel;
|
||||
private readonly IClientChannel clientChannel;
|
||||
private readonly AsbSystemAuthenticator authenticator;
|
||||
private readonly string endpoint;
|
||||
private readonly string? solutionName;
|
||||
private readonly Action<string>? trace;
|
||||
private readonly bool dumpMessages;
|
||||
private readonly Dictionary<ulong, string> monitoredItemNamesById = [];
|
||||
private AsbClientCleanupResult? cleanupResult;
|
||||
private bool disposed;
|
||||
|
||||
private MxAsbDataClient(
|
||||
ChannelFactory<IAsbDataV2> factory,
|
||||
IAsbDataV2 channel,
|
||||
AsbSystemAuthenticator authenticator,
|
||||
string endpoint,
|
||||
string? solutionName,
|
||||
Action<string>? trace,
|
||||
bool dumpMessages)
|
||||
{
|
||||
this.factory = factory;
|
||||
this.channel = channel;
|
||||
clientChannel = (IClientChannel)channel;
|
||||
this.authenticator = authenticator;
|
||||
this.endpoint = endpoint;
|
||||
this.solutionName = solutionName;
|
||||
this.trace = trace;
|
||||
this.dumpMessages = dumpMessages;
|
||||
}
|
||||
|
||||
public static MxAsbDataClient Connect(string endpoint, string? solutionName = null, Action<string>? trace = null, bool dumpMessages = false)
|
||||
{
|
||||
return Connect(new AsbConnectionOptions
|
||||
{
|
||||
Endpoint = endpoint,
|
||||
SolutionName = solutionName,
|
||||
Trace = trace,
|
||||
DumpMessages = dumpMessages,
|
||||
});
|
||||
}
|
||||
|
||||
public static MxAsbDataClient Connect(AsbConnectionOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
options.Validate();
|
||||
|
||||
string endpoint = options.Endpoint;
|
||||
string? solutionName = options.SolutionName;
|
||||
Action<string>? trace = options.Trace;
|
||||
bool dumpMessages = options.DumpMessages;
|
||||
|
||||
trace?.Invoke("asb.stage=read-passphrase");
|
||||
string passphrase = AsbRegistry.GetSolutionPassphrase(solutionName, trace);
|
||||
AsbSolutionCryptoParameters cryptoParameters = AsbRegistry.GetCryptoParameters(solutionName);
|
||||
trace?.Invoke("asb.stage=create-authenticator");
|
||||
AsbSystemAuthenticator authenticator = new(passphrase, cryptoParameters, trace);
|
||||
trace?.Invoke("asb.stage=authenticator-ready");
|
||||
NetTcpBinding binding = CreateBinding();
|
||||
ChannelFactory<IAsbDataV2> factory = new(binding, new EndpointAddress(endpoint));
|
||||
AsbDataCustomSerializer.Trace = dumpMessages ? trace : null;
|
||||
int replacedSerializers = AsbCustomSerializerContractBehavior.ReplaceSerializer(factory.Endpoint.Contract);
|
||||
trace?.Invoke($"asb.serializer.behaviors-replaced={replacedSerializers}");
|
||||
if (trace is not null && dumpMessages)
|
||||
{
|
||||
factory.Endpoint.EndpointBehaviors.Add(new AsbMessageDumpBehavior(trace));
|
||||
}
|
||||
|
||||
IAsbDataV2? channel = null;
|
||||
IClientChannel? clientChannel = null;
|
||||
try
|
||||
{
|
||||
trace?.Invoke("asb.stage=open-factory");
|
||||
factory.Open();
|
||||
channel = factory.CreateChannel();
|
||||
|
||||
trace?.Invoke("asb.stage=open-channel");
|
||||
clientChannel = (IClientChannel)channel;
|
||||
clientChannel.Open();
|
||||
|
||||
trace?.Invoke("asb.stage=connect");
|
||||
ConnectResponse response = channel.Connect(new ConnectRequest
|
||||
{
|
||||
ConnectionId = authenticator.ConnectionId,
|
||||
ConsumerPublicKey = new PublicKey { Data = authenticator.LocalPublicKey },
|
||||
});
|
||||
|
||||
if (!response.Result.Success)
|
||||
{
|
||||
throw new InvalidOperationException($"ASB Connect failed with error 0x{response.Result.ErrorCode:X8}.");
|
||||
}
|
||||
|
||||
authenticator.AcceptConnectResponse(response);
|
||||
trace?.Invoke("asb.stage=authenticate-me");
|
||||
AuthenticateMe authenticateMe = new()
|
||||
{
|
||||
ConsumerAuthenticationData = authenticator.CreateAuthenticationData(),
|
||||
};
|
||||
authenticator.Sign(authenticateMe, forceHmac: true);
|
||||
channel.AuthenticateMe(authenticateMe);
|
||||
trace?.Invoke("asb.stage=connected");
|
||||
|
||||
return new MxAsbDataClient(factory, channel, authenticator, endpoint, solutionName, trace, dumpMessages);
|
||||
}
|
||||
catch
|
||||
{
|
||||
trace?.Invoke("asb.stage=connect-cleanup");
|
||||
if (clientChannel is not null)
|
||||
{
|
||||
AsbCommunicationCleanup.CloseOrAbort(clientChannel, "connect-channel", binding.CloseTimeout, trace);
|
||||
}
|
||||
|
||||
AsbCommunicationCleanup.CloseOrAbort(factory, "connect-factory", binding.CloseTimeout, trace);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public event EventHandler<AsbPublishedValue>? PublishedValueReceived;
|
||||
|
||||
public AsbClientCleanupResult? LastCleanupResult => cleanupResult;
|
||||
|
||||
public bool IsDisposed => disposed;
|
||||
|
||||
public CommunicationState ChannelState => clientChannel.State;
|
||||
|
||||
public AsbReconnectResult Reconnect(AsbReconnectOptions? options = null)
|
||||
{
|
||||
options ??= AsbReconnectOptions.Default;
|
||||
options.Validate();
|
||||
|
||||
AsbClientCleanupResult? currentCleanup = null;
|
||||
if (options.CleanupCurrentConnection)
|
||||
{
|
||||
currentCleanup = Cleanup(options.CleanupOptions);
|
||||
}
|
||||
|
||||
List<AsbReconnectAttempt> attempts = [];
|
||||
for (int attempt = 1; attempt <= options.MaxAttempts; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
trace?.Invoke($"asb.reconnect.attempt={attempt}");
|
||||
MxAsbDataClient client = Connect(endpoint, solutionName, trace, dumpMessages);
|
||||
attempts.Add(new AsbReconnectAttempt(attempt, Succeeded: true, Exception: null));
|
||||
return new AsbReconnectResult(Succeeded: true, client, currentCleanup, attempts);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
attempts.Add(new AsbReconnectAttempt(attempt, Succeeded: false, ex));
|
||||
trace?.Invoke($"asb.reconnect.failed={attempt}:{ex.GetType().Name}:{ex.Message}");
|
||||
if (attempt < options.MaxAttempts && options.Delay > TimeSpan.Zero)
|
||||
{
|
||||
Thread.Sleep(options.Delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new AsbReconnectResult(Succeeded: false, Client: null, currentCleanup, attempts);
|
||||
}
|
||||
|
||||
public RegisterItemsResponse Register(string tag)
|
||||
{
|
||||
return RegisterMany([tag]);
|
||||
}
|
||||
|
||||
public RegisterItemsResponse RegisterMany(IEnumerable<string> tags)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(tags);
|
||||
|
||||
ItemIdentity[] items = CreateAbsoluteItems(tags);
|
||||
RegisterItemsResponse response = RegisterOnce(items);
|
||||
for (int attempt = 1; attempt < 5 && response.Result.ErrorCode == InvalidConnectionId; attempt++)
|
||||
{
|
||||
Thread.Sleep(TimeSpan.FromMilliseconds(100 * attempt));
|
||||
response = RegisterOnce(items);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private RegisterItemsResponse RegisterOnce(string tag)
|
||||
{
|
||||
return RegisterOnce([CreateAbsoluteItem(tag)]);
|
||||
}
|
||||
|
||||
private RegisterItemsResponse RegisterOnce(ItemIdentity[] items)
|
||||
{
|
||||
RegisterItemsRequest request = new()
|
||||
{
|
||||
Items = items,
|
||||
RequireId = true,
|
||||
RegisterOnly = true,
|
||||
};
|
||||
authenticator.Sign(request);
|
||||
return channel.RegisterItems(request);
|
||||
}
|
||||
|
||||
public UnregisterItemsResponse Unregister(string tag)
|
||||
{
|
||||
return Unregister(CreateAbsoluteItem(tag));
|
||||
}
|
||||
|
||||
public UnregisterItemsResponse Unregister(ItemIdentity item)
|
||||
{
|
||||
return UnregisterMany([item]);
|
||||
}
|
||||
|
||||
public UnregisterItemsResponse UnregisterMany(IEnumerable<ItemIdentity> items)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(items);
|
||||
|
||||
UnregisterItemsRequest request = new()
|
||||
{
|
||||
Items = items.ToArray(),
|
||||
};
|
||||
authenticator.Sign(request);
|
||||
return channel.UnregisterItems(request);
|
||||
}
|
||||
|
||||
public ReadResponse Read(string tag)
|
||||
{
|
||||
return ReadMany([tag]);
|
||||
}
|
||||
|
||||
public ReadResponse ReadMany(IEnumerable<string> tags)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(tags);
|
||||
|
||||
ReadRequest request = new()
|
||||
{
|
||||
Items = CreateAbsoluteItems(tags),
|
||||
};
|
||||
authenticator.Sign(request);
|
||||
return channel.Read(request);
|
||||
}
|
||||
|
||||
public WriteResponse WriteInt32(string tag, int value, uint writeHandle)
|
||||
{
|
||||
return Write(tag, AsbVariantFactory.FromInt32(value), writeHandle, "MxAsbClient write-int");
|
||||
}
|
||||
|
||||
public WriteResponse Write(string tag, Variant value, uint writeHandle, string? comment = null)
|
||||
{
|
||||
return Write(tag, value, new AsbWriteOptions
|
||||
{
|
||||
WriteHandle = writeHandle,
|
||||
Comment = comment,
|
||||
});
|
||||
}
|
||||
|
||||
public WriteResponse Write(string tag, Variant value, AsbWriteOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
WriteBasicRequest request = new()
|
||||
{
|
||||
Items = [CreateAbsoluteItem(tag)],
|
||||
Values =
|
||||
[
|
||||
new WriteValue
|
||||
{
|
||||
Value = value,
|
||||
Comment = options.Comment ?? "MxAsbClient write",
|
||||
},
|
||||
],
|
||||
WriteHandle = options.WriteHandle,
|
||||
};
|
||||
authenticator.Sign(request);
|
||||
return channel.Write(request);
|
||||
}
|
||||
|
||||
public PublishWriteCompleteResponse PublishWriteComplete()
|
||||
{
|
||||
PublishWriteCompleteRequest request = new();
|
||||
authenticator.Sign(request);
|
||||
return channel.PublishWriteComplete(request);
|
||||
}
|
||||
|
||||
public AsbWriteCompletionResult WaitForWriteComplete(uint writeHandle, AsbWriteCompletionOptions? options = null)
|
||||
{
|
||||
options ??= AsbWriteCompletionOptions.Default;
|
||||
options.ValidatePolling();
|
||||
|
||||
Stopwatch stopwatch = Stopwatch.StartNew();
|
||||
List<PublishWriteCompleteResponse> responses = [];
|
||||
List<ItemWriteComplete> completeWrites = [];
|
||||
ItemWriteComplete? matchingComplete = null;
|
||||
int pollCount = 0;
|
||||
|
||||
while (true)
|
||||
{
|
||||
options.CancellationToken.ThrowIfCancellationRequested();
|
||||
PublishWriteCompleteResponse response = PublishWriteComplete();
|
||||
pollCount++;
|
||||
responses.Add(response);
|
||||
|
||||
ItemWriteComplete[] writes = response.CompleteWrites ?? [];
|
||||
completeWrites.AddRange(writes);
|
||||
foreach (ItemWriteComplete item in writes)
|
||||
{
|
||||
if (item.WriteHandleSpecified && item.WriteHandle == writeHandle)
|
||||
{
|
||||
matchingComplete = item;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (matchingComplete.HasValue)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return new AsbWriteCompletionResult(
|
||||
writeHandle,
|
||||
Completed: true,
|
||||
TimedOut: false,
|
||||
stopwatch.Elapsed,
|
||||
pollCount,
|
||||
responses,
|
||||
completeWrites,
|
||||
matchingComplete);
|
||||
}
|
||||
|
||||
TimeSpan remaining = options.Timeout - stopwatch.Elapsed;
|
||||
if (remaining <= TimeSpan.Zero)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return new AsbWriteCompletionResult(
|
||||
writeHandle,
|
||||
Completed: false,
|
||||
TimedOut: true,
|
||||
stopwatch.Elapsed,
|
||||
pollCount,
|
||||
responses,
|
||||
completeWrites,
|
||||
MatchingComplete: null);
|
||||
}
|
||||
|
||||
TimeSpan delay = options.PollInterval < remaining ? options.PollInterval : remaining;
|
||||
if (delay > TimeSpan.Zero)
|
||||
{
|
||||
if (options.CancellationToken.WaitHandle.WaitOne(delay))
|
||||
{
|
||||
throw new OperationCanceledException(options.CancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public AsbWriteCompletionReadbackResult WaitForWriteCompleteAndRead(string tag, uint writeHandle, AsbWriteCompletionOptions? options = null)
|
||||
{
|
||||
options ??= AsbWriteCompletionOptions.Default;
|
||||
options.ValidateReadback();
|
||||
|
||||
AsbWriteCompletionResult completion = WaitForWriteComplete(writeHandle, options);
|
||||
if (!completion.Completed)
|
||||
{
|
||||
return new AsbWriteCompletionReadbackResult(completion, Readback: null);
|
||||
}
|
||||
|
||||
if (options.ReadbackDelay > TimeSpan.Zero)
|
||||
{
|
||||
if (options.CancellationToken.WaitHandle.WaitOne(options.ReadbackDelay))
|
||||
{
|
||||
throw new OperationCanceledException(options.CancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
options.CancellationToken.ThrowIfCancellationRequested();
|
||||
return new AsbWriteCompletionReadbackResult(completion, Read(tag));
|
||||
}
|
||||
|
||||
public CreateSubscriptionResponse CreateSubscription(long maxQueueSize = 128, ulong sampleInterval = 1000)
|
||||
{
|
||||
return CreateSubscription(new AsbSubscriptionOptions
|
||||
{
|
||||
MaxQueueSize = maxQueueSize,
|
||||
SampleInterval = sampleInterval,
|
||||
});
|
||||
}
|
||||
|
||||
public CreateSubscriptionResponse CreateSubscription(AsbSubscriptionOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
options.Validate();
|
||||
|
||||
CreateSubscriptionResponse response = CreateSubscriptionOnce(options.MaxQueueSize, options.SampleInterval);
|
||||
for (int attempt = 1; attempt < 5 && response.Result.ErrorCode == InvalidConnectionId; attempt++)
|
||||
{
|
||||
Thread.Sleep(TimeSpan.FromMilliseconds(100 * attempt));
|
||||
response = CreateSubscriptionOnce(options.MaxQueueSize, options.SampleInterval);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private CreateSubscriptionResponse CreateSubscriptionOnce(long maxQueueSize, ulong sampleInterval)
|
||||
{
|
||||
CreateSubscriptionRequest request = new()
|
||||
{
|
||||
MaxQueueSize = maxQueueSize,
|
||||
SampleInterval = sampleInterval,
|
||||
};
|
||||
authenticator.Sign(request);
|
||||
return channel.CreateSubscription(request);
|
||||
}
|
||||
|
||||
public DeleteSubscriptionResponse DeleteSubscription(long subscriptionId)
|
||||
{
|
||||
DeleteSubscriptionRequest request = new()
|
||||
{
|
||||
SubscriptionId = subscriptionId,
|
||||
};
|
||||
authenticator.Sign(request);
|
||||
return channel.DeleteSubscription(request);
|
||||
}
|
||||
|
||||
public AddMonitoredItemsResponse AddMonitoredItems(long subscriptionId, IEnumerable<string> tags, ulong sampleInterval = 1000, bool active = true, bool buffered = false)
|
||||
{
|
||||
return AddMonitoredItems(subscriptionId, tags, new AsbMonitoredItemOptions
|
||||
{
|
||||
SampleInterval = sampleInterval,
|
||||
Active = active,
|
||||
Buffered = buffered,
|
||||
});
|
||||
}
|
||||
|
||||
public AddMonitoredItemsResponse AddMonitoredItems(long subscriptionId, IEnumerable<string> tags, AsbMonitoredItemOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(tags);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
AddMonitoredItemsRequest request = new()
|
||||
{
|
||||
SubscriptionId = subscriptionId,
|
||||
Items = tags.Select(tag => CreateMonitoredItem(CreateAbsoluteItem(tag), options.SampleInterval, options.Active, options.Buffered)).ToArray(),
|
||||
RequireId = true,
|
||||
};
|
||||
authenticator.Sign(request);
|
||||
AddMonitoredItemsResponse response = channel.AddMonitoredItems(request);
|
||||
RememberItemNames(response.Status);
|
||||
return response;
|
||||
}
|
||||
|
||||
public DeleteMonitoredItemsResponse DeleteMonitoredItems(long subscriptionId, IEnumerable<ItemIdentity> items)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(items);
|
||||
|
||||
ItemIdentity[] itemArray = items.ToArray();
|
||||
DeleteMonitoredItemsRequest request = new()
|
||||
{
|
||||
SubscriptionId = subscriptionId,
|
||||
Items = itemArray.Select(item => CreateMonitoredItem(item, 0, active: false, buffered: false)).ToArray(),
|
||||
};
|
||||
authenticator.Sign(request);
|
||||
DeleteMonitoredItemsResponse response = channel.DeleteMonitoredItems(request);
|
||||
ForgetItemNames(itemArray);
|
||||
return response;
|
||||
}
|
||||
|
||||
public PublishResponse Publish(long subscriptionId)
|
||||
{
|
||||
PublishRequest request = new()
|
||||
{
|
||||
SubscriptionId = subscriptionId,
|
||||
};
|
||||
authenticator.Sign(request);
|
||||
return channel.Publish(request);
|
||||
}
|
||||
|
||||
public AsbPublishResult PublishValues(long subscriptionId)
|
||||
{
|
||||
PublishResponse response = Publish(subscriptionId);
|
||||
AsbPublishedValue[] values = AsbPublishMapper
|
||||
.ToPublishedValues(response, monitoredItemNamesById)
|
||||
.ToArray();
|
||||
foreach (AsbPublishedValue value in values)
|
||||
{
|
||||
PublishedValueReceived?.Invoke(this, value);
|
||||
}
|
||||
|
||||
return new AsbPublishResult(response, values);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Cleanup();
|
||||
}
|
||||
|
||||
public AsbClientCleanupResult Cleanup(AsbClientCleanupOptions? options = null)
|
||||
{
|
||||
options ??= AsbClientCleanupOptions.Default;
|
||||
options.Validate();
|
||||
|
||||
lock (cleanupGate)
|
||||
{
|
||||
if (disposed)
|
||||
{
|
||||
return cleanupResult ?? CreateAlreadyCleanedResult();
|
||||
}
|
||||
|
||||
disposed = true;
|
||||
trace?.Invoke("asb.stage=cleanup");
|
||||
DisconnectCleanupResult disconnect = options.CancellationToken.IsCancellationRequested
|
||||
? CreateCanceledDisconnectResult()
|
||||
: SendDisconnectBestEffort(options);
|
||||
CommunicationObjectCleanupResult channelResult = CleanupCommunicationObject(clientChannel, "channel", options);
|
||||
CommunicationObjectCleanupResult factoryResult = CleanupCommunicationObject(factory, "factory", options);
|
||||
|
||||
cleanupResult = new AsbClientCleanupResult(
|
||||
disconnect.Attempted,
|
||||
disconnect.Sent,
|
||||
disconnect.Failure,
|
||||
channelResult,
|
||||
factoryResult);
|
||||
trace?.Invoke($"asb.cleanup.succeeded={cleanupResult.Succeeded}");
|
||||
return cleanupResult;
|
||||
}
|
||||
}
|
||||
|
||||
private CommunicationObjectCleanupResult CleanupCommunicationObject(
|
||||
ICommunicationObject communicationObject,
|
||||
string name,
|
||||
AsbClientCleanupOptions options)
|
||||
{
|
||||
if (options.CancellationToken.IsCancellationRequested)
|
||||
{
|
||||
trace?.Invoke($"asb.cleanup.{name}.canceled");
|
||||
return AsbCommunicationCleanup.AbortOnly(communicationObject, name, trace);
|
||||
}
|
||||
|
||||
return AsbCommunicationCleanup.CloseOrAbort(communicationObject, name, options.CloseTimeout, trace);
|
||||
}
|
||||
|
||||
private static DisconnectCleanupResult CreateCanceledDisconnectResult()
|
||||
{
|
||||
string failure = AsbCommunicationCleanup.FormatCleanupFailure(
|
||||
new OperationCanceledException("Cleanup cancellation requested before disconnect."));
|
||||
return new DisconnectCleanupResult(Attempted: false, Sent: false, Failure: failure);
|
||||
}
|
||||
|
||||
private DisconnectCleanupResult SendDisconnectBestEffort(AsbClientCleanupOptions options)
|
||||
{
|
||||
if (clientChannel.State is not CommunicationState.Opened)
|
||||
{
|
||||
trace?.Invoke($"asb.cleanup.disconnect.skipped-state={clientChannel.State}");
|
||||
return new DisconnectCleanupResult(Attempted: false, Sent: false, Failure: null);
|
||||
}
|
||||
|
||||
TimeSpan previousOperationTimeout = clientChannel.OperationTimeout;
|
||||
try
|
||||
{
|
||||
clientChannel.OperationTimeout = options.DisconnectTimeout;
|
||||
trace?.Invoke("asb.stage=disconnect");
|
||||
Disconnect request = new()
|
||||
{
|
||||
ConsumerAuthenticationData = authenticator.CreateAuthenticationData(),
|
||||
};
|
||||
authenticator.Sign(request);
|
||||
channel.Disconnect(request);
|
||||
return new DisconnectCleanupResult(Attempted: true, Sent: true, Failure: null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
string failure = AsbCommunicationCleanup.FormatCleanupFailure(ex);
|
||||
trace?.Invoke($"asb.cleanup.disconnect.failed={failure}");
|
||||
return new DisconnectCleanupResult(Attempted: true, Sent: false, failure);
|
||||
}
|
||||
finally
|
||||
{
|
||||
clientChannel.OperationTimeout = previousOperationTimeout;
|
||||
}
|
||||
}
|
||||
|
||||
private static AsbClientCleanupResult CreateAlreadyCleanedResult()
|
||||
{
|
||||
CommunicationObjectCleanupResult skipped = new(
|
||||
"unknown",
|
||||
"Closed",
|
||||
"Closed",
|
||||
CloseAttempted: false,
|
||||
Closed: true,
|
||||
CloseFailure: null,
|
||||
AbortAttempted: false,
|
||||
Aborted: false,
|
||||
AbortFailure: null);
|
||||
return new AsbClientCleanupResult(
|
||||
DisconnectAttempted: false,
|
||||
DisconnectSent: false,
|
||||
DisconnectFailure: null,
|
||||
Channel: skipped with { Name = "channel" },
|
||||
Factory: skipped with { Name = "factory" });
|
||||
}
|
||||
|
||||
private static ItemIdentity CreateAbsoluteItem(string tag)
|
||||
{
|
||||
return new ItemIdentity
|
||||
{
|
||||
Type = (ushort)ItemIdentityType.Name,
|
||||
ReferenceType = (ushort)ItemReferenceType.Absolute,
|
||||
Name = tag,
|
||||
ContextName = string.Empty,
|
||||
};
|
||||
}
|
||||
|
||||
private static ItemIdentity[] CreateAbsoluteItems(IEnumerable<string> tags)
|
||||
{
|
||||
return tags.Select(CreateAbsoluteItem).ToArray();
|
||||
}
|
||||
|
||||
private static MonitoredItem CreateMonitoredItem(ItemIdentity item, ulong sampleInterval, bool active, bool buffered)
|
||||
{
|
||||
return new MonitoredItem
|
||||
{
|
||||
Item = item,
|
||||
SampleInterval = sampleInterval,
|
||||
Active = active,
|
||||
Buffered = buffered,
|
||||
UserData = AsbVariantFactory.Empty,
|
||||
ValueDeadband = AsbVariantFactory.Empty,
|
||||
};
|
||||
}
|
||||
|
||||
private void RememberItemNames(ItemStatus[]? statuses)
|
||||
{
|
||||
if (statuses is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (ItemStatus status in statuses)
|
||||
{
|
||||
if (status.Item.IdSpecified && !string.IsNullOrWhiteSpace(status.Item.Name))
|
||||
{
|
||||
monitoredItemNamesById[status.Item.Id] = status.Item.Name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ForgetItemNames(IEnumerable<ItemIdentity> items)
|
||||
{
|
||||
foreach (ItemIdentity item in items)
|
||||
{
|
||||
if (item.IdSpecified)
|
||||
{
|
||||
monitoredItemNamesById.Remove(item.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static NetTcpBinding CreateBinding()
|
||||
{
|
||||
int max = int.MaxValue;
|
||||
return new NetTcpBinding(SecurityMode.None)
|
||||
{
|
||||
TransferMode = TransferMode.Buffered,
|
||||
MaxReceivedMessageSize = max,
|
||||
MaxBufferSize = max,
|
||||
MaxBufferPoolSize = long.MaxValue,
|
||||
OpenTimeout = TimeSpan.FromSeconds(30),
|
||||
ReceiveTimeout = TimeSpan.FromSeconds(30),
|
||||
SendTimeout = TimeSpan.FromSeconds(30),
|
||||
CloseTimeout = TimeSpan.FromSeconds(30),
|
||||
ReaderQuotas =
|
||||
{
|
||||
MaxArrayLength = max,
|
||||
MaxBytesPerRead = max,
|
||||
MaxDepth = max,
|
||||
MaxNameTableCharCount = max,
|
||||
MaxStringContentLength = max,
|
||||
},
|
||||
ReliableSession =
|
||||
{
|
||||
InactivityTimeout = TimeSpan.FromSeconds(30),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public static string FormatVariant(Variant variant)
|
||||
{
|
||||
object? value = DecodeVariant(variant);
|
||||
return value switch
|
||||
{
|
||||
null => string.Empty,
|
||||
bool typed => typed.ToString(CultureInfo.InvariantCulture),
|
||||
int typed => typed.ToString(CultureInfo.InvariantCulture),
|
||||
float typed => typed.ToString(CultureInfo.InvariantCulture),
|
||||
double typed => typed.ToString(CultureInfo.InvariantCulture),
|
||||
DateTime typed => typed.ToString("O", CultureInfo.InvariantCulture),
|
||||
TimeSpan typed => typed.ToString("c", CultureInfo.InvariantCulture),
|
||||
int[] typed => string.Join(",", typed.Select(item => item.ToString(CultureInfo.InvariantCulture))),
|
||||
bool[] typed => string.Join(",", typed.Select(item => item.ToString(CultureInfo.InvariantCulture))),
|
||||
float[] typed => string.Join(",", typed.Select(item => item.ToString(CultureInfo.InvariantCulture))),
|
||||
double[] typed => string.Join(",", typed.Select(item => item.ToString(CultureInfo.InvariantCulture))),
|
||||
string[] typed => string.Join("|", typed),
|
||||
DateTime[] typed => string.Join(",", typed.Select(item => item.ToString("O", CultureInfo.InvariantCulture))),
|
||||
TimeSpan[] typed => string.Join(",", typed.Select(item => item.ToString("c", CultureInfo.InvariantCulture))),
|
||||
string typed => typed,
|
||||
byte[] typed => BitConverter.ToString(typed).Replace("-", string.Empty),
|
||||
_ => Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty,
|
||||
};
|
||||
}
|
||||
|
||||
public static object? DecodeVariant(Variant variant)
|
||||
{
|
||||
byte[] payload = variant.Payload ?? [];
|
||||
if (payload.Length == 0)
|
||||
{
|
||||
return variant.Type switch
|
||||
{
|
||||
(ushort)AsbDataType.TypeString => string.Empty,
|
||||
(ushort)AsbDataType.TypeInt32Array => Array.Empty<int>(),
|
||||
(ushort)AsbDataType.TypeBoolArray => Array.Empty<bool>(),
|
||||
(ushort)AsbDataType.TypeFloatArray => Array.Empty<float>(),
|
||||
(ushort)AsbDataType.TypeDoubleArray => Array.Empty<double>(),
|
||||
(ushort)AsbDataType.TypeStringArray => Array.Empty<string>(),
|
||||
(ushort)AsbDataType.TypeDateTimeArray => Array.Empty<DateTime>(),
|
||||
(ushort)AsbDataType.TypeDurationArray => Array.Empty<TimeSpan>(),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
return variant.Type switch
|
||||
{
|
||||
(ushort)AsbDataType.TypeBool when payload.Length >= 1 => payload[0] != 0,
|
||||
(ushort)AsbDataType.TypeInt32 when payload.Length >= 4 => BitConverter.ToInt32(payload, 0),
|
||||
(ushort)AsbDataType.TypeFloat when payload.Length >= 4 => BitConverter.ToSingle(payload, 0),
|
||||
(ushort)AsbDataType.TypeDouble when payload.Length >= 8 => BitConverter.ToDouble(payload, 0),
|
||||
(ushort)AsbDataType.TypeString => System.Text.Encoding.Unicode.GetString(payload),
|
||||
(ushort)AsbDataType.TypeDateTime when payload.Length >= 8 => DateTime.FromFileTimeUtc(BitConverter.ToInt64(payload, 0)),
|
||||
(ushort)AsbDataType.TypeDuration when payload.Length >= 8 => TimeSpan.FromTicks(BitConverter.ToInt64(payload, 0)),
|
||||
(ushort)AsbDataType.TypeInt32Array => DecodeInt32Array(payload),
|
||||
(ushort)AsbDataType.TypeBoolArray => payload.Select(item => item != 0).ToArray(),
|
||||
(ushort)AsbDataType.TypeFloatArray => DecodeSingleArray(payload),
|
||||
(ushort)AsbDataType.TypeDoubleArray => DecodeDoubleArray(payload),
|
||||
(ushort)AsbDataType.TypeStringArray => DecodeStringArray(payload),
|
||||
(ushort)AsbDataType.TypeDateTimeArray => DecodeDateTimeArray(payload),
|
||||
(ushort)AsbDataType.TypeDurationArray => DecodeDurationArray(payload),
|
||||
_ => payload,
|
||||
};
|
||||
}
|
||||
|
||||
private static int[] DecodeInt32Array(byte[] payload)
|
||||
{
|
||||
int[] values = new int[payload.Length / sizeof(int)];
|
||||
for (int i = 0; i < values.Length; i++)
|
||||
{
|
||||
values[i] = BitConverter.ToInt32(payload, i * sizeof(int));
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
private static float[] DecodeSingleArray(byte[] payload)
|
||||
{
|
||||
float[] values = new float[payload.Length / sizeof(float)];
|
||||
for (int i = 0; i < values.Length; i++)
|
||||
{
|
||||
values[i] = BitConverter.ToSingle(payload, i * sizeof(float));
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
private static double[] DecodeDoubleArray(byte[] payload)
|
||||
{
|
||||
double[] values = new double[payload.Length / sizeof(double)];
|
||||
for (int i = 0; i < values.Length; i++)
|
||||
{
|
||||
values[i] = BitConverter.ToDouble(payload, i * sizeof(double));
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
private static string[] DecodeStringArray(byte[] payload)
|
||||
{
|
||||
List<string> values = [];
|
||||
int offset = 0;
|
||||
while (offset + sizeof(int) <= payload.Length)
|
||||
{
|
||||
int byteLength = BitConverter.ToInt32(payload, offset);
|
||||
offset += sizeof(int);
|
||||
if (byteLength < 0 || offset + byteLength > payload.Length)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
values.Add(byteLength == 0 ? string.Empty : System.Text.Encoding.Unicode.GetString(payload, offset, byteLength));
|
||||
offset += byteLength;
|
||||
}
|
||||
|
||||
return values.ToArray();
|
||||
}
|
||||
|
||||
private static DateTime[] DecodeDateTimeArray(byte[] payload)
|
||||
{
|
||||
DateTime[] values = new DateTime[payload.Length / sizeof(long)];
|
||||
for (int i = 0; i < values.Length; i++)
|
||||
{
|
||||
values[i] = DateTime.FromFileTimeUtc(BitConverter.ToInt64(payload, i * sizeof(long)));
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
private static TimeSpan[] DecodeDurationArray(byte[] payload)
|
||||
{
|
||||
TimeSpan[] values = new TimeSpan[payload.Length / sizeof(long)];
|
||||
for (int i = 0; i < values.Length; i++)
|
||||
{
|
||||
values[i] = TimeSpan.FromTicks(BitConverter.ToInt64(payload, i * sizeof(long)));
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
private sealed record DisconnectCleanupResult(bool Attempted, bool Sent, string? Failure);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("MxAsbClient.Tests")]
|
||||
[assembly: InternalsVisibleTo("MxAsbClient.Probe")]
|
||||
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net481</TargetFramework>
|
||||
<PlatformTarget>x86</PlatformTarget>
|
||||
<Prefer32Bit>true</Prefer32Bit>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>disable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="Interop.aaMxDataConsumer">
|
||||
<HintPath>..\..\analysis\interop\Interop.aaMxDataConsumer.dll</HintPath>
|
||||
<Private>true</Private>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,406 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using Interop.aaMxDataConsumer;
|
||||
|
||||
namespace MxDataConsumerProbe;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
[STAThread]
|
||||
private static int Main(string[] args)
|
||||
{
|
||||
if (HasFlag(args, "--probe-dataclient"))
|
||||
{
|
||||
return ProbeDataClient(args);
|
||||
}
|
||||
|
||||
string tag = GetString(args, "--tag") ?? "TestChildObject.ScanState";
|
||||
string context = GetString(args, "--context") ?? string.Empty;
|
||||
int itemId = GetInt(args, "--item-id") ?? 1;
|
||||
ulong userData = (ulong)(GetInt(args, "--user-data") ?? 0x4401);
|
||||
int active = GetInt(args, "--active") ?? 0;
|
||||
int pollCount = GetInt(args, "--poll-count") ?? 10;
|
||||
int pollDelayMs = GetInt(args, "--poll-delay-ms") ?? 500;
|
||||
int connectPollCount = GetInt(args, "--connect-poll-count") ?? Math.Max(pollCount, 12);
|
||||
int connectPollDelayMs = GetInt(args, "--connect-poll-delay-ms") ?? Math.Max(pollDelayMs, 1000);
|
||||
string[] namespaces = GetStrings(args, "--namespace");
|
||||
if (namespaces.Length == 0)
|
||||
{
|
||||
namespaces =
|
||||
[
|
||||
"Default_ZB_MxDataProvider",
|
||||
"Default_ZB2_MxDataProvider",
|
||||
"net.tcp://localhost:4000/Default_ZB_MxDataProvider/IDataV3",
|
||||
"net.tcp://localhost:4000/Default_ZB_MxDataProvider",
|
||||
"net.tcp://localhost:9055/IDataV3SoapContract",
|
||||
"Galaxy",
|
||||
"ArchestrA",
|
||||
"Lmx",
|
||||
"localhost",
|
||||
"ZB",
|
||||
];
|
||||
}
|
||||
|
||||
Console.WriteLine($"process=x64:{Environment.Is64BitProcess}");
|
||||
Console.WriteLine($"tag={tag}");
|
||||
Console.WriteLine($"context={context}");
|
||||
|
||||
var consumer = new DataConsumerClass();
|
||||
var callback = new DataConsumerCallback();
|
||||
consumer.RegisterCallback(callback);
|
||||
try
|
||||
{
|
||||
int namespaceId = ResolveNamespace(consumer, namespaces);
|
||||
Console.WriteLine($"namespace_id={namespaceId}");
|
||||
Console.WriteLine($"namespace_callback_count={callback.NamespaceIds.Length}");
|
||||
PrintConnectionState(consumer, namespaceId, "after_namespace");
|
||||
PollConnection(consumer, namespaceId, connectPollCount, connectPollDelayMs);
|
||||
|
||||
consumer.ResolveReference(namespaceId, tag, context, itemId, userData);
|
||||
Console.WriteLine("resolve_reference=sent");
|
||||
PrintConnectionState(consumer, namespaceId, "after_resolve_reference");
|
||||
PollRegistration(consumer, pollCount, pollDelayMs);
|
||||
|
||||
consumer.subscribe(namespaceId, itemId, userData, bActive: 1);
|
||||
Console.WriteLine("subscribe=sent");
|
||||
PrintConnectionState(consumer, namespaceId, "after_subscribe");
|
||||
PollSubscription(consumer, pollCount, pollDelayMs);
|
||||
|
||||
consumer.ActivateSuspend(namespaceId, itemId, userData, active);
|
||||
Console.WriteLine($"activate_suspend=sent active={active}");
|
||||
PrintConnectionState(consumer, namespaceId, "after_activate_suspend");
|
||||
PollActivateSuspend(consumer, pollCount, pollDelayMs);
|
||||
|
||||
return 0;
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
consumer.UnregisterCallback();
|
||||
}
|
||||
catch (COMException ex)
|
||||
{
|
||||
Console.WriteLine($"unregister_callback_error=0x{ex.HResult:X8}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static int ProbeDataClient(string[] args)
|
||||
{
|
||||
string namespaceName = GetString(args, "--namespace") ?? "Galaxy";
|
||||
string[] endpoints = GetStrings(args, "--endpoint");
|
||||
if (endpoints.Length == 0)
|
||||
{
|
||||
endpoints =
|
||||
[
|
||||
"net.tcp://localhost:4000/Default_ZB_MxDataProvider/IDataV3",
|
||||
"net.tcp://localhost:4000/Default_ZB_MxDataProvider",
|
||||
"net.tcp://localhost:4000/IDataV3",
|
||||
"net.tcp://localhost:4000/MxDataService1/IDataV3",
|
||||
"net.tcp://localhost:9055/IDataV3SoapContract",
|
||||
];
|
||||
}
|
||||
|
||||
DataClientClass client;
|
||||
try
|
||||
{
|
||||
client = new DataClientClass();
|
||||
}
|
||||
catch (COMException ex)
|
||||
{
|
||||
Console.WriteLine($"dataclient_create_error=0x{ex.HResult:X8}");
|
||||
return 2;
|
||||
}
|
||||
|
||||
client.Initialize(namespaceName);
|
||||
Console.WriteLine($"dataclient_initialize={namespaceName}");
|
||||
foreach (string endpoint in endpoints)
|
||||
{
|
||||
var token = new _IUserToken();
|
||||
try
|
||||
{
|
||||
IArchestrAResult[] results = client.Connect2(endpoint, timeout: 5000, ref token, out uint clientId);
|
||||
Console.WriteLine($"dataclient_connect endpoint={endpoint} client_id={clientId} results={results?.Length ?? 0}");
|
||||
PrintArchestrAResults("dataclient_connect_result", results);
|
||||
Console.WriteLine($"dataclient_connected={client.IsIDataClientConnected()}");
|
||||
return 0;
|
||||
}
|
||||
catch (COMException ex)
|
||||
{
|
||||
Console.WriteLine($"dataclient_connect_error endpoint={endpoint} hresult=0x{ex.HResult:X8} last_error={SafeGetLastError(client)}");
|
||||
}
|
||||
}
|
||||
|
||||
return 2;
|
||||
}
|
||||
|
||||
private static void PrintArchestrAResults(string prefix, IArchestrAResult[]? results)
|
||||
{
|
||||
if (results is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < results.Length; i++)
|
||||
{
|
||||
IArchestrAResult result = results[i];
|
||||
try
|
||||
{
|
||||
Console.WriteLine($"{prefix}[{i}]=type:{result.GetType().FullName}");
|
||||
}
|
||||
catch (COMException ex)
|
||||
{
|
||||
Console.WriteLine($"{prefix}[{i}]_error=0x{ex.HResult:X8}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string SafeGetLastError(DataClientClass client)
|
||||
{
|
||||
try
|
||||
{
|
||||
return client.GetLastError();
|
||||
}
|
||||
catch (COMException ex)
|
||||
{
|
||||
return $"<GetLastError failed 0x{ex.HResult:X8}>";
|
||||
}
|
||||
}
|
||||
|
||||
private static int ResolveNamespace(DataConsumerClass consumer, string[] namespaces)
|
||||
{
|
||||
foreach (string ns in namespaces)
|
||||
{
|
||||
try
|
||||
{
|
||||
int namespaceId = consumer.ResolveNamespace(ns);
|
||||
Console.WriteLine($"resolve_namespace name={ns} id={namespaceId}");
|
||||
return namespaceId;
|
||||
}
|
||||
catch (COMException ex)
|
||||
{
|
||||
Console.WriteLine($"resolve_namespace_error name={ns} hresult=0x{ex.HResult:X8}");
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("No candidate namespace resolved.");
|
||||
}
|
||||
|
||||
private static void PollConnection(DataConsumerClass consumer, int namespaceId, int pollCount, int pollDelayMs)
|
||||
{
|
||||
for (int i = 0; i < pollCount; i++)
|
||||
{
|
||||
try
|
||||
{
|
||||
consumer.IsConnected(namespaceId, out int connected);
|
||||
Console.WriteLine($"connection_poll[{i}]={connected}");
|
||||
if (connected != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch (COMException ex)
|
||||
{
|
||||
Console.WriteLine($"connection_poll_error[{i}]=0x{ex.HResult:X8}");
|
||||
}
|
||||
|
||||
Thread.Sleep(pollDelayMs);
|
||||
}
|
||||
}
|
||||
|
||||
private static void PollRegistration(DataConsumerClass consumer, int pollCount, int pollDelayMs)
|
||||
{
|
||||
for (int i = 0; i < pollCount; i++)
|
||||
{
|
||||
uint result = consumer.ProcessRegistration2(
|
||||
out int size,
|
||||
out ItemRegistrationResponse[] responses,
|
||||
out MxResultCode[] resultCodes);
|
||||
Console.WriteLine($"registration_poll[{i}]=result:0x{result:X8} size:{size} responses:{responses?.Length ?? 0} result_codes:{resultCodes?.Length ?? 0}");
|
||||
if (responses is { Length: > 0 } || resultCodes is { Length: > 0 })
|
||||
{
|
||||
PrintRegistration(responses);
|
||||
PrintResultCodes("registration_result", resultCodes);
|
||||
return;
|
||||
}
|
||||
|
||||
Thread.Sleep(pollDelayMs);
|
||||
}
|
||||
}
|
||||
|
||||
private static void PrintConnectionState(DataConsumerClass consumer, int namespaceId, string label)
|
||||
{
|
||||
try
|
||||
{
|
||||
consumer.IsConnected(namespaceId, out int connected);
|
||||
Console.WriteLine($"is_connected[{label}]={connected}");
|
||||
}
|
||||
catch (COMException ex)
|
||||
{
|
||||
Console.WriteLine($"is_connected_error[{label}]=0x{ex.HResult:X8}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void PollSubscription(DataConsumerClass consumer, int pollCount, int pollDelayMs)
|
||||
{
|
||||
for (int i = 0; i < pollCount; i++)
|
||||
{
|
||||
uint result = consumer.ProcessSubscription2(
|
||||
out int size,
|
||||
out ItemSubscriptionResponse[] responses,
|
||||
out MxResultCode[] resultCodes);
|
||||
Console.WriteLine($"subscription_poll[{i}]=result:0x{result:X8} size:{size} responses:{responses?.Length ?? 0} result_codes:{resultCodes?.Length ?? 0}");
|
||||
if (responses is { Length: > 0 } || resultCodes is { Length: > 0 })
|
||||
{
|
||||
PrintSubscription(responses);
|
||||
PrintResultCodes("subscription_result", resultCodes);
|
||||
return;
|
||||
}
|
||||
|
||||
Thread.Sleep(pollDelayMs);
|
||||
}
|
||||
}
|
||||
|
||||
private static void PollActivateSuspend(DataConsumerClass consumer, int pollCount, int pollDelayMs)
|
||||
{
|
||||
for (int i = 0; i < pollCount; i++)
|
||||
{
|
||||
uint result = consumer.ProcessActivateSuspend2(
|
||||
out int size,
|
||||
out ItemActiveResponse[] responses,
|
||||
out MxResultCode[] resultCodes);
|
||||
Console.WriteLine($"activate_suspend_poll[{i}]=result:0x{result:X8} size:{size} responses:{responses?.Length ?? 0} result_codes:{resultCodes?.Length ?? 0}");
|
||||
if (responses is { Length: > 0 } || resultCodes is { Length: > 0 })
|
||||
{
|
||||
PrintActive(responses);
|
||||
PrintResultCodes("activate_suspend_result", resultCodes);
|
||||
return;
|
||||
}
|
||||
|
||||
Thread.Sleep(pollDelayMs);
|
||||
}
|
||||
}
|
||||
|
||||
private static void PrintRegistration(ItemRegistrationResponse[]? responses)
|
||||
{
|
||||
if (responses is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < responses.Length; i++)
|
||||
{
|
||||
ItemRegistrationResponse response = responses[i];
|
||||
Console.WriteLine($"registration[{i}]=item:{response.ItemId} user_data:{response.userData} status:0x{response.Status:X8} write_capability:{response.WriteCapability}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void PrintSubscription(ItemSubscriptionResponse[]? responses)
|
||||
{
|
||||
if (responses is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < responses.Length; i++)
|
||||
{
|
||||
ItemSubscriptionResponse response = responses[i];
|
||||
Console.WriteLine($"subscription[{i}]=item:{response.ItemId} user_data:{response.userData} status:0x{response.Status:X8}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void PrintActive(ItemActiveResponse[]? responses)
|
||||
{
|
||||
if (responses is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < responses.Length; i++)
|
||||
{
|
||||
ItemActiveResponse response = responses[i];
|
||||
Console.WriteLine($"activate_suspend[{i}]=item:{response.ItemId} user_data:{response.userData} status:0x{response.Status:X8}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void PrintResultCodes(string prefix, MxResultCode[]? resultCodes)
|
||||
{
|
||||
if (resultCodes is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < resultCodes.Length; i++)
|
||||
{
|
||||
MxResultCode resultCode = resultCodes[i];
|
||||
Console.WriteLine($"{prefix}[{i}]=namespace:{resultCode.lNamespaceId} error:0x{resultCode.ErrorCode:X8} arch_error:0x{resultCode.ArchestrErrorCode:X8} arch_specific:0x{resultCode.ArchestrSpecific:X8} arch_status:0x{resultCode.ArchestrStatus:X8}");
|
||||
}
|
||||
}
|
||||
|
||||
private static int? GetInt(string[] args, string name)
|
||||
{
|
||||
string? raw = GetString(args, name);
|
||||
if (raw is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return raw.StartsWith("0x", StringComparison.OrdinalIgnoreCase)
|
||||
? int.Parse(raw.Substring(2), NumberStyles.HexNumber, CultureInfo.InvariantCulture)
|
||||
: int.Parse(raw, CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static string? GetString(string[] args, string name)
|
||||
{
|
||||
string prefix = name + "=";
|
||||
return args.FirstOrDefault(arg => arg.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))?.Substring(prefix.Length);
|
||||
}
|
||||
|
||||
private static string[] GetStrings(string[] args, string name)
|
||||
{
|
||||
string prefix = name + "=";
|
||||
return args
|
||||
.Where(arg => arg.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
.Select(arg => arg.Substring(prefix.Length))
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static bool HasFlag(string[] args, string name)
|
||||
{
|
||||
return args.Any(arg => arg.Equals(name, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
|
||||
[ComVisible(true)]
|
||||
[ClassInterface(ClassInterfaceType.None)]
|
||||
internal sealed class DataConsumerCallback : IDataConsumerCallback
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly System.Collections.Generic.List<int> _namespaceIds = new();
|
||||
|
||||
public int[] NamespaceIds
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _namespaceIds.ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void NamespaceResolved(int lNamespaceId)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_namespaceIds.Add(lNamespaceId);
|
||||
}
|
||||
|
||||
Console.WriteLine($"callback_namespace_resolved={lNamespaceId}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MxNativeClient\MxNativeClient.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<PlatformTarget>x64</PlatformTarget>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MxNativeClient\MxNativeClient.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<PlatformTarget>x64</PlatformTarget>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,658 @@
|
||||
using MxNativeClient;
|
||||
using MxNativeCodec;
|
||||
|
||||
RunObjRefParseTest();
|
||||
RunOrpcStructureTests();
|
||||
RunObjectExporterMessageTests();
|
||||
RunNmxService2MessageTests();
|
||||
RunNmxSvcCallbackMessageTests();
|
||||
RunDceRpcAuthTrailerTests();
|
||||
RunRemUnknownMessageTests();
|
||||
RunDceRpcBindParseTest();
|
||||
RunDceRpcRequestParseTest();
|
||||
RunDceRpcResponseParseTest();
|
||||
await RunGalaxyRepositoryResolverTestAsync();
|
||||
await RunGalaxyRepositoryUserResolverTestAsync();
|
||||
RunManagedNmxService2ClientTransferBodyTests();
|
||||
RunCompatibilitySurfaceTests();
|
||||
RunRecoveryPolicyTests();
|
||||
RunOperationStatusTests();
|
||||
|
||||
Console.WriteLine("MxNativeClient protocol primitive tests passed.");
|
||||
|
||||
static void RunObjRefParseTest()
|
||||
{
|
||||
// Captured from: dotnet run MxNativeClient.Probe -- --dump-objref --objref-context=2
|
||||
byte[] prefix = FromHex(
|
||||
"4d 45 4f 57 01 00 00 00 00 00 00 00 00 00 00 00 c0 00 00 00 00 00 00 46 " +
|
||||
"00 00 00 00 01 00 00 00 c2 5b ab 3b b5 d2 f0 ea 53 46 0b 86 8b 5f 57 39 " +
|
||||
"20 f8 00 00 78 36 00 00 33 3c 4c 10 66 e0 ac 8b 95 00 7f 00");
|
||||
|
||||
var objRef = ComObjRef.Parse(prefix);
|
||||
AssertEqual("OBJREF signature", 0x574F454Du, objRef.Signature);
|
||||
AssertEqual("OBJREF flags", 1u, objRef.Flags);
|
||||
AssertEqual("OBJREF iid", new Guid("00000000-0000-0000-C000-000000000046"), objRef.Iid);
|
||||
AssertEqual("OBJREF OXID", 0xEAF0D2B53BAB5BC2ul, objRef.Oxid);
|
||||
AssertEqual("OBJREF OID", 0x39575F8B860B4653ul, objRef.Oid);
|
||||
AssertEqual("OBJREF entries", (ushort)149, objRef.DualStringEntries);
|
||||
|
||||
var std = StdObjRef.Parse(prefix.AsSpan(24, StdObjRef.EncodedLength));
|
||||
AssertEqual("STD OXID", objRef.Oxid, std.Oxid);
|
||||
AssertEqual("STD OID", objRef.Oid, std.Oid);
|
||||
AssertEqual("STD IPID", objRef.Ipid, std.Ipid);
|
||||
}
|
||||
|
||||
static void RunCompatibilitySurfaceTests()
|
||||
{
|
||||
Type serverType = typeof(MxNativeCompatibilityServer);
|
||||
AssertNotNull(
|
||||
"compat Write2 object timestamp overload",
|
||||
serverType.GetMethod(
|
||||
nameof(MxNativeCompatibilityServer.Write2),
|
||||
[
|
||||
typeof(int),
|
||||
typeof(int),
|
||||
typeof(object),
|
||||
typeof(object),
|
||||
typeof(int),
|
||||
]));
|
||||
AssertNotNull(
|
||||
"compat WriteSecured2 object timestamp overload",
|
||||
serverType.GetMethod(
|
||||
nameof(MxNativeCompatibilityServer.WriteSecured2),
|
||||
[
|
||||
typeof(int),
|
||||
typeof(int),
|
||||
typeof(int),
|
||||
typeof(int),
|
||||
typeof(object),
|
||||
typeof(object),
|
||||
]));
|
||||
AssertNotNull(
|
||||
"compat recover method",
|
||||
serverType.GetMethod(nameof(MxNativeCompatibilityServer.RecoverConnection), [typeof(int)]));
|
||||
AssertNotNull(
|
||||
"compat async recover method",
|
||||
serverType.GetMethod(
|
||||
nameof(MxNativeCompatibilityServer.RecoverConnectionAsync),
|
||||
[typeof(int), typeof(MxNativeRecoveryPolicy), typeof(CancellationToken)]));
|
||||
AssertNotNull("compat data-change event", serverType.GetEvent(nameof(MxNativeCompatibilityServer.DataChanged)));
|
||||
AssertNotNull("compat buffered data-change event", serverType.GetEvent(nameof(MxNativeCompatibilityServer.BufferedDataChanged)));
|
||||
AssertNotNull("compat write-complete event", serverType.GetEvent(nameof(MxNativeCompatibilityServer.WriteCompleted)));
|
||||
AssertNotNull("compat operation-complete event", serverType.GetEvent(nameof(MxNativeCompatibilityServer.OperationCompleted)));
|
||||
AssertNotNull("compat recovery-attempt event", serverType.GetEvent(nameof(MxNativeCompatibilityServer.RecoveryAttemptStarted)));
|
||||
AssertNotNull("compat recovery-failed event", serverType.GetEvent(nameof(MxNativeCompatibilityServer.RecoveryAttemptFailed)));
|
||||
AssertNotNull("compat recovery-completed event", serverType.GetEvent(nameof(MxNativeCompatibilityServer.RecoveryCompleted)));
|
||||
AssertNotNull(
|
||||
"session recover method",
|
||||
typeof(MxNativeSession).GetMethod(nameof(MxNativeSession.RecoverConnection), Type.EmptyTypes));
|
||||
AssertNotNull(
|
||||
"session async recover method",
|
||||
typeof(MxNativeSession).GetMethod(
|
||||
nameof(MxNativeSession.RecoverConnectionAsync),
|
||||
[typeof(MxNativeRecoveryPolicy), typeof(CancellationToken)]));
|
||||
AssertNotNull("session recovery-attempt event", typeof(MxNativeSession).GetEvent(nameof(MxNativeSession.RecoveryAttemptStarted)));
|
||||
AssertNotNull("session recovery-failed event", typeof(MxNativeSession).GetEvent(nameof(MxNativeSession.RecoveryAttemptFailed)));
|
||||
AssertNotNull("session recovery-completed event", typeof(MxNativeSession).GetEvent(nameof(MxNativeSession.RecoveryCompleted)));
|
||||
AssertNotNull(
|
||||
"callback recovery marker",
|
||||
typeof(MxNativeCallbackEvent).GetProperty(nameof(MxNativeCallbackEvent.IsDuringRecovery)));
|
||||
AssertNotNull(
|
||||
"operation status recovery marker",
|
||||
typeof(MxNativeOperationStatusEvent).GetProperty(nameof(MxNativeOperationStatusEvent.IsDuringRecovery)));
|
||||
AssertNotNull(
|
||||
"reference registration recovery marker",
|
||||
typeof(MxNativeReferenceRegistrationEvent).GetProperty(nameof(MxNativeReferenceRegistrationEvent.IsDuringRecovery)));
|
||||
AssertNotNull(
|
||||
"unparsed callback recovery marker",
|
||||
typeof(MxNativeUnparsedCallbackEvent).GetProperty(nameof(MxNativeUnparsedCallbackEvent.IsDuringRecovery)));
|
||||
AssertNotNull(
|
||||
"compat data-change recovery marker",
|
||||
typeof(MxNativeDataChangeEvent).GetProperty(nameof(MxNativeDataChangeEvent.IsDuringRecovery)));
|
||||
AssertNotNull(
|
||||
"compat write-complete recovery marker",
|
||||
typeof(MxNativeWriteCompleteEvent).GetProperty(nameof(MxNativeWriteCompleteEvent.IsDuringRecovery)));
|
||||
AssertNotNull(
|
||||
"compat buffered data-change recovery marker",
|
||||
typeof(MxNativeBufferedDataChangeEvent).GetProperty(nameof(MxNativeBufferedDataChangeEvent.IsDuringRecovery)));
|
||||
AssertNotNull(
|
||||
"managed nmx heartbeat method",
|
||||
typeof(ManagedNmxService2Client).GetMethod(
|
||||
nameof(ManagedNmxService2Client.SetHeartbeatSendInterval),
|
||||
[typeof(int), typeof(int)]));
|
||||
}
|
||||
|
||||
static void RunRecoveryPolicyTests()
|
||||
{
|
||||
new MxNativeRecoveryPolicy { MaxAttempts = 1, Delay = TimeSpan.Zero }.Validate();
|
||||
AssertThrows<ArgumentOutOfRangeException>(
|
||||
"recovery attempts validation",
|
||||
() => new MxNativeRecoveryPolicy { MaxAttempts = 0 }.Validate());
|
||||
AssertThrows<ArgumentOutOfRangeException>(
|
||||
"recovery delay validation",
|
||||
() => new MxNativeRecoveryPolicy { Delay = TimeSpan.FromMilliseconds(-1) }.Validate());
|
||||
}
|
||||
|
||||
static void RunOperationStatusTests()
|
||||
{
|
||||
AssertEqual("completion-only 00 parses", true, NmxOperationStatusMessage.TryParseInner([0x00], out var completionOk));
|
||||
AssertEqual("completion-only format", NmxOperationStatusFormat.CompletionOnly, completionOk.Format);
|
||||
AssertEqual("completion-only 00 code", (byte)0x00, completionOk.CompletionCode);
|
||||
AssertEqual("completion-only not mxaccess write complete", false, completionOk.IsMxAccessWriteComplete);
|
||||
AssertEqual("completion-only 00 success", (short)0, completionOk.Status.Success);
|
||||
AssertEqual("completion-only 00 category", MxStatusCategory.Unknown, completionOk.Status.Category);
|
||||
AssertEqual("completion-only 00 detail", (short)0, completionOk.Status.Detail);
|
||||
|
||||
AssertEqual("completion-only 41 parses", true, NmxOperationStatusMessage.TryParseInner([0x41], out var completionBadType));
|
||||
AssertEqual("completion-only 41 detail", (short)0x41, completionBadType.Status.Detail);
|
||||
AssertEqual("completion-only 41 category", MxStatusCategory.Unknown, completionBadType.Status.Category);
|
||||
AssertEqual("completion-only 41 not mxaccess write complete", false, completionBadType.IsMxAccessWriteComplete);
|
||||
|
||||
AssertEqual("completion-only ef parses", true, NmxOperationStatusMessage.TryParseInner([0xef], out var completionInternationalized));
|
||||
AssertEqual("completion-only ef detail", (short)0xef, completionInternationalized.Status.Detail);
|
||||
AssertEqual("completion-only ef not mxaccess write complete", false, completionInternationalized.IsMxAccessWriteComplete);
|
||||
|
||||
AssertEqual("status-word write complete parses", true, NmxOperationStatusMessage.TryParseInner([0x00, 0x00, 0x50, 0x80, 0x00], out var statusWord));
|
||||
AssertEqual("status-word format", NmxOperationStatusFormat.StatusWord, statusWord.Format);
|
||||
AssertEqual("status-word code", (ushort)0x8050, statusWord.StatusCode);
|
||||
AssertEqual("status-word mxaccess write complete", true, statusWord.IsMxAccessWriteComplete);
|
||||
AssertEqual("status-word status", MxStatus.WriteCompleteOk, statusWord.Status);
|
||||
}
|
||||
|
||||
static void AssertThrows<TException>(string name, Action action)
|
||||
where TException : Exception
|
||||
{
|
||||
try
|
||||
{
|
||||
action();
|
||||
}
|
||||
catch (TException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"{name}: expected {typeof(TException).Name}.");
|
||||
}
|
||||
|
||||
static void AssertNotNull(string name, object? actual)
|
||||
{
|
||||
if (actual is null)
|
||||
{
|
||||
throw new InvalidOperationException($"{name}: expected non-null value.");
|
||||
}
|
||||
}
|
||||
|
||||
static void RunOrpcStructureTests()
|
||||
{
|
||||
var cid = new Guid("01234567-89AB-CDEF-0123-456789ABCDEF");
|
||||
var thisCall = OrpcThis.Create(cid);
|
||||
byte[] encodedThis = thisCall.Encode();
|
||||
AssertEqual("ORPCTHIS length", OrpcThis.EncodedLengthWithoutExtensions, encodedThis.Length);
|
||||
AssertEqual("ORPCTHIS version", ComVersion.Version57, OrpcThis.Parse(encodedThis).Version);
|
||||
AssertEqual("ORPCTHIS cid", cid, OrpcThis.Parse(encodedThis).Cid);
|
||||
|
||||
var thatCall = new OrpcThat(0, 0);
|
||||
byte[] encodedThat = thatCall.Encode();
|
||||
AssertEqual("ORPCTHAT length", OrpcThat.EncodedLengthWithoutExtensions, encodedThat.Length);
|
||||
AssertEqual("ORPCTHAT flags", 0u, OrpcThat.Parse(encodedThat).Flags);
|
||||
|
||||
byte[] objRef = FromHex(
|
||||
"4d 45 4f 57 01 00 00 00 00 00 00 00 00 00 00 00 c0 00 00 00 00 00 00 46 " +
|
||||
"00 00 00 00 01 00 00 00 c2 5b ab 3b b5 d2 f0 ea 53 46 0b 86 8b 5f 57 39 " +
|
||||
"20 f8 00 00 78 36 00 00 33 3c 4c 10 66 e0 ac 8b 95 00 7f 00");
|
||||
var mip = new MInterfacePointer(objRef);
|
||||
AssertBytes("MInterfacePointer payload", objRef, MInterfacePointer.Parse(mip.Encode()).ObjRefBytes);
|
||||
}
|
||||
|
||||
static void RunRemUnknownMessageTests()
|
||||
{
|
||||
var ipid = new Guid("00007c14-3678-0000-fa76-a0a5cd73ac85");
|
||||
var iid = NmxProcedureMetadata.INmxService2;
|
||||
var cid = new Guid("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
|
||||
byte[] request = RemUnknownMessages.EncodeRemQueryInterfaceRequest(ipid, iid, cid, publicRefs: 5);
|
||||
|
||||
AssertEqual("RemQI length", 76, request.Length);
|
||||
AssertEqual("RemQI ORPCTHIS CID", cid, OrpcThis.Parse(request).Cid);
|
||||
AssertEqual("RemQI source IPID", ipid, new Guid(request.AsSpan(OrpcThis.EncodedLengthWithoutExtensions, 16)));
|
||||
AssertEqual("RemQI refs", 5u, ReadUInt32(request, 48));
|
||||
AssertEqual("RemQI iid count", (ushort)1, ReadUInt16(request, 52));
|
||||
AssertEqual("RemQI conformant max", 1u, ReadUInt32(request, 56));
|
||||
AssertEqual("RemQI requested IID", iid, new Guid(request.AsSpan(60, 16)));
|
||||
|
||||
var std = new StdObjRef(0, 5, 0x0102030405060708, 0x1112131415161718, ipid);
|
||||
byte[] resultBytes = new byte[RemQiResult.EncodedLength];
|
||||
std.Encode().CopyTo(resultBytes.AsSpan(8));
|
||||
var result = RemQiResult.Parse(resultBytes);
|
||||
AssertEqual("RemQI result hr", 0, result.HResult);
|
||||
AssertEqual("RemQI result ipid", ipid, result.StandardObjectReference.Ipid);
|
||||
}
|
||||
|
||||
static void RunObjectExporterMessageTests()
|
||||
{
|
||||
byte[] expected = FromHex("c2 5b ab 3b b5 d2 f0 ea 01 00 00 00 01 00 00 00 07 00 00 00");
|
||||
byte[] actual = ObjectExporterMessages.EncodeResolveOxidRequest(
|
||||
0xEAF0D2B53BAB5BC2,
|
||||
[ObjectExporterMessages.ProtseqNcacnIpTcp]);
|
||||
|
||||
AssertBytes("ResolveOxid request", expected, actual);
|
||||
|
||||
var failure = ObjectExporterMessages.ParseResolveOxidFailure(FromHex(
|
||||
"00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 05 00 00 00"));
|
||||
AssertEqual("ResolveOxid unauth status", 5u, failure.ErrorStatus);
|
||||
}
|
||||
|
||||
static void RunNmxService2MessageTests()
|
||||
{
|
||||
var cid = new Guid("11111111-2222-3333-4444-555555555555");
|
||||
var orpcThis = OrpcThis.Create(cid);
|
||||
|
||||
byte[] getVersion = NmxService2Messages.EncodeGetPartnerVersionRequest(orpcThis, 1, 2, 0x7ffd);
|
||||
AssertEqual("GetPartnerVersion opnum", (ushort)11, NmxService2Messages.GetPartnerVersionOpnum);
|
||||
AssertEqual("GetPartnerVersion request length", 44, getVersion.Length);
|
||||
AssertEqual("GetPartnerVersion cid", cid, OrpcThis.Parse(getVersion).Cid);
|
||||
AssertEqual("GetPartnerVersion galaxy", 1u, ReadUInt32(getVersion, 32));
|
||||
AssertEqual("GetPartnerVersion platform", 2u, ReadUInt32(getVersion, 36));
|
||||
AssertEqual("GetPartnerVersion engine", 0x7ffdu, ReadUInt32(getVersion, 40));
|
||||
|
||||
byte[] response = FromHex("00 00 00 00 00 00 00 00 06 00 00 00 00 00 00 00");
|
||||
var parsed = NmxService2Messages.ParseGetPartnerVersionResponse(response);
|
||||
AssertEqual("GetPartnerVersion response version", 6, parsed.PartnerVersion);
|
||||
AssertEqual("GetPartnerVersion response hr", 0, parsed.HResult);
|
||||
|
||||
byte[] connect = NmxService2Messages.EncodeConnectRequest(orpcThis, 0x7fee, 1, 1, 0x7ffd);
|
||||
AssertEqual("Connect request length", 48, connect.Length);
|
||||
AssertEqual("Connect local engine", 0x7feeu, ReadUInt32(connect, 32));
|
||||
|
||||
byte[] subscriber = NmxService2Messages.EncodeSubscriberEngineRequest(orpcThis, 0x7fee, 1, 1, 0x7ffd);
|
||||
AssertEqual("Subscriber request length", 48, subscriber.Length);
|
||||
AssertEqual("Subscriber local engine", 0x7feeu, ReadUInt32(subscriber, 32));
|
||||
AssertEqual("Subscriber remote engine", 0x7ffdu, ReadUInt32(subscriber, 44));
|
||||
|
||||
byte[] unregister = NmxService2Messages.EncodeUnregisterEngineRequest(orpcThis, 0x7fee);
|
||||
AssertEqual("UnRegisterEngine request length", 36, unregister.Length);
|
||||
AssertEqual("UnRegisterEngine local engine", 0x7feeu, ReadUInt32(unregister, 32));
|
||||
|
||||
byte[] heartbeat = NmxService2Messages.EncodeSetHeartbeatSendIntervalRequest(orpcThis, 5, 3);
|
||||
AssertEqual("Heartbeat request length", 40, heartbeat.Length);
|
||||
AssertEqual("Heartbeat ticks", 5u, ReadUInt32(heartbeat, 32));
|
||||
AssertEqual("Heartbeat misses", 3u, ReadUInt32(heartbeat, 36));
|
||||
|
||||
byte[] transferBody = FromHex(
|
||||
"01 00 00 00 00 00 00 00 00 00 9e 91 04 00 01 00 00 00 " +
|
||||
"01 00 00 00 fb 7f 00 00 01 00 00 00 01 00 00 00 02 00 " +
|
||||
"00 00 02 02 00 00 30 75 00 00");
|
||||
byte[] transfer = NmxService2Messages.EncodeTransferDataRequest(orpcThis, 1, 1, 2, transferBody);
|
||||
AssertEqual("TransferData opnum", (ushort)6, NmxService2Messages.TransferDataOpnum);
|
||||
AssertEqual("TransferData request length", 100, transfer.Length);
|
||||
AssertEqual("TransferData galaxy", 1u, ReadUInt32(transfer, 32));
|
||||
AssertEqual("TransferData platform", 1u, ReadUInt32(transfer, 36));
|
||||
AssertEqual("TransferData engine", 2u, ReadUInt32(transfer, 40));
|
||||
AssertEqual("TransferData lSize", 46u, ReadUInt32(transfer, 44));
|
||||
AssertEqual("TransferData max count", 46u, ReadUInt32(transfer, 48));
|
||||
AssertBytes("TransferData body", transferBody, transfer.AsSpan(52, transferBody.Length));
|
||||
|
||||
var hresult = NmxService2Messages.ParseHResultResponse(FromHex("00 00 00 00 00 00 00 00 00 00 00 00"));
|
||||
AssertEqual("HRESULT response", 0, hresult.HResult);
|
||||
|
||||
byte[] bstr = NmxService2Messages.EncodeBstrUserMarshal("NmxComProxyWire5");
|
||||
AssertBytes(
|
||||
"BSTR user marshal",
|
||||
FromHex(
|
||||
"10 00 00 00 20 00 00 00 10 00 00 00 4e 00 6d 00 " +
|
||||
"78 00 43 00 6f 00 6d 00 50 00 72 00 6f 00 78 00 " +
|
||||
"79 00 57 00 69 00 72 00 65 00 35 00"),
|
||||
bstr);
|
||||
|
||||
byte[] register = NmxService2Messages.EncodeRegisterEngine2Request(orpcThis, 0x7104, "NmxComProxyWire5", 6);
|
||||
AssertEqual("RegisterEngine2 opnum", (ushort)10, NmxService2Messages.RegisterEngine2Opnum);
|
||||
AssertEqual("RegisterEngine2 null callback length", 92, register.Length);
|
||||
AssertEqual("RegisterEngine2 engine", 0x7104u, ReadUInt32(register, 32));
|
||||
AssertEqual("RegisterEngine2 BSTR marker", 0x72657355u, ReadUInt32(register, 36));
|
||||
AssertBytes("RegisterEngine2 BSTR", bstr, register.AsSpan(40, bstr.Length));
|
||||
AssertEqual("RegisterEngine2 version", 6u, ReadUInt32(register, 40 + bstr.Length));
|
||||
AssertEqual("RegisterEngine2 null callback", 0u, ReadUInt32(register, 44 + bstr.Length));
|
||||
|
||||
byte[] oddRegister = NmxService2Messages.EncodeRegisterEngine2Request(orpcThis, 0x7200, "MxManagedNullCallback", 6);
|
||||
AssertEqual("RegisterEngine2 odd-name length", 104, oddRegister.Length);
|
||||
AssertEqual("RegisterEngine2 odd-name marker", 0x72657355u, ReadUInt32(oddRegister, 36));
|
||||
AssertEqual("RegisterEngine2 odd-name version", 6u, ReadUInt32(oddRegister, 96));
|
||||
AssertEqual("RegisterEngine2 odd-name null callback", 0u, ReadUInt32(oddRegister, 100));
|
||||
|
||||
byte[] compactObjRef = new byte[68];
|
||||
byte[] callbackRegister = NmxService2Messages.EncodeRegisterEngine2Request(orpcThis, 0x7108, "NmxCallbackX86", 6, compactObjRef);
|
||||
AssertEqual("RegisterEngine2 callback marker", 0x00020000u, ReadUInt32(callbackRegister, 84));
|
||||
AssertEqual("RegisterEngine2 callback size a", 68u, ReadUInt32(callbackRegister, 88));
|
||||
AssertEqual("RegisterEngine2 callback size b", 68u, ReadUInt32(callbackRegister, 92));
|
||||
}
|
||||
|
||||
static void RunNmxSvcCallbackMessageTests()
|
||||
{
|
||||
var cid = new Guid("22222222-3333-4444-5555-666666666666");
|
||||
byte[] body = FromHex("01 02 03 04 05");
|
||||
byte[] request = new byte[OrpcThis.EncodedLengthWithoutExtensions + 8 + body.Length + 3];
|
||||
OrpcThis.Create(cid).Encode().CopyTo(request.AsSpan());
|
||||
WriteUInt32(request, 32, (uint)body.Length);
|
||||
WriteUInt32(request, 36, (uint)body.Length);
|
||||
body.CopyTo(request.AsSpan(40));
|
||||
|
||||
var parsed = NmxSvcCallbackMessages.ParseCallbackRequest(request);
|
||||
AssertEqual("callback iid", NmxProcedureMetadata.INmxSvcCallback, NmxSvcCallbackMessages.InterfaceId);
|
||||
AssertEqual("callback data opnum", (ushort)3, NmxSvcCallbackMessages.DataReceivedOpnum);
|
||||
AssertEqual("callback status opnum", (ushort)4, NmxSvcCallbackMessages.StatusReceivedOpnum);
|
||||
AssertEqual("callback cid", cid, parsed.OrpcThis.Cid);
|
||||
AssertBytes("callback body", body, parsed.Body);
|
||||
|
||||
byte[] response = NmxSvcCallbackMessages.EncodeCallbackResponse(0);
|
||||
AssertEqual("callback response length", 12, response.Length);
|
||||
AssertEqual("callback response hr", 0u, ReadUInt32(response, 8));
|
||||
}
|
||||
|
||||
static void RunDceRpcAuthTrailerTests()
|
||||
{
|
||||
var trailer = new DceRpcAuthTrailer(DceRpcAuthType.WinNt, DceRpcAuthLevel.Connect, 0, 0, 79231);
|
||||
byte[] token = [0x4e, 0x54, 0x4c, 0x4d];
|
||||
var bind = new DceRpcBindPdu(
|
||||
new DceRpcPduHeader(5, 0, DceRpcPacketType.Bind, 0x03, 0x10, 0, 0, 1),
|
||||
4280,
|
||||
4280,
|
||||
0,
|
||||
[
|
||||
new DceRpcPresentationContext(
|
||||
0,
|
||||
new DceRpcSyntaxId(ObjectExporterMessages.IObjectExporter, 0, 0),
|
||||
[DceRpcSyntaxId.Ndr20]),
|
||||
]);
|
||||
|
||||
byte[] pdu = bind.EncodeWithAuth(trailer, token);
|
||||
var header = DceRpcPduHeader.Parse(pdu);
|
||||
AssertEqual("auth bind token len", (ushort)4, header.AuthLength);
|
||||
var auth = DceRpcBindPdu.ReadAuthValue(pdu);
|
||||
AssertEqual("auth trailer type", DceRpcAuthType.WinNt, auth.Trailer.AuthType);
|
||||
AssertEqual("auth trailer level", DceRpcAuthLevel.Connect, auth.Trailer.AuthLevel);
|
||||
AssertBytes("auth token", token, auth.Token.Span);
|
||||
}
|
||||
|
||||
static void RunDceRpcBindParseTest()
|
||||
{
|
||||
byte[] bind = FromHex(
|
||||
"05 00 0b 03 10 00 00 00 74 00 00 00 02 00 00 00 d0 16 d0 16 00 00 00 00 " +
|
||||
"02 00 00 00 00 00 01 00 df 90 0c 4e 9d e3 64 41 a4 21 ac e8 94 84 c6 02 " +
|
||||
"01 00 00 00 04 5d 88 8a eb 1c c9 11 9f e8 08 00 2b 10 48 60 02 00 00 00 " +
|
||||
"01 00 01 00 df 90 0c 4e 9d e3 64 41 a4 21 ac e8 94 84 c6 02 01 00 00 00 " +
|
||||
"2c 1c b7 6c 12 98 40 45 03 00 00 00 00 00 00 00 01 00 00 00");
|
||||
|
||||
var pdu = DceRpcBindPdu.Parse(bind);
|
||||
AssertEqual("bind type", DceRpcPacketType.Bind, pdu.Header.PacketType);
|
||||
AssertEqual("bind frag", (ushort)116, pdu.Header.FragmentLength);
|
||||
AssertEqual("bind call", 2u, pdu.Header.CallId);
|
||||
AssertEqual("bind contexts", 2, pdu.PresentationContexts.Count);
|
||||
AssertEqual("bind context0", (ushort)0, pdu.PresentationContexts[0].ContextId);
|
||||
AssertEqual("bind context0 syntax", new Guid("4E0C90DF-E39D-4164-A421-ACE89484C602"), pdu.PresentationContexts[0].AbstractSyntax.Uuid);
|
||||
|
||||
byte[] encoded = pdu.Encode();
|
||||
AssertBytes("bind roundtrip", bind, encoded);
|
||||
}
|
||||
|
||||
static void RunDceRpcRequestParseTest()
|
||||
{
|
||||
byte[] request = FromHex(
|
||||
"05 00 00 03 10 00 00 00 28 00 00 00 02 00 00 00 10 00 00 00 00 00 00 00 " +
|
||||
"8c 9e 28 85 62 f2 97 47 84 df 3e 4b a6 0e 98 7f");
|
||||
|
||||
var pdu = DceRpcRequestPdu.Parse(request);
|
||||
AssertEqual("request type", DceRpcPacketType.Request, pdu.Header.PacketType);
|
||||
AssertEqual("request call", 2u, pdu.Header.CallId);
|
||||
AssertEqual("request context", (ushort)0, pdu.ContextId);
|
||||
AssertEqual("request opnum", (ushort)0, pdu.Opnum);
|
||||
AssertEqual("request stub length", 16, pdu.StubData.Length);
|
||||
}
|
||||
|
||||
static void RunDceRpcResponseParseTest()
|
||||
{
|
||||
byte[] response = FromHex(
|
||||
"05 00 02 03 10 00 00 00 2c 00 00 00 02 00 00 00 14 00 00 00 00 00 00 00 " +
|
||||
"00 00 00 00 e5 96 74 5a a4 eb c2 4f b6 13 bf 8a c5 a8 3b 6e");
|
||||
|
||||
var pdu = DceRpcResponsePdu.Parse(response);
|
||||
AssertEqual("response type", DceRpcPacketType.Response, pdu.Header.PacketType);
|
||||
AssertEqual("response call", 2u, pdu.Header.CallId);
|
||||
AssertEqual("response context", (ushort)0, pdu.ContextId);
|
||||
AssertEqual("response stub length", 20, pdu.StubData.Length);
|
||||
}
|
||||
|
||||
static async Task RunGalaxyRepositoryResolverTestAsync()
|
||||
{
|
||||
var resolver = new GalaxyRepositoryTagResolver();
|
||||
GalaxyTagMetadata tag = await resolver.ResolveAsync("TestChildObject.TestInt");
|
||||
|
||||
AssertEqual("GR object", "TestChildObject", tag.ObjectTagName);
|
||||
AssertEqual("GR attribute", "TestInt", tag.AttributeName);
|
||||
AssertEqual("GR primitive", (string?)null, tag.PrimitiveName);
|
||||
AssertEqual("GR platform", (ushort)1, tag.PlatformId);
|
||||
AssertEqual("GR engine", (ushort)2, tag.EngineId);
|
||||
AssertEqual("GR object id", (ushort)5, tag.ObjectId);
|
||||
AssertEqual("GR primitive id", (short)2, tag.PrimitiveId);
|
||||
AssertEqual("GR attribute id", (short)155, tag.AttributeId);
|
||||
AssertEqual("GR property id", (short)10, tag.PropertyId);
|
||||
AssertEqual("GR data type", (short)2, tag.MxDataType);
|
||||
AssertEqual("GR is array", false, tag.IsArray);
|
||||
AssertEqual("GR source", "dynamic", tag.AttributeSource);
|
||||
AssertEqual("GR value kind", MxValueKind.Int32, tag.ToValueKind());
|
||||
AssertEqual("GR supported value kind", true, tag.IsSupportedValueKind);
|
||||
AssertEqual("GR try value kind", true, tag.TryGetValueKind(out var testIntKind));
|
||||
AssertEqual("GR try value kind result", MxValueKind.Int32, testIntKind);
|
||||
|
||||
byte[] expected = FromHex("01 00 01 00 02 00 05 00 36 d7 02 00 9b 00 0a 00 3e da 00 00");
|
||||
AssertBytes("GR synthesized handle", expected, tag.ToReferenceHandle().Encode());
|
||||
|
||||
GalaxyTagMetadata bufferedProperty = await resolver.ResolveAsync("TestChildObject.TestInt.property(buffer)");
|
||||
AssertEqual("GR property(buffer) base attribute", "TestInt", bufferedProperty.AttributeName);
|
||||
AssertEqual("GR property(buffer) id", GalaxyTagMetadata.BufferPropertyId, bufferedProperty.PropertyId);
|
||||
AssertEqual("GR property(buffer) marker", true, bufferedProperty.IsBufferProperty);
|
||||
AssertBytes(
|
||||
"GR property(buffer) native literal handle",
|
||||
FromHex("01 00 01 00 02 00 05 00 36 d7 02 00 9b 00 32 00 3e da 00 00"),
|
||||
bufferedProperty.ToReferenceHandle().Encode());
|
||||
|
||||
GalaxyTagMetadata shortDesc = await resolver.ResolveAsync("TestChildObject.ShortDesc");
|
||||
AssertEqual("GR ShortDesc category-independent property", (short)10, shortDesc.PropertyId);
|
||||
AssertBytes(
|
||||
"GR ShortDesc native value handle",
|
||||
FromHex("01 00 01 00 02 00 05 00 36 d7 02 00 65 00 0a 00 6d 02 00 00"),
|
||||
shortDesc.ToReferenceHandle().Encode());
|
||||
|
||||
GalaxyTagMetadata secured = await resolver.ResolveAsync("TestMachine_001.ProtectedValue");
|
||||
AssertEqual("GR secured object", "TestMachine_001", secured.ObjectTagName);
|
||||
AssertEqual("GR secured attribute", "ProtectedValue", secured.AttributeName);
|
||||
AssertEqual("GR secured object id", (ushort)6, secured.ObjectId);
|
||||
AssertEqual("GR secured attr id", (short)166, secured.AttributeId);
|
||||
AssertEqual("GR secured class", (short)2, secured.SecurityClassification);
|
||||
AssertEqual("GR secured kind", MxValueKind.Boolean, secured.ToValueKind());
|
||||
AssertBytes(
|
||||
"GR secured handle",
|
||||
FromHex("01 00 01 00 02 00 06 00 08 f4 02 00 a6 00 0a 00 bb 67 00 00"),
|
||||
secured.ToReferenceHandle().Encode());
|
||||
|
||||
GalaxyTagMetadata verified = await resolver.ResolveAsync("TestMachine_001.ProtectedValue1");
|
||||
AssertEqual("GR verified attr id", (short)167, verified.AttributeId);
|
||||
AssertEqual("GR verified class", (short)3, verified.SecurityClassification);
|
||||
AssertBytes(
|
||||
"GR verified handle",
|
||||
FromHex("01 00 01 00 02 00 06 00 08 f4 02 00 a7 00 0a 00 26 8a 00 00"),
|
||||
verified.ToReferenceHandle().Encode());
|
||||
|
||||
GalaxyTagMetadata elapsed = await resolver.ResolveAsync("TestMachine_001.TestAlarm001.Alarm.TimeDeadband");
|
||||
AssertEqual("GR dotted primitive object", "TestMachine_001", elapsed.ObjectTagName);
|
||||
AssertEqual("GR dotted primitive", "TestAlarm001", elapsed.PrimitiveName);
|
||||
AssertEqual("GR dotted primitive attribute", "Alarm.TimeDeadband", elapsed.AttributeName);
|
||||
AssertEqual("GR dotted primitive data type", (short)MxDataType.ElapsedTime, elapsed.MxDataType);
|
||||
AssertEqual("GR dotted primitive supported", false, elapsed.IsSupportedValueKind);
|
||||
var elapsedProjection = elapsed.ProjectWriteValue(TimeSpan.FromSeconds(1));
|
||||
AssertEqual("GR elapsed projection kind", MxValueKind.Int32, elapsedProjection.ValueKind);
|
||||
AssertEqual("GR elapsed projection value", 1000, elapsedProjection.Value);
|
||||
|
||||
GalaxyTagMetadata internationalized = await resolver.ResolveAsync("DevAppEngine.Scheduler._EngUnitsMB");
|
||||
AssertEqual("GR internationalized primitive", "Scheduler", internationalized.PrimitiveName);
|
||||
AssertEqual("GR internationalized attribute", "_EngUnitsMB", internationalized.AttributeName);
|
||||
AssertEqual("GR internationalized data type", (short)MxDataType.InternationalizedString, internationalized.MxDataType);
|
||||
AssertEqual("GR internationalized supported", false, internationalized.IsSupportedValueKind);
|
||||
var internationalizedProjection = internationalized.ProjectWriteValue("MB");
|
||||
AssertEqual("GR internationalized projection kind", MxValueKind.String, internationalizedProjection.ValueKind);
|
||||
AssertEqual("GR internationalized projection value", "MB", internationalizedProjection.Value);
|
||||
|
||||
IReadOnlyList<GalaxyTagMetadata> browsed = await resolver.BrowseAsync("TestChildObject", "Test%", maxRows: 25);
|
||||
AssertEqual("GR browse includes TestInt", true, browsed.Any(item => item.ObjectTagName == "TestChildObject" && item.AttributeName == "TestInt"));
|
||||
AssertEqual("GR browse max respected", true, browsed.Count <= 25);
|
||||
}
|
||||
|
||||
static async Task RunGalaxyRepositoryUserResolverTestAsync()
|
||||
{
|
||||
var resolver = new GalaxyRepositoryUserResolver();
|
||||
|
||||
GalaxyUserProfile administrator = await resolver.ResolveByNameAsync("Administrator");
|
||||
AssertEqual("GR user name", "Administrator", administrator.UserProfileName);
|
||||
AssertEqual("GR user id", 2, administrator.UserProfileId);
|
||||
AssertEqual("GR user guid", new Guid("9222FBBA-53F4-457E-8B37-C93A9A250B4A"), administrator.UserGuid);
|
||||
AssertEqual("GR user group", "Default", administrator.DefaultSecurityGroup);
|
||||
AssertEqual("GR user role administrator", true, administrator.Roles.Contains("Administrator"));
|
||||
AssertEqual("GR user role default", true, administrator.Roles.Contains("Default"));
|
||||
AssertEqual(
|
||||
"GR user guid to profile id",
|
||||
administrator.UserProfileId,
|
||||
await resolver.ResolveUserProfileIdByGuidAsync(administrator.UserGuid));
|
||||
}
|
||||
|
||||
static void RunManagedNmxService2ClientTransferBodyTests()
|
||||
{
|
||||
var tag = new GalaxyTagMetadata(
|
||||
ObjectTagName: "TestChildObject",
|
||||
AttributeName: "ShortDesc",
|
||||
PrimitiveName: null,
|
||||
PlatformId: 1,
|
||||
EngineId: 2,
|
||||
ObjectId: 5,
|
||||
PrimitiveId: 2,
|
||||
AttributeId: 101,
|
||||
PropertyId: 10,
|
||||
MxDataType: (short)MxDataType.InternationalizedString,
|
||||
IsArray: false,
|
||||
SecurityClassification: 1,
|
||||
AttributeSource: "dynamic");
|
||||
|
||||
byte[] writeTransfer = ManagedNmxService2Client.EncodeWriteTransferBody(
|
||||
localEngineId: 0x7ffa,
|
||||
tag,
|
||||
"hello-native",
|
||||
writeIndex: 1,
|
||||
clientToken: 0x0bff6894);
|
||||
var writeEnvelope = NmxTransferEnvelope.Parse(writeTransfer);
|
||||
AssertEqual("client write transfer kind", NmxTransferMessageKind.Write, writeEnvelope.MessageKind);
|
||||
AssertEqual("client write target engine", 2, writeEnvelope.TargetEngineId);
|
||||
AssertEqual("client write command", NmxWriteMessage.Command, writeEnvelope.InnerBody.Span[0]);
|
||||
AssertEqual("client write value kind", NmxWriteMessage.GetWireKind(MxValueKind.String), writeEnvelope.InnerBody.Span[NmxWriteMessage.KindOffset]);
|
||||
AssertBytes(
|
||||
"client write native ShortDesc body",
|
||||
FromHex("37 01 00 05 00 36 d7 02 00 65 00 0a 00 6d 02 00 00 05 1e 00 00 00 1a 00 00 00 68 00 65 00 6c 00 6c 00 6f 00 2d 00 6e 00 61 00 74 00 69 00 76 00 65 00 00 00 ff ff 00 00 00 00 00 00 00 00 94 68 ff 0b 01 00 00 00"),
|
||||
writeEnvelope.InnerBody.Span);
|
||||
|
||||
Guid correlationId = new("cd9ccac0-6532-46b0-a585-a583b2e77a5d");
|
||||
byte[] adviseTransfer = ManagedNmxService2Client.EncodeAdviseSupervisoryTransferBody(
|
||||
localEngineId: 0x7ffb,
|
||||
tag,
|
||||
correlationId);
|
||||
var adviseEnvelope = NmxTransferEnvelope.Parse(adviseTransfer);
|
||||
AssertEqual("client advise transfer kind", NmxTransferMessageKind.ItemControl, adviseEnvelope.MessageKind);
|
||||
var advise = NmxItemControlMessage.Parse(adviseEnvelope.InnerBody.Span);
|
||||
AssertEqual("client advise command", NmxItemControlCommand.AdviseSupervisory, advise.Command);
|
||||
AssertEqual("client advise correlation", correlationId, advise.ItemCorrelationId);
|
||||
AssertEqual("client advise property", (short)10, advise.PropertyId);
|
||||
|
||||
AssertEqual(
|
||||
"compat suppress empty internationalized string",
|
||||
true,
|
||||
MxNativeCompatibilityServer.ShouldSuppressCompatibilityDataChange(tag, string.Empty));
|
||||
AssertEqual(
|
||||
"compat keep populated internationalized string",
|
||||
false,
|
||||
MxNativeCompatibilityServer.ShouldSuppressCompatibilityDataChange(tag, "populated"));
|
||||
AssertEqual(
|
||||
"compat keep normal empty string",
|
||||
false,
|
||||
MxNativeCompatibilityServer.ShouldSuppressCompatibilityDataChange(tag with { MxDataType = (short)MxDataType.String }, string.Empty));
|
||||
|
||||
var securedTag = new GalaxyTagMetadata(
|
||||
ObjectTagName: "TestMachine_001",
|
||||
AttributeName: "ProtectedValue",
|
||||
PrimitiveName: null,
|
||||
PlatformId: 1,
|
||||
EngineId: 2,
|
||||
ObjectId: 6,
|
||||
PrimitiveId: 2,
|
||||
AttributeId: 166,
|
||||
PropertyId: 10,
|
||||
MxDataType: (short)MxDataType.Boolean,
|
||||
IsArray: false,
|
||||
SecurityClassification: 2,
|
||||
AttributeSource: "dynamic");
|
||||
byte[] securedTransfer = ManagedNmxService2Client.EncodeWriteSecured2TransferBody(
|
||||
localEngineId: 0x7ffa,
|
||||
securedTag,
|
||||
true,
|
||||
new DateTime(2026, 4, 25, 8, 30, 0),
|
||||
"MxManagedSecured2",
|
||||
currentUserId: 1,
|
||||
verifierUserId: 0);
|
||||
var securedEnvelope = NmxTransferEnvelope.Parse(securedTransfer);
|
||||
AssertEqual("client secured2 transfer kind", NmxTransferMessageKind.Write, securedEnvelope.MessageKind);
|
||||
AssertEqual("client secured2 command", NmxSecuredWrite2Message.Command, securedEnvelope.InnerBody.Span[0]);
|
||||
AssertEqual("client secured2 bool kind", NmxWriteMessage.GetWireKind(MxValueKind.Boolean), securedEnvelope.InnerBody.Span[NmxWriteMessage.KindOffset]);
|
||||
}
|
||||
|
||||
static byte[] FromHex(string hex)
|
||||
{
|
||||
string[] parts = hex.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
byte[] bytes = new byte[parts.Length];
|
||||
for (int i = 0; i < parts.Length; i++)
|
||||
{
|
||||
bytes[i] = Convert.ToByte(parts[i], 16);
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
static ushort ReadUInt16(ReadOnlySpan<byte> buffer, int offset)
|
||||
{
|
||||
return (ushort)(buffer[offset] | (buffer[offset + 1] << 8));
|
||||
}
|
||||
|
||||
static uint ReadUInt32(ReadOnlySpan<byte> buffer, int offset)
|
||||
{
|
||||
return (uint)(buffer[offset]
|
||||
| (buffer[offset + 1] << 8)
|
||||
| (buffer[offset + 2] << 16)
|
||||
| (buffer[offset + 3] << 24));
|
||||
}
|
||||
|
||||
static void WriteUInt32(Span<byte> buffer, int offset, uint value)
|
||||
{
|
||||
buffer[offset] = (byte)value;
|
||||
buffer[offset + 1] = (byte)(value >> 8);
|
||||
buffer[offset + 2] = (byte)(value >> 16);
|
||||
buffer[offset + 3] = (byte)(value >> 24);
|
||||
}
|
||||
|
||||
static void AssertEqual<T>(string name, T expected, T actual)
|
||||
{
|
||||
if (!EqualityComparer<T>.Default.Equals(expected, actual))
|
||||
{
|
||||
throw new InvalidOperationException($"{name}: expected {expected}, got {actual}.");
|
||||
}
|
||||
}
|
||||
|
||||
static void AssertBytes(string name, ReadOnlySpan<byte> expected, ReadOnlySpan<byte> actual)
|
||||
{
|
||||
if (!expected.SequenceEqual(actual))
|
||||
{
|
||||
throw new InvalidOperationException($"{name}: byte mismatch.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace MxNativeClient;
|
||||
|
||||
public sealed record ComObjRef(
|
||||
uint Signature,
|
||||
uint Flags,
|
||||
Guid Iid,
|
||||
uint StandardFlags,
|
||||
uint PublicRefs,
|
||||
ulong Oxid,
|
||||
ulong Oid,
|
||||
Guid Ipid,
|
||||
ushort DualStringEntries,
|
||||
ushort DualStringSecurityOffset,
|
||||
IReadOnlyList<ComDualStringEntry> DualStringEntriesDecoded)
|
||||
{
|
||||
public static ComObjRef Parse(ReadOnlySpan<byte> buffer)
|
||||
{
|
||||
if (buffer.Length < 68)
|
||||
{
|
||||
throw new ArgumentException("OBJREF buffer is too short.", nameof(buffer));
|
||||
}
|
||||
|
||||
ushort dualStringEntries = ReadUInt16(buffer, 64);
|
||||
ushort securityOffset = ReadUInt16(buffer, 66);
|
||||
|
||||
return new ComObjRef(
|
||||
Signature: ReadUInt32(buffer, 0),
|
||||
Flags: ReadUInt32(buffer, 4),
|
||||
Iid: new Guid(buffer.Slice(8, 16)),
|
||||
StandardFlags: ReadUInt32(buffer, 24),
|
||||
PublicRefs: ReadUInt32(buffer, 28),
|
||||
Oxid: ReadUInt64(buffer, 32),
|
||||
Oid: ReadUInt64(buffer, 40),
|
||||
Ipid: new Guid(buffer.Slice(48, 16)),
|
||||
DualStringEntries: dualStringEntries,
|
||||
DualStringSecurityOffset: securityOffset,
|
||||
DualStringEntriesDecoded: DecodeDualStringArray(buffer[68..], dualStringEntries, securityOffset));
|
||||
}
|
||||
|
||||
public IEnumerable<string> ToDiagnosticLines()
|
||||
{
|
||||
yield return $"objref_signature=0x{Signature:X8}";
|
||||
yield return $"objref_flags=0x{Flags:X8}";
|
||||
yield return $"objref_iid={Iid}";
|
||||
yield return $"std_flags=0x{StandardFlags:X8}";
|
||||
yield return $"std_public_refs={PublicRefs}";
|
||||
yield return $"std_oxid=0x{Oxid:X16}";
|
||||
yield return $"std_oid=0x{Oid:X16}";
|
||||
yield return $"std_ipid={Ipid}";
|
||||
yield return $"dual_string_entries={DualStringEntries}";
|
||||
yield return $"dual_string_security_offset={DualStringSecurityOffset}";
|
||||
yield return $"dual_strings={string.Join("|", DualStringEntriesDecoded.Select(static entry => entry.ToDiagnosticString()))}";
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ComDualStringEntry> DecodeDualStringArray(ReadOnlySpan<byte> data, ushort entries, ushort securityOffset)
|
||||
{
|
||||
int count = Math.Min(entries, data.Length / 2);
|
||||
var strings = new List<ComDualStringEntry>();
|
||||
|
||||
for (int i = 0; i < count;)
|
||||
{
|
||||
int entryStart = i;
|
||||
ushort towerId = ReadUInt16(data, i * 2);
|
||||
i++;
|
||||
if (towerId == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var text = new List<char>();
|
||||
while (i < count)
|
||||
{
|
||||
ushort value = ReadUInt16(data, i * 2);
|
||||
i++;
|
||||
if (value == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (value >= 0x20 && value <= 0x7e)
|
||||
{
|
||||
text.Add((char)value);
|
||||
}
|
||||
else
|
||||
{
|
||||
text.Add('<');
|
||||
text.AddRange(value.ToString("x4", CultureInfo.InvariantCulture));
|
||||
text.Add('>');
|
||||
}
|
||||
}
|
||||
|
||||
strings.Add(new ComDualStringEntry(
|
||||
towerId,
|
||||
ProtocolTowerName(towerId),
|
||||
new string(text.ToArray()),
|
||||
IsSecurityBinding: entryStart >= securityOffset));
|
||||
}
|
||||
|
||||
return strings;
|
||||
}
|
||||
|
||||
private static string ProtocolTowerName(ushort towerId)
|
||||
{
|
||||
return towerId switch
|
||||
{
|
||||
0x0007 => "ncacn_ip_tcp",
|
||||
0x0008 => "ncadg_ip_udp",
|
||||
0x0009 => "ncacn_np",
|
||||
0x000f => "ncacn_spx",
|
||||
0x0010 => "ncacn_nb_nb",
|
||||
0x0016 => "ncadg_ip_udp_or_netbios",
|
||||
0x001f => "ncalrpc",
|
||||
_ => "unknown",
|
||||
};
|
||||
}
|
||||
|
||||
private static ushort ReadUInt16(ReadOnlySpan<byte> buffer, int offset)
|
||||
{
|
||||
return (ushort)(buffer[offset] | (buffer[offset + 1] << 8));
|
||||
}
|
||||
|
||||
private static uint ReadUInt32(ReadOnlySpan<byte> buffer, int offset)
|
||||
{
|
||||
return (uint)(buffer[offset]
|
||||
| (buffer[offset + 1] << 8)
|
||||
| (buffer[offset + 2] << 16)
|
||||
| (buffer[offset + 3] << 24));
|
||||
}
|
||||
|
||||
private static ulong ReadUInt64(ReadOnlySpan<byte> buffer, int offset)
|
||||
{
|
||||
return ReadUInt32(buffer, offset) | ((ulong)ReadUInt32(buffer, offset + 4) << 32);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record ComDualStringEntry(ushort TowerId, string Protocol, string Value, bool IsSecurityBinding)
|
||||
{
|
||||
public string ToDiagnosticString()
|
||||
{
|
||||
string kind = IsSecurityBinding ? "security" : "string";
|
||||
return $"{kind}:0x{TowerId:x4}:{Protocol}:{Value}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using ComTypes = System.Runtime.InteropServices.ComTypes;
|
||||
|
||||
namespace MxNativeClient;
|
||||
|
||||
public static class ComObjRefProvider
|
||||
{
|
||||
public const uint MarshalContextInProcess = 0;
|
||||
public const uint MarshalContextLocal = 1;
|
||||
public const uint MarshalContextDifferentMachine = 2;
|
||||
|
||||
public static byte[] MarshalActivatedIUnknownObjRef(string progId, uint destinationContext)
|
||||
{
|
||||
var type = Type.GetTypeFromProgID(progId, throwOnError: true)
|
||||
?? throw new InvalidOperationException($"ProgID {progId} was not resolved.");
|
||||
object instance = Activator.CreateInstance(type)
|
||||
?? throw new InvalidOperationException($"ProgID {progId} activation returned null.");
|
||||
|
||||
try
|
||||
{
|
||||
return MarshalIUnknownObjRef(instance, destinationContext);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Marshal.IsComObject(instance))
|
||||
{
|
||||
_ = Marshal.ReleaseComObject(instance);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static byte[] MarshalIUnknownObjRef(object comObject, uint destinationContext)
|
||||
{
|
||||
return MarshalInterfaceObjRef(comObject, new Guid("00000000-0000-0000-C000-000000000046"), destinationContext);
|
||||
}
|
||||
|
||||
public static byte[] MarshalInterfaceObjRef(object comObject, Guid iid, uint destinationContext)
|
||||
{
|
||||
IntPtr unknown = IntPtr.Zero;
|
||||
ComTypes.IStream? stream = null;
|
||||
|
||||
try
|
||||
{
|
||||
unknown = Marshal.GetIUnknownForObject(comObject);
|
||||
Marshal.ThrowExceptionForHR(CreateStreamOnHGlobal(IntPtr.Zero, true, out stream));
|
||||
Marshal.ThrowExceptionForHR(CoMarshalInterface(stream, ref iid, unknown, destinationContext, IntPtr.Zero, 0));
|
||||
Marshal.ThrowExceptionForHR(GetHGlobalFromStream(stream, out var hglobal));
|
||||
|
||||
nuint size = GlobalSize(hglobal);
|
||||
IntPtr pointer = GlobalLock(hglobal);
|
||||
if (pointer == IntPtr.Zero)
|
||||
{
|
||||
throw new InvalidOperationException("GlobalLock failed.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
byte[] buffer = new byte[(int)size];
|
||||
Marshal.Copy(pointer, buffer, 0, buffer.Length);
|
||||
return buffer;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_ = GlobalUnlock(hglobal);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (unknown != IntPtr.Zero)
|
||||
{
|
||||
Marshal.Release(unknown);
|
||||
}
|
||||
|
||||
if (stream is not null)
|
||||
{
|
||||
_ = Marshal.ReleaseComObject(stream);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
[DllImport("ole32.dll")]
|
||||
private static extern int CreateStreamOnHGlobal(IntPtr hGlobal, [MarshalAs(UnmanagedType.Bool)] bool deleteOnRelease, out ComTypes.IStream stream);
|
||||
|
||||
[DllImport("ole32.dll")]
|
||||
private static extern int CoMarshalInterface(ComTypes.IStream stream, ref Guid iid, IntPtr unknown, uint destinationContext, IntPtr destinationContextPointer, uint marshalFlags);
|
||||
|
||||
[DllImport("ole32.dll")]
|
||||
private static extern int GetHGlobalFromStream(ComTypes.IStream stream, out IntPtr hGlobal);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern IntPtr GlobalLock(IntPtr hMem);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool GlobalUnlock(IntPtr hMem);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern nuint GlobalSize(IntPtr hMem);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Buffers;
|
||||
using System.Net;
|
||||
using System.Net.Security;
|
||||
|
||||
namespace MxNativeClient;
|
||||
|
||||
public enum DceRpcAuthType : byte
|
||||
{
|
||||
None = 0,
|
||||
GssNegotiate = 9,
|
||||
WinNt = 10,
|
||||
}
|
||||
|
||||
public enum DceRpcAuthLevel : byte
|
||||
{
|
||||
None = 1,
|
||||
Connect = 2,
|
||||
PacketIntegrity = 5,
|
||||
PacketPrivacy = 6,
|
||||
}
|
||||
|
||||
public sealed record DceRpcAuthTrailer(
|
||||
DceRpcAuthType AuthType,
|
||||
DceRpcAuthLevel AuthLevel,
|
||||
byte AuthPadLength,
|
||||
byte AuthReserved,
|
||||
uint AuthContextId)
|
||||
{
|
||||
public const int Length = 8;
|
||||
|
||||
public static DceRpcAuthTrailer Parse(ReadOnlySpan<byte> buffer)
|
||||
{
|
||||
if (buffer.Length < Length)
|
||||
{
|
||||
throw new ArgumentException("DCE/RPC auth trailer is too short.", nameof(buffer));
|
||||
}
|
||||
|
||||
return new DceRpcAuthTrailer(
|
||||
(DceRpcAuthType)buffer[0],
|
||||
(DceRpcAuthLevel)buffer[1],
|
||||
buffer[2],
|
||||
buffer[3],
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(buffer[4..8]));
|
||||
}
|
||||
|
||||
public void WriteTo(Span<byte> buffer)
|
||||
{
|
||||
if (buffer.Length < Length)
|
||||
{
|
||||
throw new ArgumentException("DCE/RPC auth trailer buffer is too short.", nameof(buffer));
|
||||
}
|
||||
|
||||
buffer[0] = (byte)AuthType;
|
||||
buffer[1] = (byte)AuthLevel;
|
||||
buffer[2] = AuthPadLength;
|
||||
buffer[3] = AuthReserved;
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buffer[4..8], AuthContextId);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record DceRpcAuthValue(DceRpcAuthTrailer Trailer, ReadOnlyMemory<byte> Token);
|
||||
|
||||
public sealed class SspiClientContext : IDisposable
|
||||
{
|
||||
private readonly NegotiateAuthentication _authentication;
|
||||
|
||||
public SspiClientContext(string package, string targetName, ProtectionLevel protectionLevel = ProtectionLevel.None)
|
||||
{
|
||||
var credential = CreateCredentialFromEnvironment();
|
||||
_authentication = new NegotiateAuthentication(new NegotiateAuthenticationClientOptions
|
||||
{
|
||||
Package = package,
|
||||
TargetName = targetName,
|
||||
Credential = credential ?? CredentialCache.DefaultNetworkCredentials,
|
||||
RequiredProtectionLevel = protectionLevel,
|
||||
});
|
||||
}
|
||||
|
||||
public byte[] GetOutgoingBlob(ReadOnlySpan<byte> incomingToken)
|
||||
{
|
||||
byte[]? input = incomingToken.IsEmpty ? Array.Empty<byte>() : incomingToken.ToArray();
|
||||
byte[]? output = _authentication.GetOutgoingBlob(input, out var status);
|
||||
if (status is not (NegotiateAuthenticationStatusCode.Completed or NegotiateAuthenticationStatusCode.ContinueNeeded))
|
||||
{
|
||||
throw new InvalidOperationException($"SSPI negotiation failed with {status}.");
|
||||
}
|
||||
|
||||
return output ?? [];
|
||||
}
|
||||
|
||||
public byte[] Wrap(ReadOnlySpan<byte> input, bool requestEncryption, out bool isEncrypted)
|
||||
{
|
||||
var writer = new ArrayBufferWriter<byte>(input.Length + 64);
|
||||
var status = _authentication.Wrap(input, writer, requestEncryption, out isEncrypted);
|
||||
if (status != NegotiateAuthenticationStatusCode.Completed)
|
||||
{
|
||||
throw new InvalidOperationException($"SSPI wrap failed with {status}.");
|
||||
}
|
||||
|
||||
return writer.WrittenSpan.ToArray();
|
||||
}
|
||||
|
||||
public byte[] Unwrap(ReadOnlySpan<byte> input, out bool wasEncrypted)
|
||||
{
|
||||
var writer = new ArrayBufferWriter<byte>(input.Length);
|
||||
var status = _authentication.Unwrap(input, writer, out wasEncrypted);
|
||||
if (status != NegotiateAuthenticationStatusCode.Completed)
|
||||
{
|
||||
throw new InvalidOperationException($"SSPI unwrap failed with {status}.");
|
||||
}
|
||||
|
||||
return writer.WrittenSpan.ToArray();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_authentication.Dispose();
|
||||
}
|
||||
|
||||
private static NetworkCredential? CreateCredentialFromEnvironment()
|
||||
{
|
||||
string? user = Environment.GetEnvironmentVariable("MX_RPC_USER");
|
||||
string? password = Environment.GetEnvironmentVariable("MX_RPC_PASSWORD");
|
||||
if (string.IsNullOrWhiteSpace(user) || password is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string? domain = Environment.GetEnvironmentVariable("MX_RPC_DOMAIN");
|
||||
return string.IsNullOrWhiteSpace(domain)
|
||||
? new NetworkCredential(user, password)
|
||||
: new NetworkCredential(user, password, domain);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
using System.Buffers.Binary;
|
||||
|
||||
namespace MxNativeClient;
|
||||
|
||||
public enum DceRpcPacketType : byte
|
||||
{
|
||||
Request = 0,
|
||||
Response = 2,
|
||||
Fault = 3,
|
||||
Bind = 11,
|
||||
BindAck = 12,
|
||||
AlterContext = 14,
|
||||
AlterContextResponse = 15,
|
||||
Auth3 = 16,
|
||||
}
|
||||
|
||||
public readonly record struct DceRpcSyntaxId(Guid Uuid, ushort VersionMajor, ushort VersionMinor)
|
||||
{
|
||||
public static DceRpcSyntaxId Ndr20 { get; } = new(
|
||||
new Guid("8A885D04-1CEB-11C9-9FE8-08002B104860"),
|
||||
2,
|
||||
0);
|
||||
}
|
||||
|
||||
public sealed record DceRpcPresentationContext(
|
||||
ushort ContextId,
|
||||
DceRpcSyntaxId AbstractSyntax,
|
||||
IReadOnlyList<DceRpcSyntaxId> TransferSyntaxes);
|
||||
|
||||
public sealed record DceRpcPduHeader(
|
||||
byte Version,
|
||||
byte VersionMinor,
|
||||
DceRpcPacketType PacketType,
|
||||
byte PacketFlags,
|
||||
uint DataRepresentation,
|
||||
ushort FragmentLength,
|
||||
ushort AuthLength,
|
||||
uint CallId)
|
||||
{
|
||||
public const int Length = 16;
|
||||
|
||||
public static DceRpcPduHeader Parse(ReadOnlySpan<byte> buffer)
|
||||
{
|
||||
if (buffer.Length < Length)
|
||||
{
|
||||
throw new ArgumentException("DCE/RPC PDU header is too short.", nameof(buffer));
|
||||
}
|
||||
|
||||
return new DceRpcPduHeader(
|
||||
Version: buffer[0],
|
||||
VersionMinor: buffer[1],
|
||||
PacketType: (DceRpcPacketType)buffer[2],
|
||||
PacketFlags: buffer[3],
|
||||
DataRepresentation: BinaryPrimitives.ReadUInt32LittleEndian(buffer[4..8]),
|
||||
FragmentLength: BinaryPrimitives.ReadUInt16LittleEndian(buffer[8..10]),
|
||||
AuthLength: BinaryPrimitives.ReadUInt16LittleEndian(buffer[10..12]),
|
||||
CallId: BinaryPrimitives.ReadUInt32LittleEndian(buffer[12..16]));
|
||||
}
|
||||
|
||||
public void WriteTo(Span<byte> buffer)
|
||||
{
|
||||
if (buffer.Length < Length)
|
||||
{
|
||||
throw new ArgumentException("DCE/RPC PDU header buffer is too short.", nameof(buffer));
|
||||
}
|
||||
|
||||
buffer[0] = Version;
|
||||
buffer[1] = VersionMinor;
|
||||
buffer[2] = (byte)PacketType;
|
||||
buffer[3] = PacketFlags;
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buffer[4..8], DataRepresentation);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buffer[8..10], FragmentLength);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buffer[10..12], AuthLength);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buffer[12..16], CallId);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record DceRpcRequestPdu(
|
||||
DceRpcPduHeader Header,
|
||||
uint AllocationHint,
|
||||
ushort ContextId,
|
||||
ushort Opnum,
|
||||
ReadOnlyMemory<byte> StubData)
|
||||
{
|
||||
public static DceRpcRequestPdu Parse(ReadOnlyMemory<byte> pdu)
|
||||
{
|
||||
var span = pdu.Span;
|
||||
var header = DceRpcPduHeader.Parse(span);
|
||||
if (header.PacketType != DceRpcPacketType.Request)
|
||||
{
|
||||
throw new ArgumentException("PDU is not a request.", nameof(pdu));
|
||||
}
|
||||
|
||||
if (span.Length < 24 || header.FragmentLength > span.Length)
|
||||
{
|
||||
throw new ArgumentException("DCE/RPC request PDU is truncated.", nameof(pdu));
|
||||
}
|
||||
|
||||
int trailerLength = header.AuthLength == 0 ? 0 : header.AuthLength + 8;
|
||||
int stubLength = header.FragmentLength - 24 - trailerLength;
|
||||
if (stubLength < 0)
|
||||
{
|
||||
throw new ArgumentException("DCE/RPC request PDU has invalid auth/trailer length.", nameof(pdu));
|
||||
}
|
||||
|
||||
return new DceRpcRequestPdu(
|
||||
Header: header,
|
||||
AllocationHint: BinaryPrimitives.ReadUInt32LittleEndian(span[16..20]),
|
||||
ContextId: BinaryPrimitives.ReadUInt16LittleEndian(span[20..22]),
|
||||
Opnum: BinaryPrimitives.ReadUInt16LittleEndian(span[22..24]),
|
||||
StubData: pdu.Slice(24, stubLength));
|
||||
}
|
||||
|
||||
public byte[] Encode()
|
||||
{
|
||||
int length = 24 + StubData.Length;
|
||||
byte[] pdu = new byte[length];
|
||||
var header = Header with
|
||||
{
|
||||
PacketType = DceRpcPacketType.Request,
|
||||
FragmentLength = (ushort)length,
|
||||
AuthLength = 0,
|
||||
PacketFlags = Header.PacketFlags == 0 ? (byte)0x03 : Header.PacketFlags,
|
||||
};
|
||||
header.WriteTo(pdu);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(pdu.AsSpan(16, 4), AllocationHint);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(pdu.AsSpan(20, 2), ContextId);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(pdu.AsSpan(22, 2), Opnum);
|
||||
StubData.Span.CopyTo(pdu.AsSpan(24));
|
||||
return pdu;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record DceRpcResponsePdu(
|
||||
DceRpcPduHeader Header,
|
||||
uint AllocationHint,
|
||||
ushort ContextId,
|
||||
byte CancelCount,
|
||||
ReadOnlyMemory<byte> StubData)
|
||||
{
|
||||
public static DceRpcResponsePdu Parse(ReadOnlyMemory<byte> pdu)
|
||||
{
|
||||
var span = pdu.Span;
|
||||
var header = DceRpcPduHeader.Parse(span);
|
||||
if (header.PacketType != DceRpcPacketType.Response)
|
||||
{
|
||||
throw new ArgumentException("PDU is not a response.", nameof(pdu));
|
||||
}
|
||||
|
||||
if (span.Length < 24 || header.FragmentLength > span.Length)
|
||||
{
|
||||
throw new ArgumentException("DCE/RPC response PDU is truncated.", nameof(pdu));
|
||||
}
|
||||
|
||||
int trailerLength = header.AuthLength == 0 ? 0 : header.AuthLength + 8;
|
||||
int stubLength = header.FragmentLength - 24 - trailerLength;
|
||||
if (stubLength < 0)
|
||||
{
|
||||
throw new ArgumentException("DCE/RPC response PDU has invalid auth/trailer length.", nameof(pdu));
|
||||
}
|
||||
|
||||
return new DceRpcResponsePdu(
|
||||
Header: header,
|
||||
AllocationHint: BinaryPrimitives.ReadUInt32LittleEndian(span[16..20]),
|
||||
ContextId: BinaryPrimitives.ReadUInt16LittleEndian(span[20..22]),
|
||||
CancelCount: span[22],
|
||||
StubData: pdu.Slice(24, stubLength));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record DceRpcFaultPdu(
|
||||
DceRpcPduHeader Header,
|
||||
uint AllocationHint,
|
||||
ushort ContextId,
|
||||
byte CancelCount,
|
||||
uint Status,
|
||||
ReadOnlyMemory<byte> StubData)
|
||||
{
|
||||
public static DceRpcFaultPdu Parse(ReadOnlyMemory<byte> pdu)
|
||||
{
|
||||
var span = pdu.Span;
|
||||
var header = DceRpcPduHeader.Parse(span);
|
||||
if (header.PacketType != DceRpcPacketType.Fault)
|
||||
{
|
||||
throw new ArgumentException("PDU is not a fault.", nameof(pdu));
|
||||
}
|
||||
|
||||
if (span.Length < 28 || header.FragmentLength > span.Length)
|
||||
{
|
||||
throw new ArgumentException("DCE/RPC fault PDU is truncated.", nameof(pdu));
|
||||
}
|
||||
|
||||
int trailerLength = header.AuthLength == 0 ? 0 : header.AuthLength + 8;
|
||||
int stubLength = header.FragmentLength - 28 - trailerLength;
|
||||
if (stubLength < 0)
|
||||
{
|
||||
throw new ArgumentException("DCE/RPC fault PDU has invalid auth/trailer length.", nameof(pdu));
|
||||
}
|
||||
|
||||
return new DceRpcFaultPdu(
|
||||
Header: header,
|
||||
AllocationHint: BinaryPrimitives.ReadUInt32LittleEndian(span[16..20]),
|
||||
ContextId: BinaryPrimitives.ReadUInt16LittleEndian(span[20..22]),
|
||||
CancelCount: span[22],
|
||||
Status: BinaryPrimitives.ReadUInt32LittleEndian(span[24..28]),
|
||||
StubData: pdu.Slice(28, stubLength));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record DceRpcBindPdu(
|
||||
DceRpcPduHeader Header,
|
||||
ushort MaxTransmitFragment,
|
||||
ushort MaxReceiveFragment,
|
||||
uint AssociationGroupId,
|
||||
IReadOnlyList<DceRpcPresentationContext> PresentationContexts)
|
||||
{
|
||||
public static DceRpcBindPdu Parse(ReadOnlyMemory<byte> pdu)
|
||||
{
|
||||
var span = pdu.Span;
|
||||
var header = DceRpcPduHeader.Parse(span);
|
||||
if (header.PacketType is not (DceRpcPacketType.Bind or DceRpcPacketType.AlterContext))
|
||||
{
|
||||
throw new ArgumentException("PDU is not a bind or alter-context PDU.", nameof(pdu));
|
||||
}
|
||||
|
||||
if (span.Length < 28 || header.FragmentLength > span.Length)
|
||||
{
|
||||
throw new ArgumentException("DCE/RPC bind PDU is truncated.", nameof(pdu));
|
||||
}
|
||||
|
||||
int contextCount = span[24];
|
||||
int offset = 28;
|
||||
var contexts = new List<DceRpcPresentationContext>(contextCount);
|
||||
|
||||
for (int i = 0; i < contextCount; i++)
|
||||
{
|
||||
if (offset + 24 > header.FragmentLength)
|
||||
{
|
||||
throw new ArgumentException("DCE/RPC bind context is truncated.", nameof(pdu));
|
||||
}
|
||||
|
||||
ushort contextId = BinaryPrimitives.ReadUInt16LittleEndian(span.Slice(offset, 2));
|
||||
byte transferSyntaxCount = span[offset + 2];
|
||||
offset += 4;
|
||||
|
||||
var abstractSyntax = ReadSyntax(span, ref offset);
|
||||
var transferSyntaxes = new List<DceRpcSyntaxId>(transferSyntaxCount);
|
||||
for (int j = 0; j < transferSyntaxCount; j++)
|
||||
{
|
||||
transferSyntaxes.Add(ReadSyntax(span, ref offset));
|
||||
}
|
||||
|
||||
contexts.Add(new DceRpcPresentationContext(contextId, abstractSyntax, transferSyntaxes));
|
||||
}
|
||||
|
||||
return new DceRpcBindPdu(
|
||||
Header: header,
|
||||
MaxTransmitFragment: BinaryPrimitives.ReadUInt16LittleEndian(span[16..18]),
|
||||
MaxReceiveFragment: BinaryPrimitives.ReadUInt16LittleEndian(span[18..20]),
|
||||
AssociationGroupId: BinaryPrimitives.ReadUInt32LittleEndian(span[20..24]),
|
||||
PresentationContexts: contexts);
|
||||
}
|
||||
|
||||
public byte[] Encode()
|
||||
{
|
||||
int length = 28 + PresentationContexts.Sum(static context => 24 + 20 * context.TransferSyntaxes.Count);
|
||||
byte[] pdu = new byte[length];
|
||||
var header = Header with { FragmentLength = (ushort)length, AuthLength = 0 };
|
||||
header.WriteTo(pdu);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(pdu.AsSpan(16, 2), MaxTransmitFragment);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(pdu.AsSpan(18, 2), MaxReceiveFragment);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(pdu.AsSpan(20, 4), AssociationGroupId);
|
||||
pdu[24] = (byte)PresentationContexts.Count;
|
||||
|
||||
int offset = 28;
|
||||
foreach (var context in PresentationContexts)
|
||||
{
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(pdu.AsSpan(offset, 2), context.ContextId);
|
||||
pdu[offset + 2] = (byte)context.TransferSyntaxes.Count;
|
||||
offset += 4;
|
||||
|
||||
WriteSyntax(pdu.AsSpan(), ref offset, context.AbstractSyntax);
|
||||
foreach (var transferSyntax in context.TransferSyntaxes)
|
||||
{
|
||||
WriteSyntax(pdu.AsSpan(), ref offset, transferSyntax);
|
||||
}
|
||||
}
|
||||
|
||||
return pdu;
|
||||
}
|
||||
|
||||
public byte[] EncodeWithAuth(DceRpcAuthTrailer trailer, ReadOnlySpan<byte> authToken)
|
||||
{
|
||||
byte[] unauthenticated = Encode();
|
||||
int padLength = Align(unauthenticated.Length, 4) - unauthenticated.Length;
|
||||
int length = unauthenticated.Length + padLength + DceRpcAuthTrailer.Length + authToken.Length;
|
||||
byte[] pdu = new byte[length];
|
||||
unauthenticated.CopyTo(pdu.AsSpan());
|
||||
|
||||
var header = Header with
|
||||
{
|
||||
FragmentLength = (ushort)length,
|
||||
AuthLength = (ushort)authToken.Length,
|
||||
};
|
||||
header.WriteTo(pdu);
|
||||
|
||||
var alignedTrailer = trailer with { AuthPadLength = (byte)padLength };
|
||||
alignedTrailer.WriteTo(pdu.AsSpan(unauthenticated.Length + padLength, DceRpcAuthTrailer.Length));
|
||||
authToken.CopyTo(pdu.AsSpan(length - authToken.Length));
|
||||
return pdu;
|
||||
}
|
||||
|
||||
public static byte[] EncodeAuth3(DceRpcPduHeader header, DceRpcAuthTrailer trailer, ReadOnlySpan<byte> authToken)
|
||||
{
|
||||
byte[] body = [(byte)' ', (byte)' ', (byte)' ', (byte)' '];
|
||||
int padLength = Align(DceRpcPduHeader.Length + body.Length, 4) - (DceRpcPduHeader.Length + body.Length);
|
||||
int length = DceRpcPduHeader.Length + body.Length + padLength + DceRpcAuthTrailer.Length + authToken.Length;
|
||||
byte[] pdu = new byte[length];
|
||||
var authHeader = header with
|
||||
{
|
||||
PacketType = DceRpcPacketType.Auth3,
|
||||
FragmentLength = (ushort)length,
|
||||
AuthLength = (ushort)authToken.Length,
|
||||
};
|
||||
authHeader.WriteTo(pdu);
|
||||
body.CopyTo(pdu.AsSpan(DceRpcPduHeader.Length));
|
||||
var alignedTrailer = trailer with { AuthPadLength = (byte)padLength };
|
||||
alignedTrailer.WriteTo(pdu.AsSpan(DceRpcPduHeader.Length + body.Length + padLength, DceRpcAuthTrailer.Length));
|
||||
authToken.CopyTo(pdu.AsSpan(length - authToken.Length));
|
||||
return pdu;
|
||||
}
|
||||
|
||||
public static DceRpcAuthValue ReadAuthValue(ReadOnlyMemory<byte> pdu)
|
||||
{
|
||||
var header = DceRpcPduHeader.Parse(pdu.Span);
|
||||
if (header.AuthLength == 0)
|
||||
{
|
||||
throw new ArgumentException("PDU has no auth value.", nameof(pdu));
|
||||
}
|
||||
|
||||
int trailerOffset = header.FragmentLength - header.AuthLength - DceRpcAuthTrailer.Length;
|
||||
if (trailerOffset < DceRpcPduHeader.Length)
|
||||
{
|
||||
throw new ArgumentException("PDU auth trailer offset is invalid.", nameof(pdu));
|
||||
}
|
||||
|
||||
return new DceRpcAuthValue(
|
||||
DceRpcAuthTrailer.Parse(pdu.Span.Slice(trailerOffset, DceRpcAuthTrailer.Length)),
|
||||
pdu.Slice(header.FragmentLength - header.AuthLength, header.AuthLength));
|
||||
}
|
||||
|
||||
private static DceRpcSyntaxId ReadSyntax(ReadOnlySpan<byte> span, ref int offset)
|
||||
{
|
||||
if (offset + 20 > span.Length)
|
||||
{
|
||||
throw new ArgumentException("DCE/RPC syntax identifier is truncated.", nameof(span));
|
||||
}
|
||||
|
||||
var syntax = new DceRpcSyntaxId(
|
||||
new Guid(span.Slice(offset, 16)),
|
||||
BinaryPrimitives.ReadUInt16LittleEndian(span.Slice(offset + 16, 2)),
|
||||
BinaryPrimitives.ReadUInt16LittleEndian(span.Slice(offset + 18, 2)));
|
||||
offset += 20;
|
||||
return syntax;
|
||||
}
|
||||
|
||||
private static void WriteSyntax(Span<byte> span, ref int offset, DceRpcSyntaxId syntax)
|
||||
{
|
||||
syntax.Uuid.TryWriteBytes(span.Slice(offset, 16));
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(span.Slice(offset + 16, 2), syntax.VersionMajor);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(span.Slice(offset + 18, 2), syntax.VersionMinor);
|
||||
offset += 20;
|
||||
}
|
||||
|
||||
private static int Align(int value, int alignment)
|
||||
{
|
||||
int remainder = value % alignment;
|
||||
return remainder == 0 ? value : value + alignment - remainder;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Net.Sockets;
|
||||
using System.Net.Security;
|
||||
|
||||
namespace MxNativeClient;
|
||||
|
||||
public sealed class DceRpcTcpClient : IDisposable
|
||||
{
|
||||
private readonly string _host;
|
||||
private readonly int _port;
|
||||
private TcpClient? _client;
|
||||
private NetworkStream? _stream;
|
||||
private uint _nextCallId = 1;
|
||||
private ushort _boundContextId;
|
||||
private SspiClientContext? _sspi;
|
||||
private ManagedNtlmClientContext? _managedNtlm;
|
||||
private DceRpcAuthTrailer? _authTrailer;
|
||||
private DceRpcAuthLevel _authLevel = DceRpcAuthLevel.None;
|
||||
|
||||
public DceRpcTcpClient(string host, int port)
|
||||
{
|
||||
_host = host;
|
||||
_port = port;
|
||||
}
|
||||
|
||||
public void Connect()
|
||||
{
|
||||
_client = new TcpClient();
|
||||
_client.Connect(_host, _port);
|
||||
_stream = _client.GetStream();
|
||||
}
|
||||
|
||||
public DceRpcPduHeader Bind(Guid interfaceId, ushort versionMajor, ushort versionMinor)
|
||||
{
|
||||
EnsureConnected();
|
||||
uint callId = _nextCallId++;
|
||||
var pdu = new DceRpcBindPdu(
|
||||
Header: CreateHeader(DceRpcPacketType.Bind, callId),
|
||||
MaxTransmitFragment: 4280,
|
||||
MaxReceiveFragment: 4280,
|
||||
AssociationGroupId: 0,
|
||||
PresentationContexts:
|
||||
[
|
||||
new DceRpcPresentationContext(
|
||||
ContextId: 0,
|
||||
AbstractSyntax: new DceRpcSyntaxId(interfaceId, versionMajor, versionMinor),
|
||||
TransferSyntaxes: [DceRpcSyntaxId.Ndr20]),
|
||||
]);
|
||||
|
||||
Write(pdu.Encode());
|
||||
byte[] response = ReadPdu();
|
||||
return DceRpcPduHeader.Parse(response);
|
||||
}
|
||||
|
||||
public DceRpcPduHeader BindWithNtlmConnect(Guid interfaceId, ushort versionMajor, ushort versionMinor, string targetName = "localhost")
|
||||
{
|
||||
return BindWithNtlm(interfaceId, versionMajor, versionMinor, DceRpcAuthLevel.Connect, targetName);
|
||||
}
|
||||
|
||||
public DceRpcPduHeader BindWithNtlmPacketIntegrity(Guid interfaceId, ushort versionMajor, ushort versionMinor, string targetName = "localhost")
|
||||
{
|
||||
return BindWithNtlm(interfaceId, versionMajor, versionMinor, DceRpcAuthLevel.PacketIntegrity, targetName);
|
||||
}
|
||||
|
||||
public DceRpcPduHeader BindWithManagedNtlmPacketIntegrity(Guid interfaceId, ushort versionMajor, ushort versionMinor)
|
||||
{
|
||||
EnsureConnected();
|
||||
_sspi?.Dispose();
|
||||
_sspi = null;
|
||||
_managedNtlm = ManagedNtlmClientContext.FromEnvironment();
|
||||
byte[] type1 = _managedNtlm.CreateType1();
|
||||
uint callId = _nextCallId++;
|
||||
var pdu = new DceRpcBindPdu(
|
||||
Header: CreateHeader(DceRpcPacketType.Bind, callId),
|
||||
MaxTransmitFragment: 4280,
|
||||
MaxReceiveFragment: 4280,
|
||||
AssociationGroupId: 0,
|
||||
PresentationContexts:
|
||||
[
|
||||
new DceRpcPresentationContext(
|
||||
ContextId: 0,
|
||||
AbstractSyntax: new DceRpcSyntaxId(interfaceId, versionMajor, versionMinor),
|
||||
TransferSyntaxes: [DceRpcSyntaxId.Ndr20]),
|
||||
]);
|
||||
var trailer = new DceRpcAuthTrailer(
|
||||
AuthType: DceRpcAuthType.WinNt,
|
||||
AuthLevel: DceRpcAuthLevel.PacketIntegrity,
|
||||
AuthPadLength: 0,
|
||||
AuthReserved: 0,
|
||||
AuthContextId: 79232);
|
||||
|
||||
Write(pdu.EncodeWithAuth(trailer, type1));
|
||||
byte[] response = ReadPdu();
|
||||
var responseHeader = DceRpcPduHeader.Parse(response);
|
||||
var challenge = DceRpcBindPdu.ReadAuthValue(response);
|
||||
byte[] type3 = _managedNtlm.CreateType3(challenge.Token.Span);
|
||||
byte[] auth3 = DceRpcBindPdu.EncodeAuth3(
|
||||
CreateHeader(DceRpcPacketType.Auth3, responseHeader.CallId),
|
||||
trailer,
|
||||
type3);
|
||||
Write(auth3);
|
||||
_boundContextId = 0;
|
||||
_authTrailer = trailer;
|
||||
_authLevel = DceRpcAuthLevel.PacketIntegrity;
|
||||
return responseHeader;
|
||||
}
|
||||
|
||||
public DceRpcPduHeader BindWithNtlm(Guid interfaceId, ushort versionMajor, ushort versionMinor, DceRpcAuthLevel authLevel, string targetName = "localhost")
|
||||
{
|
||||
EnsureConnected();
|
||||
_sspi?.Dispose();
|
||||
_managedNtlm = null;
|
||||
_sspi = new SspiClientContext("NTLM", targetName, ProtectionLevelFor(authLevel));
|
||||
byte[] type1 = _sspi.GetOutgoingBlob(ReadOnlySpan<byte>.Empty);
|
||||
uint callId = _nextCallId++;
|
||||
var pdu = new DceRpcBindPdu(
|
||||
Header: CreateHeader(DceRpcPacketType.Bind, callId),
|
||||
MaxTransmitFragment: 4280,
|
||||
MaxReceiveFragment: 4280,
|
||||
AssociationGroupId: 0,
|
||||
PresentationContexts:
|
||||
[
|
||||
new DceRpcPresentationContext(
|
||||
ContextId: 0,
|
||||
AbstractSyntax: new DceRpcSyntaxId(interfaceId, versionMajor, versionMinor),
|
||||
TransferSyntaxes: [DceRpcSyntaxId.Ndr20]),
|
||||
]);
|
||||
var trailer = new DceRpcAuthTrailer(
|
||||
AuthType: DceRpcAuthType.WinNt,
|
||||
AuthLevel: authLevel,
|
||||
AuthPadLength: 0,
|
||||
AuthReserved: 0,
|
||||
AuthContextId: 79232);
|
||||
|
||||
Write(pdu.EncodeWithAuth(trailer, type1));
|
||||
byte[] response = ReadPdu();
|
||||
var responseHeader = DceRpcPduHeader.Parse(response);
|
||||
var challenge = DceRpcBindPdu.ReadAuthValue(response);
|
||||
byte[] type3 = _sspi.GetOutgoingBlob(challenge.Token.Span);
|
||||
byte[] auth3 = DceRpcBindPdu.EncodeAuth3(
|
||||
CreateHeader(DceRpcPacketType.Auth3, responseHeader.CallId),
|
||||
trailer,
|
||||
type3);
|
||||
Write(auth3);
|
||||
_boundContextId = 0;
|
||||
_authTrailer = trailer;
|
||||
_authLevel = authLevel;
|
||||
return responseHeader;
|
||||
}
|
||||
|
||||
public DceRpcResponsePdu Call(ushort contextId, ushort opnum, ReadOnlyMemory<byte> stubData)
|
||||
{
|
||||
return CallCore(contextId, opnum, stubData, objectUuid: null);
|
||||
}
|
||||
|
||||
public DceRpcResponsePdu CallBoundObject(Guid objectUuid, ushort opnum, ReadOnlyMemory<byte> stubData)
|
||||
{
|
||||
return CallCore(_boundContextId, opnum, stubData, objectUuid);
|
||||
}
|
||||
|
||||
private DceRpcResponsePdu CallCore(ushort contextId, ushort opnum, ReadOnlyMemory<byte> stubData, Guid? objectUuid)
|
||||
{
|
||||
EnsureConnected();
|
||||
uint callId = _nextCallId++;
|
||||
byte[] request = EncodeRequestBytes(CreateHeader(DceRpcPacketType.Request, callId), contextId, opnum, stubData, objectUuid);
|
||||
|
||||
Write(EncodeRequest(request));
|
||||
byte[] response = ReadPdu();
|
||||
var header = DceRpcPduHeader.Parse(response);
|
||||
if (header.PacketType == DceRpcPacketType.Fault)
|
||||
{
|
||||
var fault = DceRpcFaultPdu.Parse(response);
|
||||
throw new DceRpcFaultException(fault.Status);
|
||||
}
|
||||
|
||||
return DceRpcResponsePdu.Parse(response);
|
||||
}
|
||||
|
||||
public DceRpcResponsePdu CallBound(ushort opnum, ReadOnlyMemory<byte> stubData)
|
||||
{
|
||||
return Call(_boundContextId, opnum, stubData);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_sspi?.Dispose();
|
||||
_stream?.Dispose();
|
||||
_client?.Dispose();
|
||||
}
|
||||
|
||||
private byte[] EncodeRequest(byte[] request)
|
||||
{
|
||||
if (_authLevel == DceRpcAuthLevel.PacketIntegrity)
|
||||
{
|
||||
return EncodePacketIntegrityRequest(request);
|
||||
}
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
private byte[] EncodePacketIntegrityRequest(byte[] unauthenticated)
|
||||
{
|
||||
if (_authTrailer is null)
|
||||
{
|
||||
throw new InvalidOperationException("Packet-integrity auth was requested without an auth trailer.");
|
||||
}
|
||||
|
||||
int padLength = Align(unauthenticated.Length, 4) - unauthenticated.Length;
|
||||
int signatureLength = 16;
|
||||
int length = unauthenticated.Length + padLength + DceRpcAuthTrailer.Length + signatureLength;
|
||||
byte[] pdu = new byte[length];
|
||||
unauthenticated.CopyTo(pdu.AsSpan());
|
||||
if (padLength > 0)
|
||||
{
|
||||
pdu.AsSpan(unauthenticated.Length, padLength).Fill(0xbb);
|
||||
}
|
||||
|
||||
var parsedHeader = DceRpcPduHeader.Parse(unauthenticated);
|
||||
var header = parsedHeader with
|
||||
{
|
||||
PacketType = DceRpcPacketType.Request,
|
||||
PacketFlags = parsedHeader.PacketFlags == 0 ? (byte)0x03 : parsedHeader.PacketFlags,
|
||||
FragmentLength = (ushort)length,
|
||||
AuthLength = (ushort)signatureLength,
|
||||
};
|
||||
header.WriteTo(pdu);
|
||||
|
||||
var trailer = _authTrailer with { AuthPadLength = (byte)padLength };
|
||||
int trailerOffset = unauthenticated.Length + padLength;
|
||||
trailer.WriteTo(pdu.AsSpan(trailerOffset, DceRpcAuthTrailer.Length));
|
||||
pdu.AsSpan(length - signatureLength, signatureLength).Fill(0x20);
|
||||
|
||||
byte[] verifier;
|
||||
if (_managedNtlm is not null)
|
||||
{
|
||||
verifier = _managedNtlm.Sign(pdu.AsSpan(0, length - signatureLength));
|
||||
}
|
||||
else if (_sspi is not null)
|
||||
{
|
||||
byte[] wrapped = _sspi.Wrap(pdu.AsSpan(0, length - signatureLength), requestEncryption: false, out _);
|
||||
verifier = ExtractNtlmVerifier(wrapped, pdu.AsSpan(0, length - signatureLength));
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidOperationException("Packet-integrity auth was requested without an auth context.");
|
||||
}
|
||||
|
||||
verifier.CopyTo(pdu.AsSpan(length - signatureLength, signatureLength));
|
||||
return pdu;
|
||||
}
|
||||
|
||||
private static byte[] EncodeRequestBytes(
|
||||
DceRpcPduHeader header,
|
||||
ushort contextId,
|
||||
ushort opnum,
|
||||
ReadOnlyMemory<byte> stubData,
|
||||
Guid? objectUuid)
|
||||
{
|
||||
int objectLength = objectUuid.HasValue ? 16 : 0;
|
||||
int fixedOffset = DceRpcPduHeader.Length;
|
||||
int stubOffset = fixedOffset + 8 + objectLength;
|
||||
int length = stubOffset + stubData.Length;
|
||||
byte[] pdu = new byte[length];
|
||||
var requestHeader = header with
|
||||
{
|
||||
PacketType = DceRpcPacketType.Request,
|
||||
FragmentLength = (ushort)length,
|
||||
AuthLength = 0,
|
||||
PacketFlags = (byte)((header.PacketFlags == 0 ? 0x03 : header.PacketFlags) | (objectUuid.HasValue ? 0x80 : 0x00)),
|
||||
};
|
||||
requestHeader.WriteTo(pdu);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(pdu.AsSpan(fixedOffset, 4), (uint)stubData.Length);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(pdu.AsSpan(fixedOffset + 4, 2), contextId);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(pdu.AsSpan(fixedOffset + 6, 2), opnum);
|
||||
if (objectUuid.HasValue)
|
||||
{
|
||||
objectUuid.Value.TryWriteBytes(pdu.AsSpan(fixedOffset + 8, 16));
|
||||
}
|
||||
|
||||
stubData.Span.CopyTo(pdu.AsSpan(stubOffset));
|
||||
return pdu;
|
||||
}
|
||||
|
||||
private static byte[] ExtractNtlmVerifier(ReadOnlySpan<byte> wrapped, ReadOnlySpan<byte> signedInput)
|
||||
{
|
||||
const int signatureLength = 16;
|
||||
TraceWrapShape(wrapped, signedInput, signatureLength);
|
||||
if (wrapped.Length == signatureLength)
|
||||
{
|
||||
return wrapped.ToArray();
|
||||
}
|
||||
|
||||
if (wrapped.Length == signedInput.Length + signatureLength)
|
||||
{
|
||||
if (wrapped[signatureLength..].SequenceEqual(signedInput))
|
||||
{
|
||||
return wrapped[..signatureLength].ToArray();
|
||||
}
|
||||
|
||||
if (wrapped[..signedInput.Length].SequenceEqual(signedInput))
|
||||
{
|
||||
return wrapped[^signatureLength..].ToArray();
|
||||
}
|
||||
|
||||
return wrapped[..signatureLength].ToArray();
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Unexpected SSPI wrap verifier length {wrapped.Length} for input length {signedInput.Length}.");
|
||||
}
|
||||
|
||||
private static void TraceWrapShape(ReadOnlySpan<byte> wrapped, ReadOnlySpan<byte> signedInput, int signatureLength)
|
||||
{
|
||||
string? tracePath = Environment.GetEnvironmentVariable("MX_RPC_WRAP_TRACE");
|
||||
if (string.IsNullOrWhiteSpace(tracePath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
bool tokenThenInput = wrapped.Length >= signatureLength
|
||||
&& wrapped.Length == signedInput.Length + signatureLength
|
||||
&& wrapped[signatureLength..].SequenceEqual(signedInput);
|
||||
bool inputThenToken = wrapped.Length >= signatureLength
|
||||
&& wrapped.Length == signedInput.Length + signatureLength
|
||||
&& wrapped[..signedInput.Length].SequenceEqual(signedInput);
|
||||
|
||||
string text =
|
||||
$"signed_input_length={signedInput.Length}{Environment.NewLine}" +
|
||||
$"wrapped_length={wrapped.Length}{Environment.NewLine}" +
|
||||
$"token_then_input={tokenThenInput}{Environment.NewLine}" +
|
||||
$"input_then_token={inputThenToken}{Environment.NewLine}" +
|
||||
$"wrapped_first16={Convert.ToHexString(wrapped[..Math.Min(signatureLength, wrapped.Length)])}{Environment.NewLine}" +
|
||||
$"wrapped_last16={Convert.ToHexString(wrapped[^Math.Min(signatureLength, wrapped.Length)..])}{Environment.NewLine}";
|
||||
File.AppendAllText(tracePath, text);
|
||||
}
|
||||
|
||||
private static DceRpcPduHeader CreateHeader(DceRpcPacketType packetType, uint callId)
|
||||
{
|
||||
return new DceRpcPduHeader(
|
||||
Version: 5,
|
||||
VersionMinor: 0,
|
||||
PacketType: packetType,
|
||||
PacketFlags: 0x03,
|
||||
DataRepresentation: 0x10,
|
||||
FragmentLength: 0,
|
||||
AuthLength: 0,
|
||||
CallId: callId);
|
||||
}
|
||||
|
||||
private static int Align(int value, int alignment)
|
||||
{
|
||||
int remainder = value % alignment;
|
||||
return remainder == 0 ? value : value + alignment - remainder;
|
||||
}
|
||||
|
||||
private static ProtectionLevel ProtectionLevelFor(DceRpcAuthLevel authLevel)
|
||||
{
|
||||
return authLevel switch
|
||||
{
|
||||
DceRpcAuthLevel.PacketPrivacy => ProtectionLevel.EncryptAndSign,
|
||||
DceRpcAuthLevel.PacketIntegrity => ProtectionLevel.Sign,
|
||||
_ => ProtectionLevel.None,
|
||||
};
|
||||
}
|
||||
|
||||
private void Write(byte[] pdu)
|
||||
{
|
||||
EnsureConnected();
|
||||
_stream!.Write(pdu);
|
||||
_stream.Flush();
|
||||
}
|
||||
|
||||
private byte[] ReadPdu()
|
||||
{
|
||||
EnsureConnected();
|
||||
byte[] headerBytes = ReadExact(DceRpcPduHeader.Length);
|
||||
var header = DceRpcPduHeader.Parse(headerBytes);
|
||||
byte[] pdu = new byte[header.FragmentLength];
|
||||
headerBytes.CopyTo(pdu, 0);
|
||||
byte[] body = ReadExact(header.FragmentLength - DceRpcPduHeader.Length);
|
||||
body.CopyTo(pdu.AsSpan(DceRpcPduHeader.Length));
|
||||
return pdu;
|
||||
}
|
||||
|
||||
private byte[] ReadExact(int length)
|
||||
{
|
||||
byte[] buffer = new byte[length];
|
||||
int offset = 0;
|
||||
while (offset < length)
|
||||
{
|
||||
int read = _stream!.Read(buffer, offset, length - offset);
|
||||
if (read == 0)
|
||||
{
|
||||
throw new IOException("DCE/RPC socket closed while reading.");
|
||||
}
|
||||
|
||||
offset += read;
|
||||
}
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
private void EnsureConnected()
|
||||
{
|
||||
if (_stream is null)
|
||||
{
|
||||
throw new InvalidOperationException("DCE/RPC TCP client is not connected.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class DceRpcFaultException : Exception
|
||||
{
|
||||
public DceRpcFaultException(uint status)
|
||||
: base($"DCE/RPC fault 0x{status:x8}")
|
||||
{
|
||||
Status = status;
|
||||
}
|
||||
|
||||
public uint Status { get; }
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
using MxNativeCodec;
|
||||
|
||||
namespace MxNativeClient;
|
||||
|
||||
public sealed record GalaxyTagMetadata(
|
||||
string ObjectTagName,
|
||||
string AttributeName,
|
||||
string? PrimitiveName,
|
||||
ushort PlatformId,
|
||||
ushort EngineId,
|
||||
ushort ObjectId,
|
||||
short PrimitiveId,
|
||||
short AttributeId,
|
||||
short PropertyId,
|
||||
short MxDataType,
|
||||
bool IsArray,
|
||||
short SecurityClassification,
|
||||
string AttributeSource)
|
||||
{
|
||||
public const short ValuePropertyId = 10;
|
||||
public const short BufferPropertyId = 50;
|
||||
|
||||
public bool IsBufferProperty => PropertyId == BufferPropertyId;
|
||||
|
||||
public MxReferenceHandle ToReferenceHandle(byte galaxyId = 1)
|
||||
{
|
||||
return MxReferenceHandle.Create(
|
||||
galaxyId,
|
||||
PlatformId,
|
||||
EngineId,
|
||||
ObjectId,
|
||||
ObjectTagName,
|
||||
PrimitiveId,
|
||||
AttributeId,
|
||||
PropertyId,
|
||||
AttributeName,
|
||||
IsArray);
|
||||
}
|
||||
|
||||
public MxValueKind ToValueKind()
|
||||
{
|
||||
return NmxWriteMessage.GetValueKind(MxDataType, IsArray);
|
||||
}
|
||||
|
||||
public bool TryGetValueKind(out MxValueKind valueKind)
|
||||
{
|
||||
return NmxWriteMessage.TryGetValueKind(MxDataType, IsArray, out valueKind);
|
||||
}
|
||||
|
||||
public bool IsSupportedValueKind => TryGetValueKind(out _);
|
||||
|
||||
public (MxValueKind ValueKind, object Value) ProjectWriteValue(object value)
|
||||
{
|
||||
if (TryGetValueKind(out MxValueKind valueKind))
|
||||
{
|
||||
return (valueKind, value);
|
||||
}
|
||||
|
||||
if (IsArray)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(value), $"Unsupported MX array data type {MxDataType}.");
|
||||
}
|
||||
|
||||
return (MxDataType, value) switch
|
||||
{
|
||||
((short)MxNativeCodec.MxDataType.ElapsedTime, TimeSpan timeSpan) => (MxValueKind.Int32, checked((int)timeSpan.TotalMilliseconds)),
|
||||
((short)MxNativeCodec.MxDataType.ElapsedTime, _) => (MxValueKind.Int32, value),
|
||||
((short)MxNativeCodec.MxDataType.InternationalizedString, _) => (MxValueKind.String, value),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(value), $"Unsupported MX data type {MxDataType}."),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class GalaxyRepositoryTagResolver
|
||||
{
|
||||
private const string DefaultConnectionString =
|
||||
"Server=localhost;Database=ZB;Integrated Security=True;Encrypt=False;TrustServerCertificate=True";
|
||||
|
||||
private readonly string _connectionString;
|
||||
|
||||
public GalaxyRepositoryTagResolver(string connectionString = DefaultConnectionString)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(connectionString);
|
||||
_connectionString = connectionString;
|
||||
}
|
||||
|
||||
public async Task<GalaxyTagMetadata> ResolveAsync(
|
||||
string tagReference,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
IReadOnlyList<ParsedTagReference> candidates = ParsedTagReference.ParseCandidates(tagReference);
|
||||
await using var connection = new SqlConnection(_connectionString);
|
||||
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (ParsedTagReference parsed in candidates)
|
||||
{
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = ResolveSql;
|
||||
command.Parameters.AddWithValue("@objectTagName", parsed.ObjectTagName);
|
||||
command.Parameters.AddWithValue("@attributeName", parsed.AttributeName);
|
||||
command.Parameters.AddWithValue("@primitiveName", (object?)parsed.PrimitiveName ?? DBNull.Value);
|
||||
|
||||
await using SqlDataReader reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
GalaxyTagMetadata metadata = ReadMetadata(reader);
|
||||
return parsed.PropertyIdOverride.HasValue
|
||||
? metadata with { PropertyId = parsed.PropertyIdOverride.Value }
|
||||
: metadata;
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Galaxy tag reference '{tagReference}' was not found in the deployed repository metadata.");
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<GalaxyTagMetadata>> BrowseAsync(
|
||||
string objectTagLike = "%",
|
||||
string attributeLike = "%",
|
||||
int maxRows = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(objectTagLike);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(attributeLike);
|
||||
if (maxRows <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(maxRows), "Maximum row count must be positive.");
|
||||
}
|
||||
|
||||
await using var connection = new SqlConnection(_connectionString);
|
||||
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = BrowseSql;
|
||||
command.Parameters.AddWithValue("@objectTagLike", objectTagLike);
|
||||
command.Parameters.AddWithValue("@attributeLike", attributeLike);
|
||||
command.Parameters.AddWithValue("@maxRows", Math.Min(maxRows, 1000));
|
||||
|
||||
List<GalaxyTagMetadata> results = [];
|
||||
await using SqlDataReader reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(ReadMetadata(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static GalaxyTagMetadata ReadMetadata(SqlDataReader reader)
|
||||
{
|
||||
return new GalaxyTagMetadata(
|
||||
ObjectTagName: reader.GetString(0),
|
||||
AttributeName: reader.GetString(1),
|
||||
PrimitiveName: reader.IsDBNull(2) ? null : reader.GetString(2),
|
||||
PlatformId: checked((ushort)reader.GetInt16(3)),
|
||||
EngineId: checked((ushort)reader.GetInt16(4)),
|
||||
ObjectId: checked((ushort)reader.GetInt16(5)),
|
||||
PrimitiveId: reader.GetInt16(6),
|
||||
AttributeId: reader.GetInt16(7),
|
||||
PropertyId: checked((short)reader.GetInt32(8)),
|
||||
MxDataType: reader.GetInt16(9),
|
||||
IsArray: reader.GetBoolean(10),
|
||||
SecurityClassification: reader.GetInt16(11),
|
||||
AttributeSource: reader.GetString(12));
|
||||
}
|
||||
|
||||
private sealed record ParsedTagReference(
|
||||
string ObjectTagName,
|
||||
string? PrimitiveName,
|
||||
string AttributeName,
|
||||
short? PropertyIdOverride)
|
||||
{
|
||||
public static IReadOnlyList<ParsedTagReference> ParseCandidates(string tagReference)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tagReference);
|
||||
var property = ParsePropertySuffix(tagReference);
|
||||
string[] parts = property.BaseReference.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
return parts.Length switch
|
||||
{
|
||||
2 => [new ParsedTagReference(parts[0], null, parts[1], property.PropertyIdOverride)],
|
||||
>= 3 =>
|
||||
[
|
||||
new ParsedTagReference(parts[0], parts[1], string.Join('.', parts.Skip(2)), property.PropertyIdOverride),
|
||||
new ParsedTagReference(parts[0], null, string.Join('.', parts.Skip(1)), property.PropertyIdOverride),
|
||||
],
|
||||
_ => throw new ArgumentException("Tag reference must be Object.Attribute or Object.Primitive.Attribute.", nameof(tagReference)),
|
||||
};
|
||||
}
|
||||
|
||||
private static (string BaseReference, short? PropertyIdOverride) ParsePropertySuffix(string tagReference)
|
||||
{
|
||||
const string bufferSuffix = ".property(buffer)";
|
||||
if (tagReference.EndsWith(bufferSuffix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string baseReference = tagReference[..^bufferSuffix.Length];
|
||||
if (string.IsNullOrWhiteSpace(baseReference))
|
||||
{
|
||||
throw new ArgumentException("Property references must include a base tag reference.", nameof(tagReference));
|
||||
}
|
||||
|
||||
return (baseReference, GalaxyTagMetadata.BufferPropertyId);
|
||||
}
|
||||
|
||||
return (tagReference, null);
|
||||
}
|
||||
}
|
||||
|
||||
private const string ResolveSql = """
|
||||
;WITH deployed_package_chain AS (
|
||||
SELECT
|
||||
g.gobject_id,
|
||||
p.package_id,
|
||||
p.derived_from_package_id,
|
||||
0 AS depth
|
||||
FROM dbo.gobject g
|
||||
INNER JOIN dbo.package p
|
||||
ON p.package_id = g.deployed_package_id
|
||||
WHERE g.is_template = 0
|
||||
AND g.deployed_package_id <> 0
|
||||
AND g.tag_name = @objectTagName
|
||||
UNION ALL
|
||||
SELECT
|
||||
dpc.gobject_id,
|
||||
p.package_id,
|
||||
p.derived_from_package_id,
|
||||
dpc.depth + 1
|
||||
FROM deployed_package_chain dpc
|
||||
INNER JOIN dbo.package p
|
||||
ON p.package_id = dpc.derived_from_package_id
|
||||
WHERE dpc.derived_from_package_id <> 0
|
||||
AND dpc.depth < 10
|
||||
),
|
||||
ranked_dynamic AS (
|
||||
SELECT
|
||||
g.tag_name AS object_tag_name,
|
||||
da.attribute_name,
|
||||
CAST(NULL AS nvarchar(329)) AS primitive_name,
|
||||
i.mx_platform_id,
|
||||
i.mx_engine_id,
|
||||
i.mx_object_id,
|
||||
da.mx_primitive_id,
|
||||
da.mx_attribute_id,
|
||||
CAST(10 AS int) AS property_id,
|
||||
da.mx_data_type,
|
||||
da.is_array,
|
||||
da.security_classification,
|
||||
CAST(N'dynamic' AS nvarchar(16)) AS attribute_source,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY dpc.gobject_id, da.attribute_name
|
||||
ORDER BY dpc.depth
|
||||
) AS rn
|
||||
FROM deployed_package_chain dpc
|
||||
INNER JOIN dbo.dynamic_attribute da
|
||||
ON da.package_id = dpc.package_id
|
||||
INNER JOIN dbo.gobject g
|
||||
ON g.gobject_id = dpc.gobject_id
|
||||
INNER JOIN dbo.instance i
|
||||
ON i.gobject_id = g.gobject_id
|
||||
WHERE da.attribute_name = @attributeName
|
||||
AND @primitiveName IS NULL
|
||||
),
|
||||
primitive_attributes AS (
|
||||
SELECT
|
||||
g.tag_name AS object_tag_name,
|
||||
ad.attribute_name,
|
||||
NULLIF(pi.primitive_name, N'') AS primitive_name,
|
||||
i.mx_platform_id,
|
||||
i.mx_engine_id,
|
||||
i.mx_object_id,
|
||||
pi.mx_primitive_id,
|
||||
ad.mx_attribute_id,
|
||||
CAST(10 AS int) AS property_id,
|
||||
ad.mx_data_type,
|
||||
ad.is_array,
|
||||
ad.security_classification,
|
||||
CAST(N'primitive' AS nvarchar(16)) AS attribute_source,
|
||||
1 AS rn
|
||||
FROM dbo.gobject g
|
||||
INNER JOIN dbo.instance i
|
||||
ON i.gobject_id = g.gobject_id
|
||||
INNER JOIN dbo.primitive_instance pi
|
||||
ON pi.gobject_id = g.gobject_id
|
||||
AND pi.package_id = g.deployed_package_id
|
||||
AND pi.property_bitmask & 0x10 <> 0x10
|
||||
INNER JOIN dbo.attribute_definition ad
|
||||
ON ad.primitive_definition_id = pi.primitive_definition_id
|
||||
WHERE g.tag_name = @objectTagName
|
||||
AND ad.attribute_name = @attributeName
|
||||
AND (
|
||||
(@primitiveName IS NULL AND pi.primitive_name = N'')
|
||||
OR (@primitiveName IS NOT NULL AND pi.primitive_name = @primitiveName)
|
||||
)
|
||||
)
|
||||
SELECT TOP (1)
|
||||
object_tag_name,
|
||||
attribute_name,
|
||||
primitive_name,
|
||||
mx_platform_id,
|
||||
mx_engine_id,
|
||||
mx_object_id,
|
||||
mx_primitive_id,
|
||||
mx_attribute_id,
|
||||
property_id,
|
||||
mx_data_type,
|
||||
is_array,
|
||||
security_classification,
|
||||
attribute_source
|
||||
FROM (
|
||||
SELECT * FROM ranked_dynamic WHERE rn = 1
|
||||
UNION ALL
|
||||
SELECT * FROM primitive_attributes
|
||||
) resolved
|
||||
ORDER BY CASE attribute_source WHEN N'dynamic' THEN 0 ELSE 1 END
|
||||
""";
|
||||
|
||||
private const string BrowseSql = """
|
||||
;WITH deployed_objects AS (
|
||||
SELECT
|
||||
g.gobject_id,
|
||||
g.tag_name,
|
||||
g.deployed_package_id,
|
||||
i.mx_platform_id,
|
||||
i.mx_engine_id,
|
||||
i.mx_object_id
|
||||
FROM dbo.gobject g
|
||||
INNER JOIN dbo.instance i
|
||||
ON i.gobject_id = g.gobject_id
|
||||
WHERE g.is_template = 0
|
||||
AND g.deployed_package_id <> 0
|
||||
AND g.tag_name LIKE @objectTagLike
|
||||
),
|
||||
deployed_package_chain AS (
|
||||
SELECT
|
||||
d.gobject_id,
|
||||
d.tag_name,
|
||||
d.mx_platform_id,
|
||||
d.mx_engine_id,
|
||||
d.mx_object_id,
|
||||
p.package_id,
|
||||
p.derived_from_package_id,
|
||||
0 AS depth
|
||||
FROM deployed_objects d
|
||||
INNER JOIN dbo.package p
|
||||
ON p.package_id = d.deployed_package_id
|
||||
UNION ALL
|
||||
SELECT
|
||||
dpc.gobject_id,
|
||||
dpc.tag_name,
|
||||
dpc.mx_platform_id,
|
||||
dpc.mx_engine_id,
|
||||
dpc.mx_object_id,
|
||||
p.package_id,
|
||||
p.derived_from_package_id,
|
||||
dpc.depth + 1
|
||||
FROM deployed_package_chain dpc
|
||||
INNER JOIN dbo.package p
|
||||
ON p.package_id = dpc.derived_from_package_id
|
||||
WHERE dpc.derived_from_package_id <> 0
|
||||
AND dpc.depth < 10
|
||||
),
|
||||
ranked_dynamic AS (
|
||||
SELECT
|
||||
dpc.tag_name AS object_tag_name,
|
||||
da.attribute_name,
|
||||
CAST(NULL AS nvarchar(329)) AS primitive_name,
|
||||
dpc.mx_platform_id,
|
||||
dpc.mx_engine_id,
|
||||
dpc.mx_object_id,
|
||||
da.mx_primitive_id,
|
||||
da.mx_attribute_id,
|
||||
CAST(10 AS int) AS property_id,
|
||||
da.mx_data_type,
|
||||
da.is_array,
|
||||
da.security_classification,
|
||||
CAST(N'dynamic' AS nvarchar(16)) AS attribute_source,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY dpc.gobject_id, da.attribute_name
|
||||
ORDER BY dpc.depth
|
||||
) AS rn
|
||||
FROM deployed_package_chain dpc
|
||||
INNER JOIN dbo.dynamic_attribute da
|
||||
ON da.package_id = dpc.package_id
|
||||
WHERE da.attribute_name LIKE @attributeLike
|
||||
),
|
||||
primitive_attributes AS (
|
||||
SELECT
|
||||
d.tag_name AS object_tag_name,
|
||||
ad.attribute_name,
|
||||
NULLIF(pi.primitive_name, N'') AS primitive_name,
|
||||
d.mx_platform_id,
|
||||
d.mx_engine_id,
|
||||
d.mx_object_id,
|
||||
pi.mx_primitive_id,
|
||||
ad.mx_attribute_id,
|
||||
CAST(10 AS int) AS property_id,
|
||||
ad.mx_data_type,
|
||||
ad.is_array,
|
||||
ad.security_classification,
|
||||
CAST(N'primitive' AS nvarchar(16)) AS attribute_source,
|
||||
1 AS rn
|
||||
FROM deployed_objects d
|
||||
INNER JOIN dbo.gobject g
|
||||
ON g.gobject_id = d.gobject_id
|
||||
INNER JOIN dbo.primitive_instance pi
|
||||
ON pi.gobject_id = g.gobject_id
|
||||
AND pi.package_id = g.deployed_package_id
|
||||
AND pi.property_bitmask & 0x10 <> 0x10
|
||||
INNER JOIN dbo.attribute_definition ad
|
||||
ON ad.primitive_definition_id = pi.primitive_definition_id
|
||||
WHERE ad.attribute_name LIKE @attributeLike
|
||||
)
|
||||
SELECT TOP (@maxRows)
|
||||
object_tag_name,
|
||||
attribute_name,
|
||||
primitive_name,
|
||||
mx_platform_id,
|
||||
mx_engine_id,
|
||||
mx_object_id,
|
||||
mx_primitive_id,
|
||||
mx_attribute_id,
|
||||
property_id,
|
||||
mx_data_type,
|
||||
is_array,
|
||||
security_classification,
|
||||
attribute_source
|
||||
FROM (
|
||||
SELECT * FROM ranked_dynamic WHERE rn = 1
|
||||
UNION ALL
|
||||
SELECT * FROM primitive_attributes
|
||||
) resolved
|
||||
ORDER BY object_tag_name, primitive_name, attribute_name
|
||||
""";
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
|
||||
namespace MxNativeClient;
|
||||
|
||||
public sealed record GalaxyUserProfile(
|
||||
int UserProfileId,
|
||||
string UserProfileName,
|
||||
Guid UserGuid,
|
||||
string DefaultSecurityGroup,
|
||||
int? InTouchAccessLevel,
|
||||
IReadOnlyList<string> Roles);
|
||||
|
||||
public sealed class GalaxyRepositoryUserResolver
|
||||
{
|
||||
private const string DefaultConnectionString =
|
||||
"Server=localhost;Database=ZB;Integrated Security=True;Encrypt=False;TrustServerCertificate=True";
|
||||
|
||||
private readonly string _connectionString;
|
||||
|
||||
public GalaxyRepositoryUserResolver(string connectionString = DefaultConnectionString)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(connectionString);
|
||||
_connectionString = connectionString;
|
||||
}
|
||||
|
||||
public async Task<int> ResolveUserProfileIdByGuidAsync(
|
||||
Guid userGuid,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
GalaxyUserProfile profile = await ResolveByGuidAsync(userGuid, cancellationToken).ConfigureAwait(false);
|
||||
return profile.UserProfileId;
|
||||
}
|
||||
|
||||
public async Task<GalaxyUserProfile> ResolveByGuidAsync(
|
||||
Guid userGuid,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = new SqlConnection(_connectionString);
|
||||
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = UserByGuidSql;
|
||||
command.Parameters.AddWithValue("@userGuid", userGuid);
|
||||
|
||||
await using SqlDataReader reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
throw new KeyNotFoundException($"Galaxy user GUID {userGuid} was not found in dbo.user_profile.");
|
||||
}
|
||||
|
||||
return ReadProfile(reader);
|
||||
}
|
||||
|
||||
public async Task<GalaxyUserProfile> ResolveByNameAsync(
|
||||
string userName,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(userName);
|
||||
|
||||
await using var connection = new SqlConnection(_connectionString);
|
||||
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = UserByNameSql;
|
||||
command.Parameters.AddWithValue("@userName", userName);
|
||||
|
||||
await using SqlDataReader reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
throw new KeyNotFoundException($"Galaxy user {userName} was not found in dbo.user_profile.");
|
||||
}
|
||||
|
||||
return ReadProfile(reader);
|
||||
}
|
||||
|
||||
private static GalaxyUserProfile ReadProfile(SqlDataReader reader)
|
||||
{
|
||||
return new GalaxyUserProfile(
|
||||
reader.GetInt32(0),
|
||||
reader.GetString(1),
|
||||
reader.GetGuid(2),
|
||||
reader.GetString(3),
|
||||
reader.IsDBNull(4) ? null : reader.GetInt32(4),
|
||||
reader.IsDBNull(5) ? [] : ParseRoleBlob(reader.GetString(5)));
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ParseRoleBlob(string rolesText)
|
||||
{
|
||||
if (!rolesText.StartsWith("0x", StringComparison.OrdinalIgnoreCase) || rolesText.Length <= 2)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
byte[] bytes = Convert.FromHexString(rolesText[2..]);
|
||||
List<string> roles = [];
|
||||
for (int offset = 0; offset + 3 < bytes.Length; offset++)
|
||||
{
|
||||
List<char> chars = [];
|
||||
int cursor = offset;
|
||||
while (cursor + 1 < bytes.Length)
|
||||
{
|
||||
char c = (char)(bytes[cursor] | (bytes[cursor + 1] << 8));
|
||||
if (c == '\0')
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (c < 0x20 || c > 0x7e)
|
||||
{
|
||||
chars.Clear();
|
||||
break;
|
||||
}
|
||||
|
||||
chars.Add(c);
|
||||
cursor += 2;
|
||||
}
|
||||
|
||||
if (chars.Count < 2 || cursor + 1 >= bytes.Length || bytes[cursor] != 0 || bytes[cursor + 1] != 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string role = new(chars.ToArray());
|
||||
if (!roles.Contains(role, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
roles.Add(role);
|
||||
}
|
||||
|
||||
offset = cursor;
|
||||
}
|
||||
|
||||
return roles;
|
||||
}
|
||||
|
||||
private const string UserSelectSql = """
|
||||
SELECT TOP (1)
|
||||
user_profile_id,
|
||||
user_profile_name,
|
||||
user_guid,
|
||||
default_security_group,
|
||||
intouch_access_level,
|
||||
CONVERT(nvarchar(max), roles) AS roles_text
|
||||
FROM dbo.user_profile
|
||||
""";
|
||||
|
||||
private const string UserByGuidSql = UserSelectSql + "\nWHERE user_guid = @userGuid\nORDER BY user_profile_id";
|
||||
|
||||
private const string UserByNameSql = UserSelectSql + "\nWHERE user_profile_name = @userName\nORDER BY user_profile_id";
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace MxNativeClient;
|
||||
|
||||
public sealed class ManagedCallbackExporter : IDisposable
|
||||
{
|
||||
private readonly TcpListener _listener;
|
||||
private readonly CancellationTokenSource _cancellation = new();
|
||||
private readonly Task _acceptLoop;
|
||||
private readonly List<string> _events = [];
|
||||
private readonly object _eventsLock = new();
|
||||
private readonly ulong _oxid = RandomUInt64();
|
||||
private readonly ulong _oid = RandomUInt64();
|
||||
|
||||
public ManagedCallbackExporter(int port = 0)
|
||||
{
|
||||
CallbackIpid = Guid.NewGuid();
|
||||
RemUnknownIpid = Guid.NewGuid();
|
||||
_listener = new TcpListener(IPAddress.Any, port);
|
||||
_listener.Start();
|
||||
Port = ((IPEndPoint)_listener.LocalEndpoint).Port;
|
||||
_acceptLoop = Task.Run(AcceptLoopAsync);
|
||||
}
|
||||
|
||||
public int Port { get; }
|
||||
|
||||
public Guid CallbackIpid { get; }
|
||||
|
||||
public Guid RemUnknownIpid { get; }
|
||||
|
||||
public IReadOnlyList<string> Events
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_eventsLock)
|
||||
{
|
||||
return _events.ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public byte[] CreateCallbackObjRef(string hostName)
|
||||
{
|
||||
return ComObjRefBuilder.CreateStandardObjRef(
|
||||
NmxProcedureMetadata.INmxSvcCallback,
|
||||
stdFlags: 0x280,
|
||||
publicRefs: 5,
|
||||
oxid: _oxid,
|
||||
oid: _oid,
|
||||
ipid: CallbackIpid,
|
||||
stringBindings: [$"{hostName}[{Port}]"]);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cancellation.Cancel();
|
||||
_listener.Stop();
|
||||
try
|
||||
{
|
||||
_acceptLoop.Wait(TimeSpan.FromSeconds(1));
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
_cancellation.Dispose();
|
||||
}
|
||||
|
||||
private async Task AcceptLoopAsync()
|
||||
{
|
||||
while (!_cancellation.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var client = await _listener.AcceptTcpClientAsync(_cancellation.Token).ConfigureAwait(false);
|
||||
Record($"accept remote={client.Client.RemoteEndPoint}");
|
||||
_ = Task.Run(() => ServeClientAsync(client), _cancellation.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Record($"accept_error {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ServeClientAsync(TcpClient client)
|
||||
{
|
||||
using (client)
|
||||
{
|
||||
NetworkStream stream = client.GetStream();
|
||||
ushort currentContext = 0;
|
||||
Guid currentInterface = Guid.Empty;
|
||||
|
||||
while (!_cancellation.IsCancellationRequested)
|
||||
{
|
||||
byte[]? pdu = await ReadPduAsync(stream, _cancellation.Token).ConfigureAwait(false);
|
||||
if (pdu is null)
|
||||
{
|
||||
Record("client_closed");
|
||||
return;
|
||||
}
|
||||
|
||||
var header = DceRpcPduHeader.Parse(pdu);
|
||||
Record($"pdu type={header.PacketType} flags=0x{header.PacketFlags:X2} frag={header.FragmentLength} auth={header.AuthLength} call={header.CallId}");
|
||||
|
||||
if (header.PacketType == DceRpcPacketType.Bind || header.PacketType == DceRpcPacketType.AlterContext)
|
||||
{
|
||||
var bind = DceRpcBindPdu.Parse(pdu);
|
||||
currentContext = bind.PresentationContexts.Count == 0 ? (ushort)0 : bind.PresentationContexts[0].ContextId;
|
||||
currentInterface = bind.PresentationContexts.Count == 0 ? Guid.Empty : bind.PresentationContexts[0].AbstractSyntax.Uuid;
|
||||
Record($"bind context={currentContext} iid={currentInterface}");
|
||||
await stream.WriteAsync(EncodeBindAck(header.CallId, currentContext), _cancellation.Token).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (header.PacketType == DceRpcPacketType.Request)
|
||||
{
|
||||
byte[] response = HandleRequest(pdu, header, currentContext, currentInterface);
|
||||
await stream.WriteAsync(response, _cancellation.Token).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (header.PacketType == DceRpcPacketType.Auth3)
|
||||
{
|
||||
Record("auth3_ignored");
|
||||
continue;
|
||||
}
|
||||
|
||||
Record($"unhandled_pdu {header.PacketType}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] HandleRequest(byte[] pdu, DceRpcPduHeader header, ushort currentContext, Guid currentInterface)
|
||||
{
|
||||
int fixedOffset = DceRpcPduHeader.Length;
|
||||
uint allocationHint = BinaryPrimitives.ReadUInt32LittleEndian(pdu.AsSpan(fixedOffset, 4));
|
||||
ushort contextId = BinaryPrimitives.ReadUInt16LittleEndian(pdu.AsSpan(fixedOffset + 4, 2));
|
||||
ushort opnum = BinaryPrimitives.ReadUInt16LittleEndian(pdu.AsSpan(fixedOffset + 6, 2));
|
||||
int stubOffset = fixedOffset + 8;
|
||||
Guid? objectUuid = null;
|
||||
if ((header.PacketFlags & 0x80) != 0)
|
||||
{
|
||||
objectUuid = new Guid(pdu.AsSpan(stubOffset, 16));
|
||||
stubOffset += 16;
|
||||
}
|
||||
|
||||
int trailerLength = header.AuthLength == 0 ? 0 : DceRpcAuthTrailer.Length + header.AuthLength;
|
||||
int stubLength = header.FragmentLength - stubOffset - trailerLength;
|
||||
ReadOnlySpan<byte> stub = pdu.AsSpan(stubOffset, Math.Max(0, stubLength));
|
||||
|
||||
Record($"request iid={currentInterface} context={contextId}/{currentContext} opnum={opnum} object={objectUuid} alloc={allocationHint} stub={stub.Length}");
|
||||
|
||||
if (currentInterface == RemUnknownMessages.IRemUnknown)
|
||||
{
|
||||
return opnum switch
|
||||
{
|
||||
RemUnknownMessages.RemQueryInterfaceOpnum => EncodeResponse(header.CallId, contextId, EncodeRemQueryInterfaceResponse(stub)),
|
||||
RemUnknownMessages.RemAddRefOpnum => EncodeResponse(header.CallId, contextId, EncodeOrpcHResultResponse(0)),
|
||||
RemUnknownMessages.RemReleaseOpnum => EncodeResponse(header.CallId, contextId, EncodeOrpcHResultResponse(0)),
|
||||
_ => EncodeFault(header.CallId, contextId, 0x000006F7),
|
||||
};
|
||||
}
|
||||
|
||||
if (currentInterface == NmxSvcCallbackMessages.InterfaceId)
|
||||
{
|
||||
if (opnum is NmxSvcCallbackMessages.DataReceivedOpnum or NmxSvcCallbackMessages.StatusReceivedOpnum)
|
||||
{
|
||||
var parsed = NmxSvcCallbackMessages.ParseCallbackRequest(stub);
|
||||
Record($"callback opnum={opnum} body={parsed.Body.Length}");
|
||||
return EncodeResponse(header.CallId, contextId, NmxSvcCallbackMessages.EncodeCallbackResponse(0));
|
||||
}
|
||||
|
||||
return EncodeFault(header.CallId, contextId, 0x000006F7);
|
||||
}
|
||||
|
||||
return EncodeFault(header.CallId, contextId, 0x000006F7);
|
||||
}
|
||||
|
||||
private byte[] EncodeRemQueryInterfaceResponse(ReadOnlySpan<byte> request)
|
||||
{
|
||||
Guid requestedIid = request.Length >= 76 ? new Guid(request.Slice(60, 16)) : Guid.Empty;
|
||||
Record($"remqi requested={requestedIid}");
|
||||
|
||||
var std = new StdObjRef(0x280, 5, _oxid, _oid, requestedIid == RemUnknownMessages.IRemUnknown ? RemUnknownIpid : CallbackIpid);
|
||||
int hr = requestedIid == RemUnknownMessages.IRemUnknown
|
||||
|| requestedIid == NmxProcedureMetadata.INmxSvcCallback
|
||||
|| requestedIid == new Guid("00000000-0000-0000-C000-000000000046")
|
||||
? 0
|
||||
: unchecked((int)0x80004002);
|
||||
|
||||
byte[] buffer = new byte[OrpcThat.EncodedLengthWithoutExtensions + 4 + 4 + RemQiResult.EncodedLength + 4];
|
||||
int offset = 0;
|
||||
new OrpcThat(0, 0).Encode().CopyTo(buffer.AsSpan(offset));
|
||||
offset += OrpcThat.EncodedLengthWithoutExtensions;
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(offset, 4), 0x00020000);
|
||||
offset += 4;
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(offset, 4), 1);
|
||||
offset += 4;
|
||||
BinaryPrimitives.WriteInt32LittleEndian(buffer.AsSpan(offset, 4), hr);
|
||||
offset += 8;
|
||||
std.Encode().CopyTo(buffer.AsSpan(offset));
|
||||
offset += StdObjRef.EncodedLength;
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(offset, 4), 0);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
private static byte[] EncodeOrpcHResultResponse(int hresult)
|
||||
{
|
||||
byte[] buffer = new byte[OrpcThat.EncodedLengthWithoutExtensions + 4];
|
||||
new OrpcThat(0, 0).Encode().CopyTo(buffer.AsSpan());
|
||||
BinaryPrimitives.WriteInt32LittleEndian(buffer.AsSpan(OrpcThat.EncodedLengthWithoutExtensions, 4), hresult);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
private static byte[] EncodeBindAck(uint callId, ushort contextId)
|
||||
{
|
||||
const string secondaryAddress = "";
|
||||
byte[] secondary = System.Text.Encoding.ASCII.GetBytes(secondaryAddress + "\0");
|
||||
int secAddrLength = secondary.Length;
|
||||
int secPad = Align(28 + 2 + secAddrLength, 4) - (28 + 2 + secAddrLength);
|
||||
int resultOffset = 28 + 2 + secAddrLength + secPad;
|
||||
int length = resultOffset + 4 + 24;
|
||||
byte[] pdu = new byte[length];
|
||||
new DceRpcPduHeader(5, 0, DceRpcPacketType.BindAck, 0x03, 0x10, (ushort)length, 0, callId).WriteTo(pdu);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(pdu.AsSpan(16, 2), 4280);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(pdu.AsSpan(18, 2), 4280);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(pdu.AsSpan(20, 4), 0x00005353);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(pdu.AsSpan(24, 2), (ushort)secAddrLength);
|
||||
secondary.CopyTo(pdu.AsSpan(26));
|
||||
int offset = resultOffset;
|
||||
pdu[offset++] = 1;
|
||||
pdu[offset++] = 0;
|
||||
pdu[offset++] = 0;
|
||||
pdu[offset++] = 0;
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(pdu.AsSpan(offset, 2), 0);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(pdu.AsSpan(offset + 2, 2), 0);
|
||||
offset += 4;
|
||||
DceRpcSyntaxId.Ndr20.Uuid.TryWriteBytes(pdu.AsSpan(offset, 16));
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(pdu.AsSpan(offset + 16, 2), DceRpcSyntaxId.Ndr20.VersionMajor);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(pdu.AsSpan(offset + 18, 2), DceRpcSyntaxId.Ndr20.VersionMinor);
|
||||
_ = contextId;
|
||||
return pdu;
|
||||
}
|
||||
|
||||
private static byte[] EncodeResponse(uint callId, ushort contextId, byte[] stubData)
|
||||
{
|
||||
int length = 24 + stubData.Length;
|
||||
byte[] pdu = new byte[length];
|
||||
new DceRpcPduHeader(5, 0, DceRpcPacketType.Response, 0x03, 0x10, (ushort)length, 0, callId).WriteTo(pdu);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(pdu.AsSpan(16, 4), (uint)stubData.Length);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(pdu.AsSpan(20, 2), contextId);
|
||||
pdu[22] = 0;
|
||||
pdu[23] = 0;
|
||||
stubData.CopyTo(pdu.AsSpan(24));
|
||||
return pdu;
|
||||
}
|
||||
|
||||
private static byte[] EncodeFault(uint callId, ushort contextId, uint status)
|
||||
{
|
||||
byte[] pdu = new byte[28];
|
||||
new DceRpcPduHeader(5, 0, DceRpcPacketType.Fault, 0x03, 0x10, (ushort)pdu.Length, 0, callId).WriteTo(pdu);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(pdu.AsSpan(16, 4), 0);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(pdu.AsSpan(20, 2), contextId);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(pdu.AsSpan(24, 4), status);
|
||||
return pdu;
|
||||
}
|
||||
|
||||
private async Task<byte[]?> ReadPduAsync(NetworkStream stream, CancellationToken cancellationToken)
|
||||
{
|
||||
byte[] header = new byte[DceRpcPduHeader.Length];
|
||||
if (!await ReadExactAsync(stream, header, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var parsed = DceRpcPduHeader.Parse(header);
|
||||
byte[] pdu = new byte[parsed.FragmentLength];
|
||||
header.CopyTo(pdu, 0);
|
||||
if (!await ReadExactAsync(stream, pdu.AsMemory(DceRpcPduHeader.Length), cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return pdu;
|
||||
}
|
||||
|
||||
private static async Task<bool> ReadExactAsync(NetworkStream stream, Memory<byte> buffer, CancellationToken cancellationToken)
|
||||
{
|
||||
int offset = 0;
|
||||
while (offset < buffer.Length)
|
||||
{
|
||||
int read = await stream.ReadAsync(buffer[offset..], cancellationToken).ConfigureAwait(false);
|
||||
if (read == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
offset += read;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private void Record(string message)
|
||||
{
|
||||
lock (_eventsLock)
|
||||
{
|
||||
_events.Add($"{DateTimeOffset.UtcNow:O} {message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static int Align(int value, int alignment)
|
||||
{
|
||||
int remainder = value % alignment;
|
||||
return remainder == 0 ? value : value + alignment - remainder;
|
||||
}
|
||||
|
||||
private static ulong RandomUInt64()
|
||||
{
|
||||
Span<byte> bytes = stackalloc byte[8];
|
||||
System.Security.Cryptography.RandomNumberGenerator.Fill(bytes);
|
||||
return BinaryPrimitives.ReadUInt64LittleEndian(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
public static class ComObjRefBuilder
|
||||
{
|
||||
public static byte[] CreateStandardObjRef(
|
||||
Guid iid,
|
||||
uint stdFlags,
|
||||
uint publicRefs,
|
||||
ulong oxid,
|
||||
ulong oid,
|
||||
Guid ipid,
|
||||
IReadOnlyList<string> stringBindings)
|
||||
{
|
||||
ushort securityOffset = (ushort)(stringBindings.Sum(binding => 1 + binding.Length + 1) + 1);
|
||||
var words = new List<ushort>();
|
||||
foreach (string binding in stringBindings)
|
||||
{
|
||||
words.Add(0x0007);
|
||||
foreach (char ch in binding)
|
||||
{
|
||||
words.Add(ch);
|
||||
}
|
||||
|
||||
words.Add(0);
|
||||
}
|
||||
|
||||
words.Add(0);
|
||||
foreach (ushort authenticationService in new ushort[] { 0x0009, 0x001e, 0x0010, 0x000a, 0x0016, 0x001f, 0x000e })
|
||||
{
|
||||
words.Add(authenticationService);
|
||||
words.Add(0xffff);
|
||||
words.Add(0);
|
||||
}
|
||||
|
||||
words.Add(0);
|
||||
|
||||
ushort entries = (ushort)words.Count;
|
||||
byte[] buffer = new byte[68 + entries * 2];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(0, 4), 0x574F454D);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(4, 4), 1);
|
||||
iid.TryWriteBytes(buffer.AsSpan(8, 16));
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(24, 4), stdFlags);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(28, 4), publicRefs);
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(buffer.AsSpan(32, 8), oxid);
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(buffer.AsSpan(40, 8), oid);
|
||||
ipid.TryWriteBytes(buffer.AsSpan(48, 16));
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(64, 2), entries);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(66, 2), securityOffset);
|
||||
|
||||
int offset = 68;
|
||||
foreach (ushort word in words)
|
||||
{
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(offset, 2), word);
|
||||
offset += 2;
|
||||
}
|
||||
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,575 @@
|
||||
using System.Globalization;
|
||||
using System.Runtime.InteropServices;
|
||||
using MxNativeCodec;
|
||||
|
||||
namespace MxNativeClient;
|
||||
|
||||
public enum DceRpcClientAuthentication
|
||||
{
|
||||
ManagedNtlm,
|
||||
WindowsSspiNtlm,
|
||||
}
|
||||
|
||||
public sealed class ManagedNmxService2Client : IDisposable
|
||||
{
|
||||
private readonly object _activatedComObject;
|
||||
private readonly DceRpcTcpClient _serviceClient;
|
||||
private readonly Guid _serviceIpid;
|
||||
private bool _disposed;
|
||||
|
||||
private ManagedNmxService2Client(object activatedComObject, DceRpcTcpClient serviceClient, Guid serviceIpid)
|
||||
{
|
||||
_activatedComObject = activatedComObject;
|
||||
_serviceClient = serviceClient;
|
||||
_serviceIpid = serviceIpid;
|
||||
}
|
||||
|
||||
public string Host { get; private init; } = string.Empty;
|
||||
public int Port { get; private init; }
|
||||
|
||||
public static ManagedNmxService2Client Create(
|
||||
DceRpcClientAuthentication authentication = DceRpcClientAuthentication.ManagedNtlm)
|
||||
{
|
||||
var type = Type.GetTypeFromProgID("NmxSvc.NmxService", throwOnError: true)
|
||||
?? throw new InvalidOperationException("ProgID NmxSvc.NmxService was not resolved.");
|
||||
object instance = Activator.CreateInstance(type)
|
||||
?? throw new InvalidOperationException("ProgID NmxSvc.NmxService activation returned null.");
|
||||
|
||||
try
|
||||
{
|
||||
var resolved = ResolveService(instance, authentication);
|
||||
var serviceClient = new DceRpcTcpClient(resolved.Host, resolved.Port);
|
||||
serviceClient.Connect();
|
||||
BindWithAuthentication(
|
||||
serviceClient,
|
||||
NmxService2Messages.InterfaceId,
|
||||
authentication,
|
||||
resolved.Host);
|
||||
|
||||
return new ManagedNmxService2Client(instance, serviceClient, resolved.ServiceIpid)
|
||||
{
|
||||
Host = resolved.Host,
|
||||
Port = resolved.Port,
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (Marshal.IsComObject(instance))
|
||||
{
|
||||
_ = Marshal.ReleaseComObject(instance);
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public int GetPartnerVersion(int galaxyId, int platformId, int engineId)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
byte[] request = NmxService2Messages.EncodeGetPartnerVersionRequest(
|
||||
OrpcThis.Create(Guid.NewGuid()),
|
||||
galaxyId,
|
||||
platformId,
|
||||
engineId);
|
||||
var response = _serviceClient.CallBoundObject(_serviceIpid, NmxService2Messages.GetPartnerVersionOpnum, request);
|
||||
var parsed = NmxService2Messages.ParseGetPartnerVersionResponse(response.StubData.Span);
|
||||
ThrowIfFailed(parsed.HResult, nameof(GetPartnerVersion));
|
||||
return parsed.PartnerVersion;
|
||||
}
|
||||
|
||||
public int RegisterEngine2(int localEngineId, string engineName, int version, byte[] callbackObjRef)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
byte[] request = NmxService2Messages.EncodeRegisterEngine2Request(
|
||||
OrpcThis.Create(Guid.NewGuid()),
|
||||
localEngineId,
|
||||
engineName,
|
||||
version,
|
||||
callbackObjRef);
|
||||
return CallForHResult(NmxService2Messages.RegisterEngine2Opnum, request);
|
||||
}
|
||||
|
||||
public int RegisterEngine2WithoutCallback(int localEngineId, string engineName, int version)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
byte[] request = NmxService2Messages.EncodeRegisterEngine2Request(
|
||||
OrpcThis.Create(Guid.NewGuid()),
|
||||
localEngineId,
|
||||
engineName,
|
||||
version,
|
||||
callbackObjRef: null);
|
||||
return CallForHResult(NmxService2Messages.RegisterEngine2Opnum, request);
|
||||
}
|
||||
|
||||
public int UnregisterEngine(int localEngineId)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
byte[] request = NmxService2Messages.EncodeUnregisterEngineRequest(
|
||||
OrpcThis.Create(Guid.NewGuid()),
|
||||
localEngineId);
|
||||
return CallForHResult(NmxService2Messages.UnregisterEngineOpnum, request);
|
||||
}
|
||||
|
||||
public int Connect(int localEngineId, int remoteGalaxyId, int remotePlatformId, int remoteEngineId)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
byte[] request = NmxService2Messages.EncodeConnectRequest(
|
||||
OrpcThis.Create(Guid.NewGuid()),
|
||||
localEngineId,
|
||||
remoteGalaxyId,
|
||||
remotePlatformId,
|
||||
remoteEngineId);
|
||||
return CallForHResult(NmxService2Messages.ConnectOpnum, request);
|
||||
}
|
||||
|
||||
public int AddSubscriberEngine(int localEngineId, int subscriberGalaxyId, int subscriberPlatformId, int subscriberEngineId)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
byte[] request = NmxService2Messages.EncodeSubscriberEngineRequest(
|
||||
OrpcThis.Create(Guid.NewGuid()),
|
||||
localEngineId,
|
||||
subscriberGalaxyId,
|
||||
subscriberPlatformId,
|
||||
subscriberEngineId);
|
||||
return CallForHResult(NmxService2Messages.AddSubscriberEngineOpnum, request);
|
||||
}
|
||||
|
||||
public int RemoveSubscriberEngine(int localEngineId, int subscriberGalaxyId, int subscriberPlatformId, int subscriberEngineId)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
byte[] request = NmxService2Messages.EncodeSubscriberEngineRequest(
|
||||
OrpcThis.Create(Guid.NewGuid()),
|
||||
localEngineId,
|
||||
subscriberGalaxyId,
|
||||
subscriberPlatformId,
|
||||
subscriberEngineId);
|
||||
return CallForHResult(NmxService2Messages.RemoveSubscriberEngineOpnum, request);
|
||||
}
|
||||
|
||||
public int SetHeartbeatSendInterval(int ticksPerBeat, int maxMissedTicks)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
byte[] request = NmxService2Messages.EncodeSetHeartbeatSendIntervalRequest(
|
||||
OrpcThis.Create(Guid.NewGuid()),
|
||||
ticksPerBeat,
|
||||
maxMissedTicks);
|
||||
return CallForHResult(NmxService2Messages.SetHeartbeatSendIntervalOpnum, request);
|
||||
}
|
||||
|
||||
public int TransferData(int remoteGalaxyId, int remotePlatformId, int remoteEngineId, ReadOnlySpan<byte> messageBody)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
ValidateTransferDataBody(messageBody, nameof(messageBody));
|
||||
byte[] request = NmxService2Messages.EncodeTransferDataRequest(
|
||||
OrpcThis.Create(Guid.NewGuid()),
|
||||
remoteGalaxyId,
|
||||
remotePlatformId,
|
||||
remoteEngineId,
|
||||
messageBody);
|
||||
return CallForHResult(NmxService2Messages.TransferDataOpnum, request);
|
||||
}
|
||||
|
||||
private static void ValidateTransferDataBody(ReadOnlySpan<byte> messageBody, string parameterName)
|
||||
{
|
||||
if (messageBody.IsEmpty)
|
||||
{
|
||||
throw new ArgumentException("TransferData body cannot be empty.", parameterName);
|
||||
}
|
||||
|
||||
NmxTransferEnvelopeTemplate.FromObserved(messageBody);
|
||||
if (messageBody.Length == NmxTransferEnvelopeTemplate.HeaderLength)
|
||||
{
|
||||
throw new ArgumentException("TransferData body must include an inner message after the 46-byte envelope.", parameterName);
|
||||
}
|
||||
}
|
||||
|
||||
internal static byte[] EncodeWriteTransferBody(
|
||||
int localEngineId,
|
||||
GalaxyTagMetadata tag,
|
||||
object value,
|
||||
int writeIndex = 1,
|
||||
uint clientToken = 0,
|
||||
int galaxyId = 1,
|
||||
int sourceGalaxyId = 1,
|
||||
int sourcePlatformId = 1)
|
||||
{
|
||||
var projection = tag.ProjectWriteValue(value);
|
||||
byte[] innerBody = NmxWriteMessage.Encode(
|
||||
tag.ToReferenceHandle((byte)galaxyId),
|
||||
projection.ValueKind,
|
||||
projection.Value,
|
||||
writeIndex,
|
||||
clientToken);
|
||||
return NmxTransferEnvelope.Encode(
|
||||
NmxTransferMessageKind.Write,
|
||||
localEngineId,
|
||||
galaxyId,
|
||||
tag.PlatformId,
|
||||
tag.EngineId,
|
||||
innerBody,
|
||||
sourceGalaxyId,
|
||||
sourcePlatformId);
|
||||
}
|
||||
|
||||
internal static byte[] EncodeWrite2TransferBody(
|
||||
int localEngineId,
|
||||
GalaxyTagMetadata tag,
|
||||
object value,
|
||||
DateTime timestamp,
|
||||
int writeIndex = 1,
|
||||
uint clientToken = 0,
|
||||
int galaxyId = 1,
|
||||
int sourceGalaxyId = 1,
|
||||
int sourcePlatformId = 1)
|
||||
{
|
||||
var projection = tag.ProjectWriteValue(value);
|
||||
byte[] innerBody = NmxWriteMessage.EncodeTimestamped(
|
||||
tag.ToReferenceHandle((byte)galaxyId),
|
||||
projection.ValueKind,
|
||||
projection.Value,
|
||||
timestamp,
|
||||
writeIndex,
|
||||
clientToken);
|
||||
return NmxTransferEnvelope.Encode(
|
||||
NmxTransferMessageKind.Write,
|
||||
localEngineId,
|
||||
galaxyId,
|
||||
tag.PlatformId,
|
||||
tag.EngineId,
|
||||
innerBody,
|
||||
sourceGalaxyId,
|
||||
sourcePlatformId);
|
||||
}
|
||||
|
||||
internal static byte[] EncodeWriteSecured2TransferBody(
|
||||
int localEngineId,
|
||||
GalaxyTagMetadata tag,
|
||||
object value,
|
||||
DateTime timestamp,
|
||||
string clientName,
|
||||
int currentUserId,
|
||||
int verifierUserId,
|
||||
int writeIndex = 1,
|
||||
uint clientToken = 0,
|
||||
int galaxyId = 1,
|
||||
int sourceGalaxyId = 1,
|
||||
int sourcePlatformId = 1)
|
||||
{
|
||||
var projection = tag.ProjectWriteValue(value);
|
||||
byte[] innerBody = NmxSecuredWrite2Message.Encode(
|
||||
tag.ToReferenceHandle((byte)galaxyId),
|
||||
projection.ValueKind,
|
||||
projection.Value,
|
||||
timestamp,
|
||||
clientName,
|
||||
NmxSecuredWrite2Message.ResolveObservedUserToken(currentUserId),
|
||||
NmxSecuredWrite2Message.ResolveObservedUserToken(verifierUserId),
|
||||
writeIndex,
|
||||
clientToken);
|
||||
return NmxTransferEnvelope.Encode(
|
||||
NmxTransferMessageKind.Write,
|
||||
localEngineId,
|
||||
galaxyId,
|
||||
tag.PlatformId,
|
||||
tag.EngineId,
|
||||
innerBody,
|
||||
sourceGalaxyId,
|
||||
sourcePlatformId);
|
||||
}
|
||||
|
||||
internal static byte[] EncodeAdviseSupervisoryTransferBody(
|
||||
int localEngineId,
|
||||
GalaxyTagMetadata tag,
|
||||
Guid itemCorrelationId,
|
||||
int galaxyId = 1,
|
||||
int sourceGalaxyId = 1,
|
||||
int sourcePlatformId = 1)
|
||||
{
|
||||
byte[] innerBody = NmxItemControlMessage.FromReferenceHandle(
|
||||
NmxItemControlCommand.AdviseSupervisory,
|
||||
itemCorrelationId,
|
||||
tag.ToReferenceHandle((byte)galaxyId)).Encode();
|
||||
return NmxTransferEnvelope.Encode(
|
||||
NmxTransferMessageKind.ItemControl,
|
||||
localEngineId,
|
||||
galaxyId,
|
||||
tag.PlatformId,
|
||||
tag.EngineId,
|
||||
innerBody,
|
||||
sourceGalaxyId,
|
||||
sourcePlatformId);
|
||||
}
|
||||
|
||||
public int Write(
|
||||
int localEngineId,
|
||||
GalaxyTagMetadata tag,
|
||||
object value,
|
||||
int writeIndex = 1,
|
||||
uint clientToken = 0,
|
||||
int galaxyId = 1,
|
||||
int sourceGalaxyId = 1,
|
||||
int sourcePlatformId = 1)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
byte[] transferBody = EncodeWriteTransferBody(
|
||||
localEngineId,
|
||||
tag,
|
||||
value,
|
||||
writeIndex,
|
||||
clientToken,
|
||||
galaxyId,
|
||||
sourceGalaxyId,
|
||||
sourcePlatformId);
|
||||
return TransferData(galaxyId, tag.PlatformId, tag.EngineId, transferBody);
|
||||
}
|
||||
|
||||
public int Write2(
|
||||
int localEngineId,
|
||||
GalaxyTagMetadata tag,
|
||||
object value,
|
||||
DateTime timestamp,
|
||||
int writeIndex = 1,
|
||||
uint clientToken = 0,
|
||||
int galaxyId = 1,
|
||||
int sourceGalaxyId = 1,
|
||||
int sourcePlatformId = 1)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
byte[] transferBody = EncodeWrite2TransferBody(
|
||||
localEngineId,
|
||||
tag,
|
||||
value,
|
||||
timestamp,
|
||||
writeIndex,
|
||||
clientToken,
|
||||
galaxyId,
|
||||
sourceGalaxyId,
|
||||
sourcePlatformId);
|
||||
return TransferData(galaxyId, tag.PlatformId, tag.EngineId, transferBody);
|
||||
}
|
||||
|
||||
public int WriteSecured2(
|
||||
int localEngineId,
|
||||
GalaxyTagMetadata tag,
|
||||
object value,
|
||||
DateTime timestamp,
|
||||
string clientName,
|
||||
int currentUserId,
|
||||
int verifierUserId,
|
||||
int writeIndex = 1,
|
||||
uint clientToken = 0,
|
||||
int galaxyId = 1,
|
||||
int sourceGalaxyId = 1,
|
||||
int sourcePlatformId = 1)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
byte[] transferBody = EncodeWriteSecured2TransferBody(
|
||||
localEngineId,
|
||||
tag,
|
||||
value,
|
||||
timestamp,
|
||||
clientName,
|
||||
currentUserId,
|
||||
verifierUserId,
|
||||
writeIndex,
|
||||
clientToken,
|
||||
galaxyId,
|
||||
sourceGalaxyId,
|
||||
sourcePlatformId);
|
||||
return TransferData(galaxyId, tag.PlatformId, tag.EngineId, transferBody);
|
||||
}
|
||||
|
||||
public int AdviseSupervisory(
|
||||
int localEngineId,
|
||||
GalaxyTagMetadata tag,
|
||||
Guid itemCorrelationId,
|
||||
int galaxyId = 1,
|
||||
int sourceGalaxyId = 1,
|
||||
int sourcePlatformId = 1)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
byte[] transferBody = EncodeAdviseSupervisoryTransferBody(
|
||||
localEngineId,
|
||||
tag,
|
||||
itemCorrelationId,
|
||||
galaxyId,
|
||||
sourceGalaxyId,
|
||||
sourcePlatformId);
|
||||
return TransferData(galaxyId, tag.PlatformId, tag.EngineId, transferBody);
|
||||
}
|
||||
|
||||
public int SendObservedPreAdviseMetadata(
|
||||
int localEngineId,
|
||||
Guid itemCorrelationId,
|
||||
int galaxyId = 1,
|
||||
int sourceGalaxyId = 1,
|
||||
int sourcePlatformId = 1)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
byte[] innerBody = NmxMetadataQueryMessage.EncodeObservedPreAdvise(itemCorrelationId);
|
||||
byte[] transferBody = NmxTransferEnvelope.Encode(
|
||||
NmxTransferMessageKind.Metadata,
|
||||
localEngineId,
|
||||
galaxyId,
|
||||
targetPlatformId: 1,
|
||||
targetEngineId: 1,
|
||||
innerBody,
|
||||
sourceGalaxyId,
|
||||
sourcePlatformId);
|
||||
return TransferData(galaxyId, 1, 1, transferBody);
|
||||
}
|
||||
|
||||
public int RegisterReference(
|
||||
int localEngineId,
|
||||
GalaxyTagMetadata routeTag,
|
||||
NmxReferenceRegistrationMessage message,
|
||||
int galaxyId = 1,
|
||||
int sourceGalaxyId = 1,
|
||||
int sourcePlatformId = 1)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
byte[] transferBody = NmxTransferEnvelope.Encode(
|
||||
NmxTransferMessageKind.ItemControl,
|
||||
localEngineId,
|
||||
galaxyId,
|
||||
routeTag.PlatformId,
|
||||
routeTag.EngineId,
|
||||
message.Encode(),
|
||||
sourceGalaxyId,
|
||||
sourcePlatformId);
|
||||
return TransferData(galaxyId, routeTag.PlatformId, routeTag.EngineId, transferBody);
|
||||
}
|
||||
|
||||
public int UnAdvise(
|
||||
int localEngineId,
|
||||
GalaxyTagMetadata tag,
|
||||
Guid itemCorrelationId,
|
||||
int galaxyId = 1,
|
||||
int sourceGalaxyId = 1,
|
||||
int sourcePlatformId = 1)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
byte[] innerBody = NmxItemControlMessage.FromReferenceHandle(
|
||||
NmxItemControlCommand.UnAdvise,
|
||||
itemCorrelationId,
|
||||
tag.ToReferenceHandle((byte)galaxyId)).Encode();
|
||||
byte[] transferBody = NmxTransferEnvelope.Encode(
|
||||
NmxTransferMessageKind.Write,
|
||||
localEngineId,
|
||||
galaxyId,
|
||||
tag.PlatformId,
|
||||
tag.EngineId,
|
||||
innerBody,
|
||||
sourceGalaxyId,
|
||||
sourcePlatformId);
|
||||
return TransferData(galaxyId, tag.PlatformId, tag.EngineId, transferBody);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_serviceClient.Dispose();
|
||||
if (Marshal.IsComObject(_activatedComObject))
|
||||
{
|
||||
_ = Marshal.ReleaseComObject(_activatedComObject);
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
private int CallForHResult(ushort opnum, byte[] request)
|
||||
{
|
||||
var response = _serviceClient.CallBoundObject(_serviceIpid, opnum, request);
|
||||
var parsed = NmxService2Messages.ParseHResultResponse(response.StubData.Span);
|
||||
return parsed.HResult;
|
||||
}
|
||||
|
||||
private static (string Host, int Port, Guid ServiceIpid) ResolveService(
|
||||
object instance,
|
||||
DceRpcClientAuthentication authentication)
|
||||
{
|
||||
byte[] buffer = ComObjRefProvider.MarshalIUnknownObjRef(
|
||||
instance,
|
||||
ComObjRefProvider.MarshalContextDifferentMachine);
|
||||
var objRef = ComObjRef.Parse(buffer);
|
||||
var exporter = new ObjectExporterClient();
|
||||
var oxidResponse = authentication == DceRpcClientAuthentication.ManagedNtlm
|
||||
? exporter.ResolveOxidWithManagedNtlmPacketIntegrity(objRef.Oxid)
|
||||
: exporter.ResolveOxidWithNtlmPacketIntegrity(objRef.Oxid);
|
||||
var resolved = ObjectExporterMessages.ParseResolveOxidResult(oxidResponse.StubData.Span);
|
||||
var endpoint = resolved.Bindings.First(binding => binding.TowerId == ObjectExporterMessages.ProtseqNcacnIpTcp);
|
||||
string host = ParseBracketedHost(endpoint.Value);
|
||||
int port = ParseBracketedPort(endpoint.Value);
|
||||
|
||||
using var client = new DceRpcTcpClient(host, port);
|
||||
client.Connect();
|
||||
BindWithAuthentication(client, RemUnknownMessages.IRemUnknown, authentication, host);
|
||||
byte[] request = RemUnknownMessages.EncodeRemQueryInterfaceRequest(
|
||||
objRef.Ipid,
|
||||
NmxProcedureMetadata.INmxService2,
|
||||
Guid.NewGuid());
|
||||
var response = client.CallBoundObject(resolved.RemUnknownIpid, RemUnknownMessages.RemQueryInterfaceOpnum, request);
|
||||
var parsed = RemUnknownMessages.ParseRemQueryInterfaceResponse(response.StubData.Span);
|
||||
if (parsed.Result is null || parsed.Result.HResult != 0 || parsed.ErrorCode != 0)
|
||||
{
|
||||
throw new InvalidOperationException($"RemQueryInterface failed: hresult=0x{parsed.Result?.HResult ?? -1:X8}, error=0x{parsed.ErrorCode:X8}.");
|
||||
}
|
||||
|
||||
return (host, port, parsed.Result.StandardObjectReference.Ipid);
|
||||
}
|
||||
|
||||
private static void BindWithAuthentication(
|
||||
DceRpcTcpClient client,
|
||||
Guid interfaceId,
|
||||
DceRpcClientAuthentication authentication,
|
||||
string targetName)
|
||||
{
|
||||
if (authentication == DceRpcClientAuthentication.ManagedNtlm)
|
||||
{
|
||||
client.BindWithManagedNtlmPacketIntegrity(interfaceId, versionMajor: 0, versionMinor: 0);
|
||||
return;
|
||||
}
|
||||
|
||||
client.BindWithNtlmPacketIntegrity(interfaceId, versionMajor: 0, versionMinor: 0, targetName);
|
||||
}
|
||||
|
||||
private static string ParseBracketedHost(string binding)
|
||||
{
|
||||
int open = binding.LastIndexOf('[');
|
||||
if (open <= 0)
|
||||
{
|
||||
throw new FormatException($"Binding does not contain a bracketed host: {binding}");
|
||||
}
|
||||
|
||||
return binding[..open];
|
||||
}
|
||||
|
||||
private static int ParseBracketedPort(string binding)
|
||||
{
|
||||
int open = binding.LastIndexOf('[');
|
||||
int close = binding.LastIndexOf(']');
|
||||
if (open < 0 || close <= open)
|
||||
{
|
||||
throw new FormatException($"Binding does not contain a bracketed port: {binding}");
|
||||
}
|
||||
|
||||
return int.Parse(binding.AsSpan(open + 1, close - open - 1), CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static void ThrowIfFailed(int hresult, string operation)
|
||||
{
|
||||
if (hresult < 0)
|
||||
{
|
||||
Marshal.ThrowExceptionForHR(hresult);
|
||||
}
|
||||
|
||||
if (hresult != 0)
|
||||
{
|
||||
throw new InvalidOperationException($"{operation} returned application status 0x{hresult:X8}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Numerics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace MxNativeClient;
|
||||
|
||||
public sealed class ManagedNtlmClientContext
|
||||
{
|
||||
private const uint NegotiateUnicode = 0x00000001;
|
||||
private const uint RequestTarget = 0x00000004;
|
||||
private const uint NegotiateSign = 0x00000010;
|
||||
private const uint NegotiateSeal = 0x00000020;
|
||||
private const uint NegotiateNtlm = 0x00000200;
|
||||
private const uint NegotiateAlwaysSign = 0x00008000;
|
||||
private const uint NegotiateExtendedSessionSecurity = 0x00080000;
|
||||
private const uint NegotiateTargetInfo = 0x00800000;
|
||||
private const uint NegotiateVersion = 0x02000000;
|
||||
private const uint Negotiate128 = 0x20000000;
|
||||
private const uint NegotiateKeyExchange = 0x40000000;
|
||||
private const uint Negotiate56 = 0x80000000;
|
||||
|
||||
private readonly string _user;
|
||||
private readonly string _password;
|
||||
private readonly string _domain;
|
||||
private readonly string _workstation;
|
||||
private uint _flags;
|
||||
private byte[] _exportedSessionKey = [];
|
||||
private byte[] _clientSigningKey = [];
|
||||
private Rc4? _clientSealingHandle;
|
||||
private uint _sequence;
|
||||
|
||||
public ManagedNtlmClientContext(string user, string password, string domain, string? workstation = null)
|
||||
{
|
||||
_user = user;
|
||||
_password = password;
|
||||
_domain = domain;
|
||||
_workstation = string.IsNullOrWhiteSpace(workstation) ? Environment.MachineName : workstation;
|
||||
}
|
||||
|
||||
public static ManagedNtlmClientContext FromEnvironment()
|
||||
{
|
||||
string user = Environment.GetEnvironmentVariable("MX_RPC_USER")
|
||||
?? throw new InvalidOperationException("MX_RPC_USER is required for managed NTLM.");
|
||||
string password = Environment.GetEnvironmentVariable("MX_RPC_PASSWORD")
|
||||
?? throw new InvalidOperationException("MX_RPC_PASSWORD is required for managed NTLM.");
|
||||
string domain = Environment.GetEnvironmentVariable("MX_RPC_DOMAIN") ?? string.Empty;
|
||||
return new ManagedNtlmClientContext(user, password, domain);
|
||||
}
|
||||
|
||||
public byte[] CreateType1()
|
||||
{
|
||||
_flags = NegotiateKeyExchange
|
||||
| NegotiateSign
|
||||
| NegotiateAlwaysSign
|
||||
| NegotiateSeal
|
||||
| NegotiateTargetInfo
|
||||
| NegotiateNtlm
|
||||
| NegotiateExtendedSessionSecurity
|
||||
| NegotiateUnicode
|
||||
| RequestTarget
|
||||
| Negotiate128
|
||||
| Negotiate56;
|
||||
|
||||
byte[] message = new byte[32];
|
||||
Encoding.ASCII.GetBytes("NTLMSSP\0").CopyTo(message, 0);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(message.AsSpan(8, 4), 1);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(message.AsSpan(12, 4), _flags);
|
||||
return message;
|
||||
}
|
||||
|
||||
public byte[] CreateType3(ReadOnlySpan<byte> type2)
|
||||
{
|
||||
var challenge = NtlmChallenge.Parse(type2);
|
||||
_flags &= challenge.Flags;
|
||||
|
||||
byte[] clientChallenge = RandomNumberGenerator.GetBytes(8);
|
||||
byte[] targetInfo = BuildTargetInfo(challenge.TargetInfo);
|
||||
byte[] responseKeyNt = HmacMd5(NtHash(_password), Encoding.Unicode.GetBytes(_user.ToUpperInvariant() + _domain));
|
||||
|
||||
byte[] temp = BuildNtlmV2Temp(clientChallenge, targetInfo);
|
||||
byte[] ntProof = HmacMd5(responseKeyNt, Combine(challenge.ServerChallenge, temp));
|
||||
byte[] ntResponse = Combine(ntProof, temp);
|
||||
byte[] lmResponse = Combine(HmacMd5(responseKeyNt, Combine(challenge.ServerChallenge, clientChallenge)), clientChallenge);
|
||||
byte[] sessionBaseKey = HmacMd5(responseKeyNt, ntProof);
|
||||
|
||||
_exportedSessionKey = RandomNumberGenerator.GetBytes(16);
|
||||
byte[] encryptedSessionKey = new Rc4(sessionBaseKey).Transform(_exportedSessionKey);
|
||||
_clientSigningKey = SignKey(_exportedSessionKey, clientMode: true);
|
||||
_clientSealingHandle = new Rc4(SealKey(_exportedSessionKey, clientMode: true));
|
||||
_sequence = 0;
|
||||
|
||||
byte[] domain = Encoding.Unicode.GetBytes(_domain);
|
||||
byte[] user = Encoding.Unicode.GetBytes(_user);
|
||||
byte[] workstation = Encoding.Unicode.GetBytes(_workstation);
|
||||
|
||||
const int headerLength = 64;
|
||||
int payloadLength = lmResponse.Length + ntResponse.Length + domain.Length + user.Length + workstation.Length + encryptedSessionKey.Length;
|
||||
byte[] message = new byte[headerLength + payloadLength];
|
||||
Encoding.ASCII.GetBytes("NTLMSSP\0").CopyTo(message, 0);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(message.AsSpan(8, 4), 3);
|
||||
|
||||
int offset = headerLength;
|
||||
WriteSecurityBuffer(message, 12, lmResponse, ref offset);
|
||||
WriteSecurityBuffer(message, 20, ntResponse, ref offset);
|
||||
WriteSecurityBuffer(message, 28, domain, ref offset);
|
||||
WriteSecurityBuffer(message, 36, user, ref offset);
|
||||
WriteSecurityBuffer(message, 44, workstation, ref offset);
|
||||
WriteSecurityBuffer(message, 52, encryptedSessionKey, ref offset);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(message.AsSpan(60, 4), _flags);
|
||||
return message;
|
||||
}
|
||||
|
||||
public byte[] Sign(ReadOnlySpan<byte> message)
|
||||
{
|
||||
if (_clientSealingHandle is null || _clientSigningKey.Length == 0)
|
||||
{
|
||||
throw new InvalidOperationException("NTLM context has not completed Type3 negotiation.");
|
||||
}
|
||||
|
||||
byte[] sequenceBytes = new byte[4];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(sequenceBytes, _sequence);
|
||||
byte[] digest = HmacMd5(_clientSigningKey, Combine(sequenceBytes, message.ToArray()));
|
||||
byte[] checksum = _clientSealingHandle.Transform(digest[..8]);
|
||||
|
||||
byte[] signature = new byte[16];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(signature.AsSpan(0, 4), 1);
|
||||
checksum.CopyTo(signature.AsSpan(4, 8));
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(signature.AsSpan(12, 4), _sequence);
|
||||
_sequence++;
|
||||
return signature;
|
||||
}
|
||||
|
||||
private static byte[] BuildNtlmV2Temp(byte[] clientChallenge, byte[] targetInfo)
|
||||
{
|
||||
byte[] temp = new byte[28 + targetInfo.Length];
|
||||
temp[0] = 1;
|
||||
temp[1] = 1;
|
||||
BinaryPrimitives.WriteInt64LittleEndian(temp.AsSpan(8, 8), DateTimeOffset.UtcNow.ToFileTime());
|
||||
clientChallenge.CopyTo(temp.AsSpan(16, 8));
|
||||
targetInfo.CopyTo(temp.AsSpan(28));
|
||||
return temp;
|
||||
}
|
||||
|
||||
private static byte[] BuildTargetInfo(ReadOnlySpan<byte> original)
|
||||
{
|
||||
var pairs = AvPair.ParseAll(original);
|
||||
byte[]? dnsHost = pairs.FirstOrDefault(static pair => pair.Id == 3)?.Value;
|
||||
if (dnsHost is not null)
|
||||
{
|
||||
byte[] prefix = Encoding.Unicode.GetBytes("cifs/");
|
||||
pairs.RemoveAll(static pair => pair.Id == 9);
|
||||
pairs.Add(new AvPair(9, Combine(prefix, dnsHost)));
|
||||
}
|
||||
|
||||
if (!pairs.Any(static pair => pair.Id == 7))
|
||||
{
|
||||
byte[] timestamp = new byte[8];
|
||||
BinaryPrimitives.WriteInt64LittleEndian(timestamp, DateTimeOffset.UtcNow.ToFileTime());
|
||||
pairs.Add(new AvPair(7, timestamp));
|
||||
}
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
Span<byte> header = stackalloc byte[4];
|
||||
foreach (var pair in pairs.Where(static pair => pair.Id != 0))
|
||||
{
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(header[..2], pair.Id);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(header[2..], (ushort)pair.Value.Length);
|
||||
stream.Write(header);
|
||||
stream.Write(pair.Value);
|
||||
}
|
||||
|
||||
stream.Write(new byte[4]);
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
private static byte[] SignKey(byte[] sessionKey, bool clientMode)
|
||||
{
|
||||
string magic = clientMode
|
||||
? "session key to client-to-server signing key magic constant\0"
|
||||
: "session key to server-to-client signing key magic constant\0";
|
||||
return MD5.HashData(Combine(sessionKey, Encoding.ASCII.GetBytes(magic)));
|
||||
}
|
||||
|
||||
private static byte[] SealKey(byte[] sessionKey, bool clientMode)
|
||||
{
|
||||
string magic = clientMode
|
||||
? "session key to client-to-server sealing key magic constant\0"
|
||||
: "session key to server-to-client sealing key magic constant\0";
|
||||
return MD5.HashData(Combine(sessionKey, Encoding.ASCII.GetBytes(magic)));
|
||||
}
|
||||
|
||||
private static byte[] NtHash(string password)
|
||||
{
|
||||
return Md4.Hash(Encoding.Unicode.GetBytes(password));
|
||||
}
|
||||
|
||||
private static byte[] HmacMd5(byte[] key, byte[] data)
|
||||
{
|
||||
using var hmac = new HMACMD5(key);
|
||||
return hmac.ComputeHash(data);
|
||||
}
|
||||
|
||||
private static byte[] Combine(params byte[][] parts)
|
||||
{
|
||||
byte[] combined = new byte[parts.Sum(static part => part.Length)];
|
||||
int offset = 0;
|
||||
foreach (byte[] part in parts)
|
||||
{
|
||||
part.CopyTo(combined.AsSpan(offset));
|
||||
offset += part.Length;
|
||||
}
|
||||
|
||||
return combined;
|
||||
}
|
||||
|
||||
private static void WriteSecurityBuffer(byte[] message, int descriptorOffset, byte[] value, ref int payloadOffset)
|
||||
{
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(message.AsSpan(descriptorOffset, 2), (ushort)value.Length);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(message.AsSpan(descriptorOffset + 2, 2), (ushort)value.Length);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(message.AsSpan(descriptorOffset + 4, 4), (uint)payloadOffset);
|
||||
value.CopyTo(message.AsSpan(payloadOffset));
|
||||
payloadOffset += value.Length;
|
||||
}
|
||||
|
||||
private sealed record NtlmChallenge(uint Flags, byte[] ServerChallenge, byte[] TargetInfo)
|
||||
{
|
||||
public static NtlmChallenge Parse(ReadOnlySpan<byte> message)
|
||||
{
|
||||
if (message.Length < 48 || !message[..8].SequenceEqual(Encoding.ASCII.GetBytes("NTLMSSP\0")))
|
||||
{
|
||||
throw new ArgumentException("NTLM challenge is truncated or invalid.", nameof(message));
|
||||
}
|
||||
|
||||
int targetInfoLength = BinaryPrimitives.ReadUInt16LittleEndian(message.Slice(40, 2));
|
||||
int targetInfoOffset = (int)BinaryPrimitives.ReadUInt32LittleEndian(message.Slice(44, 4));
|
||||
if (targetInfoOffset < 0 || targetInfoOffset + targetInfoLength > message.Length)
|
||||
{
|
||||
throw new ArgumentException("NTLM challenge target-info buffer is invalid.", nameof(message));
|
||||
}
|
||||
|
||||
return new NtlmChallenge(
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(message.Slice(20, 4)),
|
||||
message.Slice(24, 8).ToArray(),
|
||||
message.Slice(targetInfoOffset, targetInfoLength).ToArray());
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record AvPair(ushort Id, byte[] Value)
|
||||
{
|
||||
public static List<AvPair> ParseAll(ReadOnlySpan<byte> buffer)
|
||||
{
|
||||
var pairs = new List<AvPair>();
|
||||
int offset = 0;
|
||||
while (offset + 4 <= buffer.Length)
|
||||
{
|
||||
ushort id = BinaryPrimitives.ReadUInt16LittleEndian(buffer.Slice(offset, 2));
|
||||
ushort length = BinaryPrimitives.ReadUInt16LittleEndian(buffer.Slice(offset + 2, 2));
|
||||
offset += 4;
|
||||
if (id == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (offset + length > buffer.Length)
|
||||
{
|
||||
throw new ArgumentException("NTLM AV pair buffer is truncated.", nameof(buffer));
|
||||
}
|
||||
|
||||
pairs.Add(new AvPair(id, buffer.Slice(offset, length).ToArray()));
|
||||
offset += length;
|
||||
}
|
||||
|
||||
return pairs;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Rc4
|
||||
{
|
||||
private readonly byte[] _s = new byte[256];
|
||||
private int _i;
|
||||
private int _j;
|
||||
|
||||
public Rc4(byte[] key)
|
||||
{
|
||||
for (int i = 0; i < 256; i++)
|
||||
{
|
||||
_s[i] = (byte)i;
|
||||
}
|
||||
|
||||
int j = 0;
|
||||
for (int i = 0; i < 256; i++)
|
||||
{
|
||||
j = (j + _s[i] + key[i % key.Length]) & 0xff;
|
||||
(_s[i], _s[j]) = (_s[j], _s[i]);
|
||||
}
|
||||
}
|
||||
|
||||
public byte[] Transform(ReadOnlySpan<byte> input)
|
||||
{
|
||||
byte[] output = new byte[input.Length];
|
||||
for (int k = 0; k < input.Length; k++)
|
||||
{
|
||||
_i = (_i + 1) & 0xff;
|
||||
_j = (_j + _s[_i]) & 0xff;
|
||||
(_s[_i], _s[_j]) = (_s[_j], _s[_i]);
|
||||
byte keyByte = _s[(_s[_i] + _s[_j]) & 0xff];
|
||||
output[k] = (byte)(input[k] ^ keyByte);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
}
|
||||
|
||||
private static class Md4
|
||||
{
|
||||
public static byte[] Hash(byte[] input)
|
||||
{
|
||||
uint a = 0x67452301;
|
||||
uint b = 0xefcdab89;
|
||||
uint c = 0x98badcfe;
|
||||
uint d = 0x10325476;
|
||||
|
||||
byte[] padded = Pad(input);
|
||||
Span<uint> x = stackalloc uint[16];
|
||||
for (int offset = 0; offset < padded.Length; offset += 64)
|
||||
{
|
||||
uint aa = a;
|
||||
uint bb = b;
|
||||
uint cc = c;
|
||||
uint dd = d;
|
||||
for (int i = 0; i < 16; i++)
|
||||
{
|
||||
x[i] = BinaryPrimitives.ReadUInt32LittleEndian(padded.AsSpan(offset + i * 4, 4));
|
||||
}
|
||||
|
||||
Round1(ref a, b, c, d, x[0], 3); Round1(ref d, a, b, c, x[1], 7); Round1(ref c, d, a, b, x[2], 11); Round1(ref b, c, d, a, x[3], 19);
|
||||
Round1(ref a, b, c, d, x[4], 3); Round1(ref d, a, b, c, x[5], 7); Round1(ref c, d, a, b, x[6], 11); Round1(ref b, c, d, a, x[7], 19);
|
||||
Round1(ref a, b, c, d, x[8], 3); Round1(ref d, a, b, c, x[9], 7); Round1(ref c, d, a, b, x[10], 11); Round1(ref b, c, d, a, x[11], 19);
|
||||
Round1(ref a, b, c, d, x[12], 3); Round1(ref d, a, b, c, x[13], 7); Round1(ref c, d, a, b, x[14], 11); Round1(ref b, c, d, a, x[15], 19);
|
||||
|
||||
Round2(ref a, b, c, d, x[0], 3); Round2(ref d, a, b, c, x[4], 5); Round2(ref c, d, a, b, x[8], 9); Round2(ref b, c, d, a, x[12], 13);
|
||||
Round2(ref a, b, c, d, x[1], 3); Round2(ref d, a, b, c, x[5], 5); Round2(ref c, d, a, b, x[9], 9); Round2(ref b, c, d, a, x[13], 13);
|
||||
Round2(ref a, b, c, d, x[2], 3); Round2(ref d, a, b, c, x[6], 5); Round2(ref c, d, a, b, x[10], 9); Round2(ref b, c, d, a, x[14], 13);
|
||||
Round2(ref a, b, c, d, x[3], 3); Round2(ref d, a, b, c, x[7], 5); Round2(ref c, d, a, b, x[11], 9); Round2(ref b, c, d, a, x[15], 13);
|
||||
|
||||
Round3(ref a, b, c, d, x[0], 3); Round3(ref d, a, b, c, x[8], 9); Round3(ref c, d, a, b, x[4], 11); Round3(ref b, c, d, a, x[12], 15);
|
||||
Round3(ref a, b, c, d, x[2], 3); Round3(ref d, a, b, c, x[10], 9); Round3(ref c, d, a, b, x[6], 11); Round3(ref b, c, d, a, x[14], 15);
|
||||
Round3(ref a, b, c, d, x[1], 3); Round3(ref d, a, b, c, x[9], 9); Round3(ref c, d, a, b, x[5], 11); Round3(ref b, c, d, a, x[13], 15);
|
||||
Round3(ref a, b, c, d, x[3], 3); Round3(ref d, a, b, c, x[11], 9); Round3(ref c, d, a, b, x[7], 11); Round3(ref b, c, d, a, x[15], 15);
|
||||
|
||||
a += aa;
|
||||
b += bb;
|
||||
c += cc;
|
||||
d += dd;
|
||||
}
|
||||
|
||||
byte[] hash = new byte[16];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(hash.AsSpan(0, 4), a);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(hash.AsSpan(4, 4), b);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(hash.AsSpan(8, 4), c);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(hash.AsSpan(12, 4), d);
|
||||
return hash;
|
||||
}
|
||||
|
||||
private static byte[] Pad(byte[] input)
|
||||
{
|
||||
ulong bitLength = (ulong)input.Length * 8;
|
||||
int paddedLength = input.Length + 1;
|
||||
while (paddedLength % 64 != 56)
|
||||
{
|
||||
paddedLength++;
|
||||
}
|
||||
|
||||
byte[] padded = new byte[paddedLength + 8];
|
||||
input.CopyTo(padded.AsSpan());
|
||||
padded[input.Length] = 0x80;
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(padded.AsSpan(paddedLength, 8), bitLength);
|
||||
return padded;
|
||||
}
|
||||
|
||||
private static uint F(uint x, uint y, uint z) => (x & y) | (~x & z);
|
||||
private static uint G(uint x, uint y, uint z) => (x & y) | (x & z) | (y & z);
|
||||
private static uint H(uint x, uint y, uint z) => x ^ y ^ z;
|
||||
private static void Round1(ref uint a, uint b, uint c, uint d, uint x, int s) => a = BitOperations.RotateLeft(a + F(b, c, d) + x, s);
|
||||
private static void Round2(ref uint a, uint b, uint c, uint d, uint x, int s) => a = BitOperations.RotateLeft(a + G(b, c, d) + x + 0x5a827999, s);
|
||||
private static void Round3(ref uint a, uint b, uint c, uint d, uint x, int s) => a = BitOperations.RotateLeft(a + H(b, c, d) + x + 0x6ed9eba1, s);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
<PlatformTarget>x64</PlatformTarget>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MxNativeCodec\MxNativeCodec.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.1" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,910 @@
|
||||
using System.Globalization;
|
||||
using MxNativeCodec;
|
||||
|
||||
namespace MxNativeClient;
|
||||
|
||||
public sealed record MxNativeDataChangeEvent(
|
||||
int ServerHandle,
|
||||
int ItemHandle,
|
||||
object? Value,
|
||||
ushort Quality,
|
||||
DateTime TimestampUtc,
|
||||
IReadOnlyList<MxStatus> Statuses,
|
||||
bool IsDuringRecovery = false);
|
||||
|
||||
public sealed record MxNativeWriteCompleteEvent(
|
||||
int ServerHandle,
|
||||
int ItemHandle,
|
||||
IReadOnlyList<MxStatus> Statuses,
|
||||
bool IsDuringRecovery = false);
|
||||
|
||||
public sealed record MxNativeOperationCompleteEvent(
|
||||
int ServerHandle,
|
||||
int ItemHandle,
|
||||
IReadOnlyList<MxStatus> Statuses,
|
||||
bool IsDuringRecovery = false);
|
||||
|
||||
public sealed record MxNativeBufferedDataChangeEvent(
|
||||
int ServerHandle,
|
||||
int ItemHandle,
|
||||
short MxDataType,
|
||||
IReadOnlyList<object?> Values,
|
||||
IReadOnlyList<ushort> Qualities,
|
||||
IReadOnlyList<DateTime> TimestampsUtc,
|
||||
IReadOnlyList<MxStatus> Statuses,
|
||||
bool IsDuringRecovery = false);
|
||||
|
||||
public sealed record MxNativeCompatibilityRecoveryAttemptEvent(
|
||||
int ServerHandle,
|
||||
int Attempt,
|
||||
int MaxAttempts);
|
||||
|
||||
public sealed record MxNativeCompatibilityRecoveryFailureEvent(
|
||||
int ServerHandle,
|
||||
int Attempt,
|
||||
int MaxAttempts,
|
||||
Exception Exception,
|
||||
bool WillRetry);
|
||||
|
||||
public sealed record MxNativeCompatibilityRecoveryCompletedEvent(
|
||||
int ServerHandle,
|
||||
int Attempt,
|
||||
int MaxAttempts);
|
||||
|
||||
public sealed class MxNativeCompatibilityServer : IDisposable
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly Dictionary<int, MxNativeSession> _sessions = [];
|
||||
private readonly Dictionary<int, CompatibilityItem> _items = [];
|
||||
private readonly Dictionary<int, int> _bufferedUpdateIntervals = [];
|
||||
private readonly Dictionary<int, int> _nextUserHandles = [];
|
||||
private readonly Dictionary<int, Queue<int>> _pendingWriteItems = [];
|
||||
private int _nextServerHandle = 1;
|
||||
private int _nextItemHandle = 1;
|
||||
private bool _disposed;
|
||||
|
||||
public event EventHandler<MxNativeDataChangeEvent>? DataChanged;
|
||||
public event EventHandler<MxNativeBufferedDataChangeEvent>? BufferedDataChanged;
|
||||
public event EventHandler<MxNativeWriteCompleteEvent>? WriteCompleted;
|
||||
#pragma warning disable CS0067 // OperationComplete has the MXAccess event shape, but no firing path is modeled until captures define its trigger.
|
||||
public event EventHandler<MxNativeOperationCompleteEvent>? OperationCompleted;
|
||||
#pragma warning restore CS0067
|
||||
public event EventHandler<MxNativeCompatibilityRecoveryAttemptEvent>? RecoveryAttemptStarted;
|
||||
public event EventHandler<MxNativeCompatibilityRecoveryFailureEvent>? RecoveryAttemptFailed;
|
||||
public event EventHandler<MxNativeCompatibilityRecoveryCompletedEvent>? RecoveryCompleted;
|
||||
|
||||
public int Register(string clientName)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
var session = MxNativeSession.Open(new MxNativeClientOptions
|
||||
{
|
||||
EngineName = string.IsNullOrWhiteSpace(clientName) ? $"MxNativeClient.{Environment.ProcessId}" : clientName,
|
||||
});
|
||||
|
||||
int serverHandle;
|
||||
lock (_gate)
|
||||
{
|
||||
serverHandle = _nextServerHandle++;
|
||||
_sessions.Add(serverHandle, session);
|
||||
_nextUserHandles.Add(serverHandle, 1);
|
||||
_pendingWriteItems.Add(serverHandle, new Queue<int>());
|
||||
}
|
||||
|
||||
session.CallbackReceived += (_, evt) => OnCallbackReceived(serverHandle, evt);
|
||||
session.OperationStatusReceived += (_, evt) => OnOperationStatusReceived(serverHandle, evt);
|
||||
session.RecoveryAttemptStarted += (_, evt) => RecoveryAttemptStarted?.Invoke(
|
||||
this,
|
||||
new MxNativeCompatibilityRecoveryAttemptEvent(serverHandle, evt.Attempt, evt.MaxAttempts));
|
||||
session.RecoveryAttemptFailed += (_, evt) => RecoveryAttemptFailed?.Invoke(
|
||||
this,
|
||||
new MxNativeCompatibilityRecoveryFailureEvent(
|
||||
serverHandle,
|
||||
evt.Attempt,
|
||||
evt.MaxAttempts,
|
||||
evt.Exception,
|
||||
evt.WillRetry));
|
||||
session.RecoveryCompleted += (_, evt) => RecoveryCompleted?.Invoke(
|
||||
this,
|
||||
new MxNativeCompatibilityRecoveryCompletedEvent(serverHandle, evt.Attempt, evt.MaxAttempts));
|
||||
return serverHandle;
|
||||
}
|
||||
|
||||
public void Unregister(int serverHandle)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
MxNativeSession session;
|
||||
lock (_gate)
|
||||
{
|
||||
session = GetSessionLocked(serverHandle);
|
||||
foreach (int itemHandle in _items
|
||||
.Where(pair => pair.Value.ServerHandle == serverHandle)
|
||||
.Select(pair => pair.Key)
|
||||
.ToArray())
|
||||
{
|
||||
_items.Remove(itemHandle);
|
||||
}
|
||||
|
||||
_sessions.Remove(serverHandle);
|
||||
_bufferedUpdateIntervals.Remove(serverHandle);
|
||||
_nextUserHandles.Remove(serverHandle);
|
||||
_pendingWriteItems.Remove(serverHandle);
|
||||
}
|
||||
|
||||
session.Dispose();
|
||||
}
|
||||
|
||||
public void RecoverConnection(int serverHandle)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
MxNativeSession session;
|
||||
lock (_gate)
|
||||
{
|
||||
session = GetSessionLocked(serverHandle);
|
||||
}
|
||||
|
||||
session.RecoverConnection();
|
||||
}
|
||||
|
||||
public async Task RecoverConnectionAsync(
|
||||
int serverHandle,
|
||||
MxNativeRecoveryPolicy? policy = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
MxNativeSession session;
|
||||
lock (_gate)
|
||||
{
|
||||
session = GetSessionLocked(serverHandle);
|
||||
}
|
||||
|
||||
await session.RecoverConnectionAsync(policy, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public int AddItem(int serverHandle, string itemDefinition)
|
||||
{
|
||||
return AddItemAsync(serverHandle, itemDefinition).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
public async Task<int> AddItemAsync(
|
||||
int serverHandle,
|
||||
string itemDefinition,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await AddItemCoreAsync(serverHandle, itemDefinition, itemContext: null, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<int> AddItemCoreAsync(
|
||||
int serverHandle,
|
||||
string itemDefinition,
|
||||
string? itemContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
MxNativeSession session;
|
||||
lock (_gate)
|
||||
{
|
||||
session = GetSessionLocked(serverHandle);
|
||||
}
|
||||
|
||||
GalaxyTagMetadata? metadata;
|
||||
string resolvedReference;
|
||||
try
|
||||
{
|
||||
(metadata, resolvedReference) = await ResolveWithOptionalContextAsync(
|
||||
session,
|
||||
itemDefinition,
|
||||
itemContext,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (InvalidOperationException ex) when (IsMissingReferenceResolution(ex))
|
||||
{
|
||||
metadata = null;
|
||||
resolvedReference = string.IsNullOrWhiteSpace(itemContext)
|
||||
? itemDefinition
|
||||
: CombineItemContext(itemDefinition, itemContext);
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
int itemHandle = _nextItemHandle++;
|
||||
_items.Add(
|
||||
itemHandle,
|
||||
metadata is null
|
||||
? new CompatibilityItem(
|
||||
serverHandle,
|
||||
resolvedReference,
|
||||
metadata: null,
|
||||
isInvalidReference: true)
|
||||
: new CompatibilityItem(serverHandle, resolvedReference, metadata));
|
||||
return itemHandle;
|
||||
}
|
||||
}
|
||||
|
||||
public int AddItem2(int serverHandle, string itemDefinition, string itemContext)
|
||||
{
|
||||
return AddItemCoreAsync(serverHandle, itemDefinition, itemContext, CancellationToken.None).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
public void RemoveItem(int serverHandle, int itemHandle)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
MxNativeSession session;
|
||||
CompatibilityItem item;
|
||||
lock (_gate)
|
||||
{
|
||||
session = GetSessionLocked(serverHandle);
|
||||
item = GetItemLocked(serverHandle, itemHandle);
|
||||
_items.Remove(itemHandle);
|
||||
}
|
||||
|
||||
if (item.Subscription is not null)
|
||||
{
|
||||
session.Unsubscribe(item.Subscription.CorrelationId);
|
||||
}
|
||||
}
|
||||
|
||||
public void Advise(int serverHandle, int itemHandle)
|
||||
{
|
||||
AdviseAsync(serverHandle, itemHandle).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
public Task AdviseSupervisoryAsync(int serverHandle, int itemHandle, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return AdviseAsync(serverHandle, itemHandle, cancellationToken);
|
||||
}
|
||||
|
||||
public void AdviseSupervisory(int serverHandle, int itemHandle)
|
||||
{
|
||||
Advise(serverHandle, itemHandle);
|
||||
}
|
||||
|
||||
public async Task AdviseAsync(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
MxNativeSession session;
|
||||
CompatibilityItem item;
|
||||
bool fireInvalidReference = false;
|
||||
lock (_gate)
|
||||
{
|
||||
session = GetSessionLocked(serverHandle);
|
||||
item = GetItemLocked(serverHandle, itemHandle);
|
||||
if (item.Subscription is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.IsInvalidReference)
|
||||
{
|
||||
item.IsAdvisedInvalidReference = true;
|
||||
fireInvalidReference = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (fireInvalidReference)
|
||||
{
|
||||
ThreadPool.QueueUserWorkItem(_ => FireInvalidReferenceDataChange(serverHandle, itemHandle));
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.Metadata is null)
|
||||
{
|
||||
throw new InvalidOperationException("Valid compatibility items must carry Galaxy metadata.");
|
||||
}
|
||||
|
||||
MxNativeSubscription subscription = item.IsBuffered
|
||||
? await session.RegisterBufferedItemAsync(
|
||||
item.ItemDefinition,
|
||||
item.ItemContext,
|
||||
itemHandle,
|
||||
cancellationToken).ConfigureAwait(false)
|
||||
: await session.SubscribeAsync(item.TagReference, cancellationToken).ConfigureAwait(false);
|
||||
lock (_gate)
|
||||
{
|
||||
if (_items.TryGetValue(itemHandle, out CompatibilityItem? current))
|
||||
{
|
||||
current.Subscription = subscription;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void UnAdvise(int serverHandle, int itemHandle)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
MxNativeSession session;
|
||||
CompatibilityItem item;
|
||||
lock (_gate)
|
||||
{
|
||||
session = GetSessionLocked(serverHandle);
|
||||
item = GetItemLocked(serverHandle, itemHandle);
|
||||
if (item.IsInvalidReference)
|
||||
{
|
||||
item.IsAdvisedInvalidReference = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.Subscription is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
session.Unsubscribe(item.Subscription.CorrelationId);
|
||||
lock (_gate)
|
||||
{
|
||||
if (_items.TryGetValue(itemHandle, out CompatibilityItem? current))
|
||||
{
|
||||
current.Subscription = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Write(int serverHandle, int itemHandle, object value, int userId = 0)
|
||||
{
|
||||
WriteAsync(serverHandle, itemHandle, value).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
public async Task WriteAsync(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
object value,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
MxNativeSession session;
|
||||
CompatibilityItem item;
|
||||
lock (_gate)
|
||||
{
|
||||
session = GetSessionLocked(serverHandle);
|
||||
item = GetItemLocked(serverHandle, itemHandle);
|
||||
if (item.IsInvalidReference)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.IsBuffered)
|
||||
{
|
||||
throw new ArgumentException("Normal Write is not valid for buffered item handles.", nameof(itemHandle));
|
||||
}
|
||||
|
||||
if (item.Subscription is null)
|
||||
{
|
||||
throw new ArgumentException("Write requires an advised item handle.", nameof(itemHandle));
|
||||
}
|
||||
|
||||
if (item.Metadata?.IsBufferProperty == true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
GetPendingWriteItemsLocked(serverHandle).Enqueue(itemHandle);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await session.WriteAsync(item.TagReference, value, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
RemovePendingWriteItemLocked(serverHandle, itemHandle);
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public void Write2(int serverHandle, int itemHandle, object value, DateTime timestamp, int userId = 0)
|
||||
{
|
||||
Write2Async(serverHandle, itemHandle, value, timestamp).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
public void Write2(int serverHandle, int itemHandle, object value, object timestamp, int userId = 0)
|
||||
{
|
||||
Write2(serverHandle, itemHandle, value, CoerceWriteTimestamp(timestamp), userId);
|
||||
}
|
||||
|
||||
public async Task Write2Async(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
object value,
|
||||
DateTime timestamp,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
MxNativeSession session;
|
||||
CompatibilityItem item;
|
||||
lock (_gate)
|
||||
{
|
||||
session = GetSessionLocked(serverHandle);
|
||||
item = GetItemLocked(serverHandle, itemHandle);
|
||||
if (item.IsInvalidReference)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.IsBuffered)
|
||||
{
|
||||
throw new ArgumentException("Normal Write2 is not valid for buffered item handles.", nameof(itemHandle));
|
||||
}
|
||||
|
||||
if (item.Subscription is null)
|
||||
{
|
||||
throw new ArgumentException("Write2 requires an advised item handle.", nameof(itemHandle));
|
||||
}
|
||||
|
||||
if (item.Metadata?.IsBufferProperty == true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
GetPendingWriteItemsLocked(serverHandle).Enqueue(itemHandle);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await session.Write2Async(item.TagReference, value, timestamp, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
RemovePendingWriteItemLocked(serverHandle, itemHandle);
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public Task Write2Async(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
object value,
|
||||
object timestamp,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Write2Async(serverHandle, itemHandle, value, CoerceWriteTimestamp(timestamp), cancellationToken);
|
||||
}
|
||||
|
||||
public void WriteSecured(int serverHandle, int itemHandle, int currentUserId, int verifierUserId, object value)
|
||||
{
|
||||
throw new NotSupportedException("Dedicated WriteSecured parity is not implemented because current MXAccess captures do not emit a successful NMX secured-write body.");
|
||||
}
|
||||
|
||||
public void WriteSecured2(int serverHandle, int itemHandle, int currentUserId, int verifierUserId, object value, DateTime timestamp)
|
||||
{
|
||||
WriteSecured2Async(serverHandle, itemHandle, value, timestamp, currentUserId, verifierUserId).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
public void WriteSecured2(int serverHandle, int itemHandle, int currentUserId, int verifierUserId, object value, object timestamp)
|
||||
{
|
||||
WriteSecured2(serverHandle, itemHandle, currentUserId, verifierUserId, value, CoerceWriteTimestamp(timestamp));
|
||||
}
|
||||
|
||||
public async Task WriteSecured2Async(int serverHandle, int itemHandle, object value, DateTime timestamp, int currentUserId, int verifierUserId = 0)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
MxNativeSession session;
|
||||
CompatibilityItem item;
|
||||
lock (_gate)
|
||||
{
|
||||
session = GetSessionLocked(serverHandle);
|
||||
item = GetItemLocked(serverHandle, itemHandle);
|
||||
}
|
||||
|
||||
await session.WriteSecured2Async(
|
||||
item.TagReference,
|
||||
value,
|
||||
timestamp,
|
||||
currentUserId,
|
||||
verifierUserId).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public Task WriteSecured2Async(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
object value,
|
||||
object timestamp,
|
||||
int currentUserId,
|
||||
int verifierUserId = 0)
|
||||
{
|
||||
return WriteSecured2Async(
|
||||
serverHandle,
|
||||
itemHandle,
|
||||
value,
|
||||
CoerceWriteTimestamp(timestamp),
|
||||
currentUserId,
|
||||
verifierUserId);
|
||||
}
|
||||
|
||||
public int AuthenticateUser(int serverHandle, string verifyUser, string verifyUserPassword)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
ArgumentNullException.ThrowIfNull(verifyUser);
|
||||
_ = verifyUserPassword;
|
||||
lock (_gate)
|
||||
{
|
||||
_ = GetSessionLocked(serverHandle);
|
||||
return AllocateUserHandleLocked(serverHandle);
|
||||
}
|
||||
}
|
||||
|
||||
public int ArchestrAUserToId(int serverHandle, string userIdGuid)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
lock (_gate)
|
||||
{
|
||||
_ = GetSessionLocked(serverHandle);
|
||||
}
|
||||
|
||||
if (!Guid.TryParse(userIdGuid, out Guid parsedGuid))
|
||||
{
|
||||
throw new ArgumentException("ArchestrA user ID must be a GUID string.", nameof(userIdGuid));
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
return AllocateUserHandleLocked(serverHandle);
|
||||
}
|
||||
}
|
||||
|
||||
public void Suspend(int serverHandle, int itemHandle, out MxStatus status)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
lock (_gate)
|
||||
{
|
||||
_ = GetSessionLocked(serverHandle);
|
||||
CompatibilityItem item = GetItemLocked(serverHandle, itemHandle);
|
||||
if (item.Subscription is null)
|
||||
{
|
||||
status = new MxStatus(0, MxStatusCategory.Unknown, MxStatusSource.Unknown, 0);
|
||||
throw new ArgumentException("Suspend requires an advised item handle.", nameof(itemHandle));
|
||||
}
|
||||
}
|
||||
|
||||
status = MxStatus.SuspendPending;
|
||||
}
|
||||
|
||||
public void Activate(int serverHandle, int itemHandle, out MxStatus status)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
lock (_gate)
|
||||
{
|
||||
_ = GetSessionLocked(serverHandle);
|
||||
CompatibilityItem item = GetItemLocked(serverHandle, itemHandle);
|
||||
if (item.Subscription is null)
|
||||
{
|
||||
status = new MxStatus(0, MxStatusCategory.Unknown, MxStatusSource.Unknown, 0);
|
||||
throw new ArgumentException("Activate requires an advised item handle.", nameof(itemHandle));
|
||||
}
|
||||
}
|
||||
|
||||
status = MxStatus.ActivateOk;
|
||||
}
|
||||
|
||||
public int AddBufferedItem(int serverHandle, string itemDefinition, string itemContext)
|
||||
{
|
||||
return AddBufferedItemAsync(serverHandle, itemDefinition, itemContext).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
public async Task<int> AddBufferedItemAsync(
|
||||
int serverHandle,
|
||||
string itemDefinition,
|
||||
string itemContext,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
MxNativeSession session;
|
||||
lock (_gate)
|
||||
{
|
||||
session = GetSessionLocked(serverHandle);
|
||||
}
|
||||
|
||||
(GalaxyTagMetadata metadata, string routeReference) = await ResolveWithOptionalContextAsync(
|
||||
session,
|
||||
itemDefinition,
|
||||
itemContext,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
lock (_gate)
|
||||
{
|
||||
int itemHandle = _nextItemHandle++;
|
||||
_items.Add(
|
||||
itemHandle,
|
||||
new CompatibilityItem(
|
||||
serverHandle,
|
||||
routeReference,
|
||||
metadata,
|
||||
isBuffered: true,
|
||||
itemDefinition: itemDefinition,
|
||||
itemContext: itemContext));
|
||||
return itemHandle;
|
||||
}
|
||||
}
|
||||
|
||||
public void SetBufferedUpdateInterval(int serverHandle, int updateInterval)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (updateInterval <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(updateInterval), updateInterval, "Buffered update interval must be positive.");
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
_ = GetSessionLocked(serverHandle);
|
||||
_bufferedUpdateIntervals[serverHandle] = ((updateInterval + 99) / 100) * 100;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
MxNativeSession[] sessions;
|
||||
lock (_gate)
|
||||
{
|
||||
sessions = _sessions.Values.ToArray();
|
||||
_sessions.Clear();
|
||||
_items.Clear();
|
||||
_bufferedUpdateIntervals.Clear();
|
||||
_nextUserHandles.Clear();
|
||||
_pendingWriteItems.Clear();
|
||||
}
|
||||
|
||||
foreach (MxNativeSession session in sessions)
|
||||
{
|
||||
session.Dispose();
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
private void OnCallbackReceived(int serverHandle, MxNativeCallbackEvent evt)
|
||||
{
|
||||
int itemHandle;
|
||||
CompatibilityItem? item;
|
||||
lock (_gate)
|
||||
{
|
||||
var matched = _items.FirstOrDefault(pair =>
|
||||
pair.Value.ServerHandle == serverHandle &&
|
||||
pair.Value.Subscription?.CorrelationId == evt.Message.ItemCorrelationId);
|
||||
itemHandle = matched.Key;
|
||||
item = matched.Value;
|
||||
}
|
||||
|
||||
if (itemHandle == 0 || item is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.Record.Value is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.IsBuffered)
|
||||
{
|
||||
BufferedDataChanged?.Invoke(
|
||||
this,
|
||||
new MxNativeBufferedDataChangeEvent(
|
||||
serverHandle,
|
||||
itemHandle,
|
||||
item.Metadata?.MxDataType ?? 0,
|
||||
[evt.Record.Value],
|
||||
[evt.Record.Quality],
|
||||
[evt.Record.TimestampUtc],
|
||||
[evt.Status],
|
||||
evt.IsDuringRecovery));
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.Metadata is not null && ShouldSuppressCompatibilityDataChange(item.Metadata, evt.Record.Value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
DataChanged?.Invoke(
|
||||
this,
|
||||
new MxNativeDataChangeEvent(
|
||||
serverHandle,
|
||||
itemHandle,
|
||||
evt.Record.Value,
|
||||
evt.Record.Quality,
|
||||
evt.Record.TimestampUtc,
|
||||
[evt.Status],
|
||||
evt.IsDuringRecovery));
|
||||
}
|
||||
|
||||
internal static bool ShouldSuppressCompatibilityDataChange(GalaxyTagMetadata metadata, object value)
|
||||
{
|
||||
return metadata.MxDataType == (short)MxDataType.InternationalizedString
|
||||
&& value is string text
|
||||
&& text.Length == 0;
|
||||
}
|
||||
|
||||
private void FireInvalidReferenceDataChange(int serverHandle, int itemHandle)
|
||||
{
|
||||
DataChanged?.Invoke(
|
||||
this,
|
||||
new MxNativeDataChangeEvent(
|
||||
serverHandle,
|
||||
itemHandle,
|
||||
Value: null,
|
||||
Quality: 0,
|
||||
TimestampUtc: DateTime.UtcNow,
|
||||
[MxStatus.InvalidReferenceConfiguration]));
|
||||
}
|
||||
|
||||
private void OnOperationStatusReceived(int serverHandle, MxNativeOperationStatusEvent evt)
|
||||
{
|
||||
int itemHandle;
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_pendingWriteItems.TryGetValue(serverHandle, out Queue<int>? pendingItems)
|
||||
|| !pendingItems.TryDequeue(out itemHandle))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!evt.Message.IsMxAccessWriteComplete)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
WriteCompleted?.Invoke(
|
||||
this,
|
||||
new MxNativeWriteCompleteEvent(serverHandle, itemHandle, [evt.Message.Status], evt.IsDuringRecovery));
|
||||
}
|
||||
|
||||
private MxNativeSession GetSessionLocked(int serverHandle)
|
||||
{
|
||||
if (!_sessions.TryGetValue(serverHandle, out MxNativeSession? session))
|
||||
{
|
||||
throw new ArgumentException("Unknown MX native server handle.", nameof(serverHandle));
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
private CompatibilityItem GetItemLocked(int serverHandle, int itemHandle)
|
||||
{
|
||||
if (!_items.TryGetValue(itemHandle, out CompatibilityItem? item) || item.ServerHandle != serverHandle)
|
||||
{
|
||||
throw new ArgumentException("Unknown MX native item handle.", nameof(itemHandle));
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
private int AllocateUserHandleLocked(int serverHandle)
|
||||
{
|
||||
int next = _nextUserHandles.TryGetValue(serverHandle, out int value) ? value : 1;
|
||||
_nextUserHandles[serverHandle] = next + 1;
|
||||
return next;
|
||||
}
|
||||
|
||||
private Queue<int> GetPendingWriteItemsLocked(int serverHandle)
|
||||
{
|
||||
if (!_pendingWriteItems.TryGetValue(serverHandle, out Queue<int>? pendingItems))
|
||||
{
|
||||
pendingItems = new Queue<int>();
|
||||
_pendingWriteItems.Add(serverHandle, pendingItems);
|
||||
}
|
||||
|
||||
return pendingItems;
|
||||
}
|
||||
|
||||
private static bool IsMissingReferenceResolution(InvalidOperationException ex)
|
||||
{
|
||||
return ex.Message.Contains("was not found in the deployed repository metadata", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string CombineItemContext(string itemDefinition, string itemContext)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(itemContext))
|
||||
{
|
||||
return itemDefinition;
|
||||
}
|
||||
|
||||
string trimmedContext = itemContext.TrimEnd('.');
|
||||
string trimmedDefinition = itemDefinition.TrimStart('.');
|
||||
return trimmedDefinition.StartsWith(trimmedContext + ".", StringComparison.OrdinalIgnoreCase)
|
||||
? trimmedDefinition
|
||||
: $"{trimmedContext}.{trimmedDefinition}";
|
||||
}
|
||||
|
||||
private static async Task<(GalaxyTagMetadata Metadata, string ResolvedReference)> ResolveWithOptionalContextAsync(
|
||||
MxNativeSession session,
|
||||
string itemDefinition,
|
||||
string? itemContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
GalaxyTagMetadata metadata = await session.ResolveTagAsync(itemDefinition, cancellationToken).ConfigureAwait(false);
|
||||
return (metadata, itemDefinition);
|
||||
}
|
||||
catch (Exception ex) when (!string.IsNullOrWhiteSpace(itemContext) && CanRetryWithItemContext(ex))
|
||||
{
|
||||
string combinedReference = CombineItemContext(itemDefinition, itemContext);
|
||||
GalaxyTagMetadata metadata = await session.ResolveTagAsync(combinedReference, cancellationToken).ConfigureAwait(false);
|
||||
return (metadata, combinedReference);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool CanRetryWithItemContext(Exception ex)
|
||||
{
|
||||
return ex is ArgumentException
|
||||
|| (ex is InvalidOperationException invalidOperation && IsMissingReferenceResolution(invalidOperation));
|
||||
}
|
||||
|
||||
private static DateTime CoerceWriteTimestamp(object timestamp)
|
||||
{
|
||||
return timestamp switch
|
||||
{
|
||||
DateTime dateTime => dateTime,
|
||||
DateTimeOffset dateTimeOffset => dateTimeOffset.UtcDateTime,
|
||||
double oaDate => DateTime.FromOADate(oaDate),
|
||||
float oaDate => DateTime.FromOADate(oaDate),
|
||||
decimal oaDate => DateTime.FromOADate((double)oaDate),
|
||||
string text when DateTime.TryParse(
|
||||
text,
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeLocal,
|
||||
out DateTime parsed) => parsed,
|
||||
_ => throw new ArgumentException(
|
||||
"Timestamp must be a DateTime, DateTimeOffset, OLE Automation date, or invariant parseable date/time string.",
|
||||
nameof(timestamp)),
|
||||
};
|
||||
}
|
||||
|
||||
private void RemovePendingWriteItemLocked(int serverHandle, int itemHandle)
|
||||
{
|
||||
if (!_pendingWriteItems.TryGetValue(serverHandle, out Queue<int>? pendingItems)
|
||||
|| pendingItems.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
int pendingCount = pendingItems.Count;
|
||||
bool removed = false;
|
||||
for (int i = 0; i < pendingCount; i++)
|
||||
{
|
||||
int pendingItem = pendingItems.Dequeue();
|
||||
if (!removed && pendingItem == itemHandle)
|
||||
{
|
||||
removed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
pendingItems.Enqueue(pendingItem);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CompatibilityItem(
|
||||
int serverHandle,
|
||||
string tagReference,
|
||||
GalaxyTagMetadata? metadata,
|
||||
bool isBuffered = false,
|
||||
bool isInvalidReference = false,
|
||||
string itemDefinition = "",
|
||||
string itemContext = "")
|
||||
{
|
||||
public int ServerHandle { get; } = serverHandle;
|
||||
public string TagReference { get; } = tagReference;
|
||||
public GalaxyTagMetadata? Metadata { get; } = metadata;
|
||||
public bool IsBuffered { get; } = isBuffered;
|
||||
public bool IsInvalidReference { get; } = isInvalidReference;
|
||||
public string ItemDefinition { get; } = itemDefinition;
|
||||
public string ItemContext { get; } = itemContext;
|
||||
public MxNativeSubscription? Subscription { get; set; }
|
||||
public bool IsAdvisedInvalidReference { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,669 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using MxNativeCodec;
|
||||
|
||||
namespace MxNativeClient;
|
||||
|
||||
public sealed record MxNativeClientOptions
|
||||
{
|
||||
public int LocalEngineId { get; init; } = GenerateDefaultLocalEngineId();
|
||||
public string EngineName { get; init; } = $"MxNativeClient.{Environment.ProcessId}";
|
||||
public int PartnerVersion { get; init; } = 6;
|
||||
public int GalaxyId { get; init; } = 1;
|
||||
public int SourcePlatformId { get; init; } = 1;
|
||||
public DceRpcClientAuthentication Authentication { get; init; } = DceRpcClientAuthentication.ManagedNtlm;
|
||||
public int? HeartbeatTicksPerBeat { get; init; }
|
||||
public int HeartbeatMaxMissedTicks { get; init; } = 3;
|
||||
|
||||
private static int GenerateDefaultLocalEngineId()
|
||||
{
|
||||
return 0x7000 + (Environment.ProcessId & 0x0fff);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record MxNativeRecoveryPolicy
|
||||
{
|
||||
public static MxNativeRecoveryPolicy SingleAttempt { get; } = new();
|
||||
|
||||
public int MaxAttempts { get; init; } = 1;
|
||||
public TimeSpan Delay { get; init; } = TimeSpan.Zero;
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (MaxAttempts < 1)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(MaxAttempts), "Recovery attempts must be at least one.");
|
||||
}
|
||||
|
||||
if (Delay < TimeSpan.Zero)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(Delay), "Recovery delay cannot be negative.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record MxNativeRecoveryAttemptEvent(int Attempt, int MaxAttempts);
|
||||
|
||||
public sealed record MxNativeRecoveryFailureEvent(
|
||||
int Attempt,
|
||||
int MaxAttempts,
|
||||
Exception Exception,
|
||||
bool WillRetry);
|
||||
|
||||
public sealed record MxNativeRecoveryCompletedEvent(int Attempt, int MaxAttempts);
|
||||
|
||||
public sealed record MxNativeSubscription(
|
||||
Guid CorrelationId,
|
||||
string TagReference,
|
||||
GalaxyTagMetadata Metadata,
|
||||
bool IsBuffered = false,
|
||||
string BufferedItemDefinition = "",
|
||||
string BufferedItemContext = "",
|
||||
int BufferedItemHandle = 0);
|
||||
|
||||
public sealed record MxNativeCallbackEvent(
|
||||
NmxSubscriptionMessage Message,
|
||||
NmxSubscriptionRecord Record,
|
||||
byte[] RawBody,
|
||||
bool IsDuringRecovery = false)
|
||||
{
|
||||
public MxStatus Status => Record.ToDataChangeStatus();
|
||||
}
|
||||
|
||||
public sealed record MxNativeOperationStatusEvent(
|
||||
NmxOperationStatusMessage Message,
|
||||
byte[] RawBody,
|
||||
bool IsDuringRecovery = false);
|
||||
|
||||
public sealed record MxNativeReferenceRegistrationEvent(
|
||||
NmxReferenceRegistrationResultMessage Message,
|
||||
byte[] RawBody,
|
||||
bool IsDuringRecovery = false);
|
||||
|
||||
public sealed record MxNativeUnparsedCallbackEvent(
|
||||
byte[] RawBody,
|
||||
string ParseError,
|
||||
bool IsDuringRecovery = false);
|
||||
|
||||
public sealed class MxNativeSession : IDisposable
|
||||
{
|
||||
private readonly MxNativeClientOptions _options;
|
||||
private readonly GalaxyRepositoryTagResolver _resolver;
|
||||
private readonly NmxCallbackSink _callback;
|
||||
private readonly GCHandle _callbackHandle;
|
||||
private readonly Dictionary<Guid, MxNativeSubscription> _subscriptions = [];
|
||||
private readonly HashSet<PublisherEndpoint> _publisherEndpoints = [];
|
||||
private ManagedNmxService2Client _service;
|
||||
private int _recoveryActive;
|
||||
private bool _disposed;
|
||||
|
||||
private MxNativeSession(
|
||||
MxNativeClientOptions options,
|
||||
ManagedNmxService2Client service,
|
||||
GalaxyRepositoryTagResolver resolver,
|
||||
NmxCallbackSink callback,
|
||||
GCHandle callbackHandle)
|
||||
{
|
||||
_options = options;
|
||||
_service = service;
|
||||
_resolver = resolver;
|
||||
_callback = callback;
|
||||
_callbackHandle = callbackHandle;
|
||||
|
||||
_callback.DataReceived += OnCallbackReceived;
|
||||
_callback.StatusReceived += OnCallbackReceived;
|
||||
}
|
||||
|
||||
public event EventHandler<MxNativeCallbackEvent>? CallbackReceived;
|
||||
public event EventHandler<MxNativeOperationStatusEvent>? OperationStatusReceived;
|
||||
public event EventHandler<MxNativeReferenceRegistrationEvent>? ReferenceRegistrationReceived;
|
||||
public event EventHandler<MxNativeUnparsedCallbackEvent>? UnparsedCallbackReceived;
|
||||
public event EventHandler<MxNativeRecoveryAttemptEvent>? RecoveryAttemptStarted;
|
||||
public event EventHandler<MxNativeRecoveryFailureEvent>? RecoveryAttemptFailed;
|
||||
public event EventHandler<MxNativeRecoveryCompletedEvent>? RecoveryCompleted;
|
||||
|
||||
public IReadOnlyCollection<MxNativeSubscription> Subscriptions => _subscriptions.Values;
|
||||
|
||||
public static MxNativeSession Open(MxNativeClientOptions? options = null)
|
||||
{
|
||||
options ??= new MxNativeClientOptions();
|
||||
var callback = new NmxCallbackSink();
|
||||
var callbackHandle = GCHandle.Alloc(callback);
|
||||
|
||||
try
|
||||
{
|
||||
return new MxNativeSession(
|
||||
options,
|
||||
CreateRegisteredService(options, callback),
|
||||
new GalaxyRepositoryTagResolver(),
|
||||
callback,
|
||||
callbackHandle);
|
||||
}
|
||||
catch
|
||||
{
|
||||
callbackHandle.Free();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public Task<GalaxyTagMetadata> ResolveTagAsync(string tagReference, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
return _resolver.ResolveAsync(tagReference, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<GalaxyTagMetadata>> BrowseAsync(
|
||||
string objectTagLike = "%",
|
||||
string attributeLike = "%",
|
||||
int maxRows = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
return _resolver.BrowseAsync(objectTagLike, attributeLike, maxRows, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task WriteAsync(
|
||||
string tagReference,
|
||||
object value,
|
||||
int writeIndex = 1,
|
||||
uint clientToken = 0,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
GalaxyTagMetadata tag = await ResolveTagAsync(tagReference, cancellationToken).ConfigureAwait(false);
|
||||
EnsureSucceeded(
|
||||
_service.Write(
|
||||
_options.LocalEngineId,
|
||||
tag,
|
||||
value,
|
||||
writeIndex,
|
||||
clientToken,
|
||||
_options.GalaxyId,
|
||||
_options.GalaxyId,
|
||||
_options.SourcePlatformId),
|
||||
nameof(ManagedNmxService2Client.Write));
|
||||
}
|
||||
|
||||
public async Task Write2Async(
|
||||
string tagReference,
|
||||
object value,
|
||||
DateTime timestamp,
|
||||
int writeIndex = 1,
|
||||
uint clientToken = 0,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
GalaxyTagMetadata tag = await ResolveTagAsync(tagReference, cancellationToken).ConfigureAwait(false);
|
||||
EnsureSucceeded(
|
||||
_service.Write2(
|
||||
_options.LocalEngineId,
|
||||
tag,
|
||||
value,
|
||||
timestamp,
|
||||
writeIndex,
|
||||
clientToken,
|
||||
_options.GalaxyId,
|
||||
_options.GalaxyId,
|
||||
_options.SourcePlatformId),
|
||||
nameof(ManagedNmxService2Client.Write2));
|
||||
}
|
||||
|
||||
public Task WriteSecuredAsync(
|
||||
string tagReference,
|
||||
object value,
|
||||
int currentUserId,
|
||||
int verifierUserId = 0,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
throw new NotSupportedException(
|
||||
"Dedicated MXAccess WriteSecured parity is not implemented. Captures 036, 038, and 039 show the installed MXAccess WriteSecured method returning 0x80004021 before emitting an NMX write body; normal Write to secured-classified public tags is supported through WriteAsync.");
|
||||
}
|
||||
|
||||
public async Task WriteSecured2Async(
|
||||
string tagReference,
|
||||
object value,
|
||||
DateTime timestamp,
|
||||
int currentUserId,
|
||||
int verifierUserId = 0,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
GalaxyTagMetadata tag = await ResolveTagAsync(tagReference, cancellationToken).ConfigureAwait(false);
|
||||
EnsureSucceeded(
|
||||
_service.WriteSecured2(
|
||||
_options.LocalEngineId,
|
||||
tag,
|
||||
value,
|
||||
timestamp,
|
||||
_options.EngineName,
|
||||
currentUserId,
|
||||
verifierUserId,
|
||||
writeIndex: 1,
|
||||
clientToken: 0,
|
||||
_options.GalaxyId,
|
||||
_options.GalaxyId,
|
||||
_options.SourcePlatformId),
|
||||
nameof(ManagedNmxService2Client.WriteSecured2));
|
||||
}
|
||||
|
||||
public async Task<MxNativeSubscription> SubscribeAsync(
|
||||
string tagReference,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
GalaxyTagMetadata tag = await ResolveTagAsync(tagReference, cancellationToken).ConfigureAwait(false);
|
||||
EnsurePublisherConnected(tag);
|
||||
|
||||
var subscription = new MxNativeSubscription(Guid.NewGuid(), tagReference, tag);
|
||||
EnsureSucceeded(
|
||||
_service.AdviseSupervisory(
|
||||
_options.LocalEngineId,
|
||||
tag,
|
||||
subscription.CorrelationId,
|
||||
_options.GalaxyId,
|
||||
_options.GalaxyId,
|
||||
_options.SourcePlatformId),
|
||||
nameof(ManagedNmxService2Client.AdviseSupervisory));
|
||||
_subscriptions.Add(subscription.CorrelationId, subscription);
|
||||
return subscription;
|
||||
}
|
||||
|
||||
public async Task<MxNativeSubscription> RegisterBufferedItemAsync(
|
||||
string itemDefinition,
|
||||
string itemContext,
|
||||
int itemHandle,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
string routeReference = string.IsNullOrWhiteSpace(itemContext)
|
||||
? itemDefinition
|
||||
: $"{itemContext.TrimEnd('.')}.{itemDefinition.TrimStart('.')}";
|
||||
GalaxyTagMetadata routeTag = await ResolveTagAsync(routeReference, cancellationToken).ConfigureAwait(false);
|
||||
EnsurePublisherConnected(routeTag);
|
||||
|
||||
var subscription = new MxNativeSubscription(
|
||||
Guid.NewGuid(),
|
||||
routeReference,
|
||||
routeTag,
|
||||
IsBuffered: true,
|
||||
BufferedItemDefinition: itemDefinition,
|
||||
BufferedItemContext: itemContext,
|
||||
BufferedItemHandle: itemHandle);
|
||||
var message = new NmxReferenceRegistrationMessage(
|
||||
itemHandle,
|
||||
subscription.CorrelationId,
|
||||
NmxReferenceRegistrationMessage.ToBufferedItemDefinition(itemDefinition),
|
||||
itemContext,
|
||||
Subscribe: true);
|
||||
EnsureSucceeded(
|
||||
_service.RegisterReference(
|
||||
_options.LocalEngineId,
|
||||
routeTag,
|
||||
message,
|
||||
_options.GalaxyId,
|
||||
_options.GalaxyId,
|
||||
_options.SourcePlatformId),
|
||||
nameof(ManagedNmxService2Client.RegisterReference));
|
||||
_subscriptions.Add(subscription.CorrelationId, subscription);
|
||||
return subscription;
|
||||
}
|
||||
|
||||
public async Task<object?> ReadAsync(
|
||||
string tagReference,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (timeout <= TimeSpan.Zero)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(timeout), "Read timeout must be positive.");
|
||||
}
|
||||
|
||||
using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeoutSource.CancelAfter(timeout);
|
||||
|
||||
var completion = new TaskCompletionSource<object?>(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
MxNativeSubscription? subscription = null;
|
||||
using CancellationTokenRegistration cancellation = timeoutSource.Token.Register(
|
||||
static state => ((TaskCompletionSource<object?>)state!).TrySetCanceled(),
|
||||
completion);
|
||||
|
||||
EventHandler<MxNativeCallbackEvent> handler = (_, evt) =>
|
||||
{
|
||||
if (subscription is null ||
|
||||
evt.Message.ItemCorrelationId != subscription.CorrelationId ||
|
||||
evt.Record.Value is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
completion.TrySetResult(evt.Record.Value);
|
||||
};
|
||||
|
||||
CallbackReceived += handler;
|
||||
try
|
||||
{
|
||||
subscription = await SubscribeAsync(tagReference, timeoutSource.Token).ConfigureAwait(false);
|
||||
return await completion.Task.ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CallbackReceived -= handler;
|
||||
if (subscription is not null && _subscriptions.ContainsKey(subscription.CorrelationId))
|
||||
{
|
||||
Unsubscribe(subscription.CorrelationId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Unsubscribe(Guid correlationId)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (!_subscriptions.Remove(correlationId, out MxNativeSubscription? subscription))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!subscription.IsBuffered)
|
||||
{
|
||||
EnsureSucceeded(
|
||||
_service.UnAdvise(
|
||||
_options.LocalEngineId,
|
||||
subscription.Metadata,
|
||||
subscription.CorrelationId,
|
||||
_options.GalaxyId,
|
||||
_options.GalaxyId,
|
||||
_options.SourcePlatformId),
|
||||
nameof(ManagedNmxService2Client.UnAdvise));
|
||||
}
|
||||
}
|
||||
|
||||
public void RecoverConnection()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
try
|
||||
{
|
||||
RecoveryAttemptStarted?.Invoke(this, new MxNativeRecoveryAttemptEvent(1, 1));
|
||||
RecoverConnectionCore();
|
||||
RecoveryCompleted?.Invoke(this, new MxNativeRecoveryCompletedEvent(1, 1));
|
||||
}
|
||||
catch (Exception ex) when (IsRecoverableRecoveryException(ex))
|
||||
{
|
||||
RecoveryAttemptFailed?.Invoke(this, new MxNativeRecoveryFailureEvent(1, 1, ex, WillRetry: false));
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RecoverConnectionAsync(
|
||||
MxNativeRecoveryPolicy? policy = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
policy ??= MxNativeRecoveryPolicy.SingleAttempt;
|
||||
policy.Validate();
|
||||
|
||||
Exception? lastError = null;
|
||||
for (int attempt = 1; attempt <= policy.MaxAttempts; attempt++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
RecoveryAttemptStarted?.Invoke(this, new MxNativeRecoveryAttemptEvent(attempt, policy.MaxAttempts));
|
||||
try
|
||||
{
|
||||
RecoverConnectionCore();
|
||||
RecoveryCompleted?.Invoke(this, new MxNativeRecoveryCompletedEvent(attempt, policy.MaxAttempts));
|
||||
return;
|
||||
}
|
||||
catch (Exception ex) when (IsRecoverableRecoveryException(ex))
|
||||
{
|
||||
lastError = ex;
|
||||
bool willRetry = attempt < policy.MaxAttempts;
|
||||
RecoveryAttemptFailed?.Invoke(
|
||||
this,
|
||||
new MxNativeRecoveryFailureEvent(attempt, policy.MaxAttempts, ex, willRetry));
|
||||
if (!willRetry)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (policy.Delay > TimeSpan.Zero)
|
||||
{
|
||||
await Task.Delay(policy.Delay, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"MX native recovery failed after {policy.MaxAttempts} attempt(s).",
|
||||
lastError);
|
||||
}
|
||||
|
||||
private void RecoverConnectionCore()
|
||||
{
|
||||
Interlocked.Increment(ref _recoveryActive);
|
||||
try
|
||||
{
|
||||
ManagedNmxService2Client replacement = CreateRegisteredService(_options, _callback);
|
||||
try
|
||||
{
|
||||
foreach (PublisherEndpoint endpoint in _publisherEndpoints)
|
||||
{
|
||||
ConnectPublisher(replacement, endpoint);
|
||||
}
|
||||
|
||||
foreach (MxNativeSubscription subscription in _subscriptions.Values)
|
||||
{
|
||||
ReAdviseSubscription(replacement, subscription);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
replacement.Dispose();
|
||||
throw;
|
||||
}
|
||||
|
||||
ManagedNmxService2Client oldService = _service;
|
||||
_service = replacement;
|
||||
oldService.Dispose();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Interlocked.Decrement(ref _recoveryActive);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (MxNativeSubscription subscription in _subscriptions.Values.ToArray())
|
||||
{
|
||||
if (!subscription.IsBuffered)
|
||||
{
|
||||
TryCleanup(() => _service.UnAdvise(
|
||||
_options.LocalEngineId,
|
||||
subscription.Metadata,
|
||||
subscription.CorrelationId,
|
||||
_options.GalaxyId,
|
||||
_options.GalaxyId,
|
||||
_options.SourcePlatformId));
|
||||
}
|
||||
}
|
||||
|
||||
foreach (PublisherEndpoint publisherEndpoint in _publisherEndpoints)
|
||||
{
|
||||
TryCleanup(() => _service.RemoveSubscriberEngine(
|
||||
publisherEndpoint.EngineId,
|
||||
_options.GalaxyId,
|
||||
_options.SourcePlatformId,
|
||||
_options.LocalEngineId));
|
||||
}
|
||||
|
||||
TryCleanup(() => _service.UnregisterEngine(_options.LocalEngineId));
|
||||
_service.Dispose();
|
||||
if (_callbackHandle.IsAllocated)
|
||||
{
|
||||
_callbackHandle.Free();
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
private void EnsurePublisherConnected(GalaxyTagMetadata tag)
|
||||
{
|
||||
var endpoint = new PublisherEndpoint(tag.PlatformId, tag.EngineId);
|
||||
if (_publisherEndpoints.Contains(endpoint))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ConnectPublisher(_service, endpoint);
|
||||
_publisherEndpoints.Add(endpoint);
|
||||
}
|
||||
|
||||
private void ConnectPublisher(ManagedNmxService2Client service, PublisherEndpoint endpoint)
|
||||
{
|
||||
EnsureSucceeded(
|
||||
service.Connect(_options.LocalEngineId, _options.GalaxyId, endpoint.PlatformId, endpoint.EngineId),
|
||||
nameof(ManagedNmxService2Client.Connect));
|
||||
EnsureSucceeded(
|
||||
service.AddSubscriberEngine(endpoint.EngineId, _options.GalaxyId, _options.SourcePlatformId, _options.LocalEngineId),
|
||||
nameof(ManagedNmxService2Client.AddSubscriberEngine));
|
||||
}
|
||||
|
||||
private void ReAdviseSubscription(ManagedNmxService2Client service, MxNativeSubscription subscription)
|
||||
{
|
||||
if (subscription.IsBuffered)
|
||||
{
|
||||
var message = new NmxReferenceRegistrationMessage(
|
||||
subscription.BufferedItemHandle,
|
||||
subscription.CorrelationId,
|
||||
NmxReferenceRegistrationMessage.ToBufferedItemDefinition(subscription.BufferedItemDefinition),
|
||||
subscription.BufferedItemContext,
|
||||
Subscribe: true);
|
||||
EnsureSucceeded(
|
||||
service.RegisterReference(
|
||||
_options.LocalEngineId,
|
||||
subscription.Metadata,
|
||||
message,
|
||||
_options.GalaxyId,
|
||||
_options.GalaxyId,
|
||||
_options.SourcePlatformId),
|
||||
nameof(ManagedNmxService2Client.RegisterReference));
|
||||
return;
|
||||
}
|
||||
|
||||
EnsureSucceeded(
|
||||
service.AdviseSupervisory(
|
||||
_options.LocalEngineId,
|
||||
subscription.Metadata,
|
||||
subscription.CorrelationId,
|
||||
_options.GalaxyId,
|
||||
_options.GalaxyId,
|
||||
_options.SourcePlatformId),
|
||||
nameof(ManagedNmxService2Client.AdviseSupervisory));
|
||||
}
|
||||
|
||||
private void OnCallbackReceived(object? sender, NmxServiceMessage message)
|
||||
{
|
||||
bool isDuringRecovery = Volatile.Read(ref _recoveryActive) > 0;
|
||||
if (NmxOperationStatusMessage.TryParseProcessDataReceivedBody(message.Body, out var operationStatus))
|
||||
{
|
||||
OperationStatusReceived?.Invoke(
|
||||
this,
|
||||
new MxNativeOperationStatusEvent(operationStatus, message.Body, isDuringRecovery));
|
||||
return;
|
||||
}
|
||||
|
||||
if (NmxReferenceRegistrationResultMessage.TryParseProcessDataReceivedBody(message.Body, out var registrationResult))
|
||||
{
|
||||
ReferenceRegistrationReceived?.Invoke(
|
||||
this,
|
||||
new MxNativeReferenceRegistrationEvent(registrationResult!, message.Body, isDuringRecovery));
|
||||
return;
|
||||
}
|
||||
|
||||
NmxSubscriptionMessage parsed;
|
||||
try
|
||||
{
|
||||
parsed = NmxSubscriptionMessage.ParseProcessDataReceivedBody(message.Body);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
UnparsedCallbackReceived?.Invoke(
|
||||
this,
|
||||
new MxNativeUnparsedCallbackEvent(message.Body, ex.Message, isDuringRecovery));
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (NmxSubscriptionRecord record in parsed.Records)
|
||||
{
|
||||
CallbackReceived?.Invoke(this, new MxNativeCallbackEvent(parsed, record, message.Body, isDuringRecovery));
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureSucceeded(int hresult, string operation)
|
||||
{
|
||||
if (hresult == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (hresult < 0)
|
||||
{
|
||||
Marshal.ThrowExceptionForHR(hresult);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"{operation} returned application status 0x{hresult:X8}.");
|
||||
}
|
||||
|
||||
private static ManagedNmxService2Client CreateRegisteredService(MxNativeClientOptions options, NmxCallbackSink callback)
|
||||
{
|
||||
byte[] callbackObjRef = ComObjRefProvider.MarshalInterfaceObjRef(
|
||||
callback,
|
||||
NmxProcedureMetadata.INmxSvcCallback,
|
||||
ComObjRefProvider.MarshalContextDifferentMachine);
|
||||
ManagedNmxService2Client service = ManagedNmxService2Client.Create(options.Authentication);
|
||||
try
|
||||
{
|
||||
EnsureSucceeded(
|
||||
service.RegisterEngine2(options.LocalEngineId, options.EngineName, options.PartnerVersion, callbackObjRef),
|
||||
nameof(ManagedNmxService2Client.RegisterEngine2));
|
||||
if (options.HeartbeatTicksPerBeat is { } heartbeatTicks)
|
||||
{
|
||||
EnsureSucceeded(
|
||||
service.SetHeartbeatSendInterval(heartbeatTicks, options.HeartbeatMaxMissedTicks),
|
||||
nameof(ManagedNmxService2Client.SetHeartbeatSendInterval));
|
||||
}
|
||||
|
||||
return service;
|
||||
}
|
||||
catch
|
||||
{
|
||||
service.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryCleanup(Func<int> operation)
|
||||
{
|
||||
try
|
||||
{
|
||||
_ = operation();
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidOperationException or COMException or ObjectDisposedException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsRecoverableRecoveryException(Exception ex)
|
||||
{
|
||||
return ex is InvalidOperationException or COMException or IOException or ObjectDisposedException;
|
||||
}
|
||||
|
||||
private readonly record struct PublisherEndpoint(int PlatformId, int EngineId);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace MxNativeClient;
|
||||
|
||||
public sealed record NmxServiceMessage(byte[] Body);
|
||||
|
||||
[ComVisible(true)]
|
||||
[ClassInterface(ClassInterfaceType.None)]
|
||||
public sealed unsafe class NmxCallbackSink : INmxSvcCallback
|
||||
{
|
||||
public event EventHandler<NmxServiceMessage>? DataReceived;
|
||||
public event EventHandler<NmxServiceMessage>? StatusReceived;
|
||||
|
||||
public void DataReceivedRaw(int bufferSize, ref sbyte dataBuffer)
|
||||
{
|
||||
DataReceived?.Invoke(this, new NmxServiceMessage(CopyBuffer(bufferSize, ref dataBuffer)));
|
||||
}
|
||||
|
||||
public void StatusReceivedRaw(int bufferSize, ref sbyte statusBuffer)
|
||||
{
|
||||
StatusReceived?.Invoke(this, new NmxServiceMessage(CopyBuffer(bufferSize, ref statusBuffer)));
|
||||
}
|
||||
|
||||
private static byte[] CopyBuffer(int bufferSize, ref sbyte firstByte)
|
||||
{
|
||||
if (bufferSize <= 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var output = new byte[bufferSize];
|
||||
fixed (sbyte* source = &firstByte)
|
||||
{
|
||||
new ReadOnlySpan<byte>(source, bufferSize).CopyTo(output);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace MxNativeClient;
|
||||
|
||||
[ComImport]
|
||||
[Guid("AE24BD51-2E80-44CC-905B-E5446C942BEB")]
|
||||
[ClassInterface(ClassInterfaceType.None)]
|
||||
internal sealed class NmxServiceClass : INmxService2
|
||||
{
|
||||
public extern void RegisterEngine(int engineId, string engineName, INmxSvcCallback callback);
|
||||
public extern void UnRegisterEngine(int engineId);
|
||||
public extern void Connect(int localEngineId, int remoteGalaxyId, int remotePlatformId, int remoteEngineId);
|
||||
public extern void TransferData(int remoteGalaxyId, int remotePlatformId, int remoteEngineId, int size, ref byte messageBody);
|
||||
public extern void AddSubscriberEngine(int localEngineId, int subscriberGalaxyId, int subscriberPlatformId, int subscriberEngineId);
|
||||
public extern void RemoveSubscriberEngine(int localEngineId, int subscriberGalaxyId, int subscriberPlatformId, int subscriberEngineId);
|
||||
public extern void SetHeartbeatSendInterval(int ticksPerBeat, int maxMissedTicks);
|
||||
public extern void RegisterEngine2(int engineId, string engineName, int version, INmxSvcCallback callback);
|
||||
public extern void GetPartnerVersion(int galaxyId, int platformId, int engineId, out int version);
|
||||
}
|
||||
|
||||
[ComImport]
|
||||
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
[Guid("575008DB-845D-46C6-A906-F6F8CA86F315")]
|
||||
internal interface INmxService
|
||||
{
|
||||
[MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)]
|
||||
void RegisterEngine(int engineId, [MarshalAs(UnmanagedType.BStr)] string engineName, [MarshalAs(UnmanagedType.Interface)] INmxSvcCallback callback);
|
||||
|
||||
[MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)]
|
||||
void UnRegisterEngine(int engineId);
|
||||
|
||||
[MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)]
|
||||
void Connect(int localEngineId, int remoteGalaxyId, int remotePlatformId, int remoteEngineId);
|
||||
|
||||
[MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)]
|
||||
void TransferData(int remoteGalaxyId, int remotePlatformId, int remoteEngineId, int size, ref byte messageBody);
|
||||
|
||||
[MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)]
|
||||
void AddSubscriberEngine(int localEngineId, int subscriberGalaxyId, int subscriberPlatformId, int subscriberEngineId);
|
||||
|
||||
[MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)]
|
||||
void RemoveSubscriberEngine(int localEngineId, int subscriberGalaxyId, int subscriberPlatformId, int subscriberEngineId);
|
||||
|
||||
[MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)]
|
||||
void SetHeartbeatSendInterval(int ticksPerBeat, int maxMissedTicks);
|
||||
}
|
||||
|
||||
[ComImport]
|
||||
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
[Guid("2630A513-A974-4B1A-8025-457A9A7C56B8")]
|
||||
internal interface INmxService2 : INmxService
|
||||
{
|
||||
[MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)]
|
||||
new void RegisterEngine(int engineId, [MarshalAs(UnmanagedType.BStr)] string engineName, [MarshalAs(UnmanagedType.Interface)] INmxSvcCallback callback);
|
||||
|
||||
[MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)]
|
||||
new void UnRegisterEngine(int engineId);
|
||||
|
||||
[MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)]
|
||||
new void Connect(int localEngineId, int remoteGalaxyId, int remotePlatformId, int remoteEngineId);
|
||||
|
||||
[MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)]
|
||||
new void TransferData(int remoteGalaxyId, int remotePlatformId, int remoteEngineId, int size, ref byte messageBody);
|
||||
|
||||
[MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)]
|
||||
new void AddSubscriberEngine(int localEngineId, int subscriberGalaxyId, int subscriberPlatformId, int subscriberEngineId);
|
||||
|
||||
[MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)]
|
||||
new void RemoveSubscriberEngine(int localEngineId, int subscriberGalaxyId, int subscriberPlatformId, int subscriberEngineId);
|
||||
|
||||
[MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)]
|
||||
new void SetHeartbeatSendInterval(int ticksPerBeat, int maxMissedTicks);
|
||||
|
||||
[MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)]
|
||||
void RegisterEngine2(int engineId, [MarshalAs(UnmanagedType.BStr)] string engineName, int version, [MarshalAs(UnmanagedType.Interface)] INmxSvcCallback callback);
|
||||
|
||||
[MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)]
|
||||
void GetPartnerVersion(int galaxyId, int platformId, int engineId, out int version);
|
||||
}
|
||||
|
||||
[ComVisible(true)]
|
||||
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
[Guid("B49F92F7-C748-4169-8ECA-A0670B012746")]
|
||||
public interface INmxSvcCallback
|
||||
{
|
||||
[PreserveSig]
|
||||
void DataReceivedRaw(int bufferSize, ref sbyte dataBuffer);
|
||||
|
||||
[PreserveSig]
|
||||
void StatusReceivedRaw(int bufferSize, ref sbyte statusBuffer);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
namespace MxNativeClient;
|
||||
|
||||
public static class NmxProcedureMetadata
|
||||
{
|
||||
public static readonly Guid INmxService2 = new("2630A513-A974-4B1A-8025-457A9A7C56B8");
|
||||
public static readonly Guid INmxSvcCallback = new("B49F92F7-C748-4169-8ECA-A0670B012746");
|
||||
|
||||
public static readonly NdrProcedureDescriptor RegisterEngine = new(
|
||||
InterfaceId: INmxService2,
|
||||
Name: nameof(RegisterEngine),
|
||||
Opnum: 3,
|
||||
X86StackSize: 20,
|
||||
ClientBufferSize: 8,
|
||||
ServerBufferSize: 8,
|
||||
ParameterCountIncludingReturn: 4);
|
||||
|
||||
public static readonly NdrProcedureDescriptor UnRegisterEngine = new(
|
||||
InterfaceId: INmxService2,
|
||||
Name: nameof(UnRegisterEngine),
|
||||
Opnum: 4,
|
||||
X86StackSize: 12,
|
||||
ClientBufferSize: 8,
|
||||
ServerBufferSize: 8,
|
||||
ParameterCountIncludingReturn: 2);
|
||||
|
||||
public static readonly NdrProcedureDescriptor Connect = new(
|
||||
InterfaceId: INmxService2,
|
||||
Name: nameof(Connect),
|
||||
Opnum: 5,
|
||||
X86StackSize: 24,
|
||||
ClientBufferSize: 32,
|
||||
ServerBufferSize: 8,
|
||||
ParameterCountIncludingReturn: 5);
|
||||
|
||||
public static readonly NdrProcedureDescriptor TransferData = new(
|
||||
InterfaceId: INmxService2,
|
||||
Name: nameof(TransferData),
|
||||
Opnum: 6,
|
||||
X86StackSize: 28,
|
||||
ClientBufferSize: 32,
|
||||
ServerBufferSize: 8,
|
||||
ParameterCountIncludingReturn: 6);
|
||||
|
||||
public static readonly NdrProcedureDescriptor AddSubscriberEngine = new(
|
||||
InterfaceId: INmxService2,
|
||||
Name: nameof(AddSubscriberEngine),
|
||||
Opnum: 7,
|
||||
X86StackSize: 24,
|
||||
ClientBufferSize: 32,
|
||||
ServerBufferSize: 8,
|
||||
ParameterCountIncludingReturn: 5);
|
||||
|
||||
public static readonly NdrProcedureDescriptor RemoveSubscriberEngine = new(
|
||||
InterfaceId: INmxService2,
|
||||
Name: nameof(RemoveSubscriberEngine),
|
||||
Opnum: 8,
|
||||
X86StackSize: 24,
|
||||
ClientBufferSize: 32,
|
||||
ServerBufferSize: 8,
|
||||
ParameterCountIncludingReturn: 5);
|
||||
|
||||
public static readonly NdrProcedureDescriptor SetHeartbeatSendInterval = new(
|
||||
InterfaceId: INmxService2,
|
||||
Name: nameof(SetHeartbeatSendInterval),
|
||||
Opnum: 9,
|
||||
X86StackSize: 16,
|
||||
ClientBufferSize: 16,
|
||||
ServerBufferSize: 8,
|
||||
ParameterCountIncludingReturn: 3);
|
||||
|
||||
public static readonly NdrProcedureDescriptor RegisterEngine2 = new(
|
||||
InterfaceId: INmxService2,
|
||||
Name: nameof(RegisterEngine2),
|
||||
Opnum: 10,
|
||||
X86StackSize: 24,
|
||||
ClientBufferSize: 16,
|
||||
ServerBufferSize: 8,
|
||||
ParameterCountIncludingReturn: 5);
|
||||
|
||||
public static readonly NdrProcedureDescriptor GetPartnerVersion = new(
|
||||
InterfaceId: INmxService2,
|
||||
Name: nameof(GetPartnerVersion),
|
||||
Opnum: 11,
|
||||
X86StackSize: 24,
|
||||
ClientBufferSize: 24,
|
||||
ServerBufferSize: 36,
|
||||
ParameterCountIncludingReturn: 5);
|
||||
|
||||
public static readonly NdrProcedureDescriptor DataReceived = new(
|
||||
InterfaceId: INmxSvcCallback,
|
||||
Name: nameof(DataReceived),
|
||||
Opnum: 3,
|
||||
X86StackSize: 16,
|
||||
ClientBufferSize: 8,
|
||||
ServerBufferSize: 8,
|
||||
ParameterCountIncludingReturn: 3);
|
||||
|
||||
public static readonly NdrProcedureDescriptor StatusReceived = new(
|
||||
InterfaceId: INmxSvcCallback,
|
||||
Name: nameof(StatusReceived),
|
||||
Opnum: 4,
|
||||
X86StackSize: 16,
|
||||
ClientBufferSize: 8,
|
||||
ServerBufferSize: 8,
|
||||
ParameterCountIncludingReturn: 3);
|
||||
}
|
||||
|
||||
public sealed record NdrProcedureDescriptor(
|
||||
Guid InterfaceId,
|
||||
string Name,
|
||||
int Opnum,
|
||||
int X86StackSize,
|
||||
int ClientBufferSize,
|
||||
int ServerBufferSize,
|
||||
int ParameterCountIncludingReturn);
|
||||
@@ -0,0 +1,216 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
|
||||
namespace MxNativeClient;
|
||||
|
||||
public sealed record NmxGetPartnerVersionResult(OrpcThat OrpcThat, int PartnerVersion, int HResult);
|
||||
|
||||
public sealed record NmxHResultResponse(OrpcThat OrpcThat, int HResult);
|
||||
|
||||
public static class NmxService2Messages
|
||||
{
|
||||
public static Guid ClassIdNmxService { get; } = new("AE24BD51-2E80-44CC-905B-E5446C942BEB");
|
||||
public static Guid InterfaceId { get; } = NmxProcedureMetadata.INmxService2;
|
||||
|
||||
public const ushort RegisterEngineOpnum = 3;
|
||||
public const ushort UnregisterEngineOpnum = 4;
|
||||
public const ushort ConnectOpnum = 5;
|
||||
public const ushort TransferDataOpnum = 6;
|
||||
public const ushort AddSubscriberEngineOpnum = 7;
|
||||
public const ushort RemoveSubscriberEngineOpnum = 8;
|
||||
public const ushort SetHeartbeatSendIntervalOpnum = 9;
|
||||
public const ushort RegisterEngine2Opnum = 10;
|
||||
public const ushort GetPartnerVersionOpnum = 11;
|
||||
|
||||
public static byte[] EncodeGetPartnerVersionRequest(
|
||||
OrpcThis orpcThis,
|
||||
int galaxyId,
|
||||
int platformId,
|
||||
int engineId)
|
||||
{
|
||||
byte[] buffer = new byte[OrpcThis.EncodedLengthWithoutExtensions + 12];
|
||||
orpcThis.Encode().CopyTo(buffer.AsSpan());
|
||||
WriteInt32(buffer.AsSpan(32, 4), galaxyId);
|
||||
WriteInt32(buffer.AsSpan(36, 4), platformId);
|
||||
WriteInt32(buffer.AsSpan(40, 4), engineId);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
public static NmxGetPartnerVersionResult ParseGetPartnerVersionResponse(ReadOnlySpan<byte> buffer)
|
||||
{
|
||||
if (buffer.Length < OrpcThat.EncodedLengthWithoutExtensions + 8)
|
||||
{
|
||||
throw new ArgumentException("GetPartnerVersion response is too short.", nameof(buffer));
|
||||
}
|
||||
|
||||
return new NmxGetPartnerVersionResult(
|
||||
OrpcThat.Parse(buffer),
|
||||
ReadInt32(buffer[8..12]),
|
||||
ReadInt32(buffer[12..16]));
|
||||
}
|
||||
|
||||
public static byte[] EncodeConnectRequest(
|
||||
OrpcThis orpcThis,
|
||||
int localEngineId,
|
||||
int remoteGalaxyId,
|
||||
int remotePlatformId,
|
||||
int remoteEngineId)
|
||||
{
|
||||
byte[] buffer = new byte[OrpcThis.EncodedLengthWithoutExtensions + 16];
|
||||
orpcThis.Encode().CopyTo(buffer.AsSpan());
|
||||
WriteInt32(buffer.AsSpan(32, 4), localEngineId);
|
||||
WriteInt32(buffer.AsSpan(36, 4), remoteGalaxyId);
|
||||
WriteInt32(buffer.AsSpan(40, 4), remotePlatformId);
|
||||
WriteInt32(buffer.AsSpan(44, 4), remoteEngineId);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
public static byte[] EncodeSubscriberEngineRequest(
|
||||
OrpcThis orpcThis,
|
||||
int localEngineId,
|
||||
int subscriberGalaxyId,
|
||||
int subscriberPlatformId,
|
||||
int subscriberEngineId)
|
||||
{
|
||||
byte[] buffer = new byte[OrpcThis.EncodedLengthWithoutExtensions + 16];
|
||||
orpcThis.Encode().CopyTo(buffer.AsSpan());
|
||||
WriteInt32(buffer.AsSpan(32, 4), localEngineId);
|
||||
WriteInt32(buffer.AsSpan(36, 4), subscriberGalaxyId);
|
||||
WriteInt32(buffer.AsSpan(40, 4), subscriberPlatformId);
|
||||
WriteInt32(buffer.AsSpan(44, 4), subscriberEngineId);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
public static byte[] EncodeUnregisterEngineRequest(
|
||||
OrpcThis orpcThis,
|
||||
int localEngineId)
|
||||
{
|
||||
byte[] buffer = new byte[OrpcThis.EncodedLengthWithoutExtensions + 4];
|
||||
orpcThis.Encode().CopyTo(buffer.AsSpan());
|
||||
WriteInt32(buffer.AsSpan(32, 4), localEngineId);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
public static byte[] EncodeSetHeartbeatSendIntervalRequest(
|
||||
OrpcThis orpcThis,
|
||||
int ticksPerBeat,
|
||||
int maxMissedTicks)
|
||||
{
|
||||
byte[] buffer = new byte[OrpcThis.EncodedLengthWithoutExtensions + 8];
|
||||
orpcThis.Encode().CopyTo(buffer.AsSpan());
|
||||
WriteInt32(buffer.AsSpan(32, 4), ticksPerBeat);
|
||||
WriteInt32(buffer.AsSpan(36, 4), maxMissedTicks);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
public static byte[] EncodeTransferDataRequest(
|
||||
OrpcThis orpcThis,
|
||||
int remoteGalaxyId,
|
||||
int remotePlatformId,
|
||||
int remoteEngineId,
|
||||
ReadOnlySpan<byte> messageBody)
|
||||
{
|
||||
int bodyOffset = OrpcThis.EncodedLengthWithoutExtensions + 20;
|
||||
int paddedLength = Align(bodyOffset + messageBody.Length, 4);
|
||||
byte[] buffer = new byte[paddedLength];
|
||||
orpcThis.Encode().CopyTo(buffer.AsSpan());
|
||||
WriteInt32(buffer.AsSpan(32, 4), remoteGalaxyId);
|
||||
WriteInt32(buffer.AsSpan(36, 4), remotePlatformId);
|
||||
WriteInt32(buffer.AsSpan(40, 4), remoteEngineId);
|
||||
WriteInt32(buffer.AsSpan(44, 4), messageBody.Length);
|
||||
WriteInt32(buffer.AsSpan(48, 4), messageBody.Length);
|
||||
messageBody.CopyTo(buffer.AsSpan(bodyOffset));
|
||||
return buffer;
|
||||
}
|
||||
|
||||
public static byte[] EncodeRegisterEngine2Request(
|
||||
OrpcThis orpcThis,
|
||||
int localEngineId,
|
||||
string engineName,
|
||||
int version,
|
||||
byte[]? callbackObjRef = null)
|
||||
{
|
||||
byte[] bstr = EncodeBstrUserMarshal(engineName);
|
||||
byte[] callback = callbackObjRef is null
|
||||
? EncodeNullInterfacePointer()
|
||||
: EncodeInterfacePointer(callbackObjRef);
|
||||
|
||||
int bstrOffset = OrpcThis.EncodedLengthWithoutExtensions + 8;
|
||||
int versionOffset = Align(bstrOffset + bstr.Length, 4);
|
||||
int length = Align(versionOffset + 4 + callback.Length, 4);
|
||||
byte[] buffer = new byte[length];
|
||||
orpcThis.Encode().CopyTo(buffer.AsSpan());
|
||||
int offset = OrpcThis.EncodedLengthWithoutExtensions;
|
||||
WriteInt32(buffer.AsSpan(offset, 4), localEngineId);
|
||||
offset += 4;
|
||||
WriteInt32(buffer.AsSpan(offset, 4), 0x72657355);
|
||||
offset += 4;
|
||||
bstr.CopyTo(buffer.AsSpan(offset));
|
||||
offset = versionOffset;
|
||||
WriteInt32(buffer.AsSpan(offset, 4), version);
|
||||
offset += 4;
|
||||
callback.CopyTo(buffer.AsSpan(offset));
|
||||
return buffer;
|
||||
}
|
||||
|
||||
public static byte[] EncodeBstrUserMarshal(string value)
|
||||
{
|
||||
byte[] utf16 = Encoding.Unicode.GetBytes(value);
|
||||
if ((utf16.Length % 2) != 0)
|
||||
{
|
||||
throw new ArgumentException("BSTR payload must be UTF-16.", nameof(value));
|
||||
}
|
||||
|
||||
int charCount = utf16.Length / 2;
|
||||
byte[] buffer = new byte[12 + utf16.Length];
|
||||
WriteInt32(buffer.AsSpan(0, 4), charCount);
|
||||
WriteInt32(buffer.AsSpan(4, 4), utf16.Length);
|
||||
WriteInt32(buffer.AsSpan(8, 4), charCount);
|
||||
utf16.CopyTo(buffer.AsSpan(12));
|
||||
return buffer;
|
||||
}
|
||||
|
||||
public static NmxHResultResponse ParseHResultResponse(ReadOnlySpan<byte> buffer)
|
||||
{
|
||||
if (buffer.Length < OrpcThat.EncodedLengthWithoutExtensions + 4)
|
||||
{
|
||||
throw new ArgumentException("HRESULT response is too short.", nameof(buffer));
|
||||
}
|
||||
|
||||
return new NmxHResultResponse(
|
||||
OrpcThat.Parse(buffer),
|
||||
ReadInt32(buffer[8..12]));
|
||||
}
|
||||
|
||||
private static int ReadInt32(ReadOnlySpan<byte> buffer)
|
||||
{
|
||||
return BinaryPrimitives.ReadInt32LittleEndian(buffer);
|
||||
}
|
||||
|
||||
private static void WriteInt32(Span<byte> buffer, int value)
|
||||
{
|
||||
BinaryPrimitives.WriteInt32LittleEndian(buffer, value);
|
||||
}
|
||||
|
||||
private static int Align(int value, int alignment)
|
||||
{
|
||||
int remainder = value % alignment;
|
||||
return remainder == 0 ? value : value + alignment - remainder;
|
||||
}
|
||||
|
||||
private static byte[] EncodeNullInterfacePointer()
|
||||
{
|
||||
return [0, 0, 0, 0];
|
||||
}
|
||||
|
||||
private static byte[] EncodeInterfacePointer(byte[] objRef)
|
||||
{
|
||||
int length = Align(12 + objRef.Length, 4);
|
||||
byte[] buffer = new byte[length];
|
||||
WriteInt32(buffer.AsSpan(0, 4), 0x00020000);
|
||||
WriteInt32(buffer.AsSpan(4, 4), objRef.Length);
|
||||
WriteInt32(buffer.AsSpan(8, 4), objRef.Length);
|
||||
objRef.CopyTo(buffer.AsSpan(12));
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using MxNativeCodec;
|
||||
|
||||
namespace MxNativeClient;
|
||||
|
||||
public sealed class NmxServiceClient : IDisposable
|
||||
{
|
||||
public const int ObservedNmxVersion = 30000;
|
||||
|
||||
private readonly INmxService2 _service;
|
||||
private readonly object _comObject;
|
||||
private int? _registeredEngineId;
|
||||
private bool _disposed;
|
||||
|
||||
private NmxServiceClient(INmxService2 service)
|
||||
{
|
||||
_service = service;
|
||||
_comObject = service;
|
||||
}
|
||||
|
||||
public static NmxServiceClient Create()
|
||||
{
|
||||
var service = (INmxService2)new NmxServiceClass();
|
||||
return new NmxServiceClient(service);
|
||||
}
|
||||
|
||||
public void RegisterEngine(int engineId, string engineName, NmxCallbackSink callback, int version = ObservedNmxVersion)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
_service.RegisterEngine2(engineId, engineName, version, callback);
|
||||
_registeredEngineId = engineId;
|
||||
}
|
||||
|
||||
public void UnregisterEngine()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (_registeredEngineId is not { } engineId)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_service.UnRegisterEngine(engineId);
|
||||
_registeredEngineId = null;
|
||||
}
|
||||
|
||||
public int GetPartnerVersion(int galaxyId, int platformId, int engineId)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
_service.GetPartnerVersion(galaxyId, platformId, engineId, out var version);
|
||||
return version;
|
||||
}
|
||||
|
||||
public void Connect(int localEngineId, int remoteGalaxyId, int remotePlatformId, int remoteEngineId)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
_service.Connect(localEngineId, remoteGalaxyId, remotePlatformId, remoteEngineId);
|
||||
}
|
||||
|
||||
public void SetHeartbeatSendInterval(int ticksPerBeat, int maxMissedTicks)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
_service.SetHeartbeatSendInterval(ticksPerBeat, maxMissedTicks);
|
||||
}
|
||||
|
||||
public void TransferData(int remoteGalaxyId, int remotePlatformId, int remoteEngineId, ReadOnlySpan<byte> messageBody)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
ValidateTransferDataBody(messageBody, nameof(messageBody));
|
||||
|
||||
var copy = messageBody.ToArray();
|
||||
_service.TransferData(remoteGalaxyId, remotePlatformId, remoteEngineId, copy.Length, ref copy[0]);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (_registeredEngineId is not null)
|
||||
{
|
||||
UnregisterEngine();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Marshal.IsComObject(_comObject))
|
||||
{
|
||||
Marshal.ReleaseComObject(_comObject);
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateTransferDataBody(ReadOnlySpan<byte> messageBody, string parameterName)
|
||||
{
|
||||
if (messageBody.IsEmpty)
|
||||
{
|
||||
throw new ArgumentException("TransferData body cannot be empty.", parameterName);
|
||||
}
|
||||
|
||||
NmxTransferEnvelopeTemplate.FromObserved(messageBody);
|
||||
if (messageBody.Length == NmxTransferEnvelopeTemplate.HeaderLength)
|
||||
{
|
||||
throw new ArgumentException("TransferData body must include an inner message after the 46-byte envelope.", parameterName);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System.Buffers.Binary;
|
||||
|
||||
namespace MxNativeClient;
|
||||
|
||||
public sealed record NmxCallbackRequest(OrpcThis OrpcThis, byte[] Body);
|
||||
|
||||
public static class NmxSvcCallbackMessages
|
||||
{
|
||||
public static Guid InterfaceId { get; } = NmxProcedureMetadata.INmxSvcCallback;
|
||||
|
||||
public const ushort DataReceivedOpnum = 3;
|
||||
public const ushort StatusReceivedOpnum = 4;
|
||||
|
||||
public static NmxCallbackRequest ParseCallbackRequest(ReadOnlySpan<byte> buffer)
|
||||
{
|
||||
if (buffer.Length < OrpcThis.EncodedLengthWithoutExtensions + 8)
|
||||
{
|
||||
throw new ArgumentException("Callback request is too short.", nameof(buffer));
|
||||
}
|
||||
|
||||
var orpcThis = OrpcThis.Parse(buffer);
|
||||
int size = BinaryPrimitives.ReadInt32LittleEndian(buffer.Slice(OrpcThis.EncodedLengthWithoutExtensions, 4));
|
||||
int maxCount = BinaryPrimitives.ReadInt32LittleEndian(buffer.Slice(OrpcThis.EncodedLengthWithoutExtensions + 4, 4));
|
||||
if (size < 0 || maxCount < size)
|
||||
{
|
||||
throw new ArgumentException("Callback request has invalid array size metadata.", nameof(buffer));
|
||||
}
|
||||
|
||||
int bodyOffset = OrpcThis.EncodedLengthWithoutExtensions + 8;
|
||||
if (bodyOffset + size > buffer.Length)
|
||||
{
|
||||
throw new ArgumentException("Callback request byte array is truncated.", nameof(buffer));
|
||||
}
|
||||
|
||||
return new NmxCallbackRequest(orpcThis, buffer.Slice(bodyOffset, size).ToArray());
|
||||
}
|
||||
|
||||
public static byte[] EncodeCallbackResponse(int hresult)
|
||||
{
|
||||
byte[] buffer = new byte[OrpcThat.EncodedLengthWithoutExtensions + sizeof(int)];
|
||||
new OrpcThat(0, 0).Encode().CopyTo(buffer.AsSpan());
|
||||
BinaryPrimitives.WriteInt32LittleEndian(buffer.AsSpan(OrpcThat.EncodedLengthWithoutExtensions, sizeof(int)), hresult);
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
namespace MxNativeClient;
|
||||
|
||||
public sealed class ObjectExporterClient
|
||||
{
|
||||
private readonly string _host;
|
||||
private readonly int _port;
|
||||
|
||||
public ObjectExporterClient(string host = "127.0.0.1", int port = 135)
|
||||
{
|
||||
_host = host;
|
||||
_port = port;
|
||||
}
|
||||
|
||||
public ResolveOxidFailure ResolveOxidUnauthenticated(ulong oxid, IReadOnlyList<ushort>? requestedProtseqs = null)
|
||||
{
|
||||
using var client = new DceRpcTcpClient(_host, _port);
|
||||
client.Connect();
|
||||
var bind = client.Bind(ObjectExporterMessages.IObjectExporter, versionMajor: 0, versionMinor: 0);
|
||||
if (bind.PacketType != DceRpcPacketType.BindAck)
|
||||
{
|
||||
throw new InvalidOperationException($"Unexpected bind response packet type {bind.PacketType}.");
|
||||
}
|
||||
|
||||
byte[] request = ObjectExporterMessages.EncodeResolveOxidRequest(
|
||||
oxid,
|
||||
requestedProtseqs ?? [ObjectExporterMessages.ProtseqNcacnIpTcp]);
|
||||
|
||||
var response = client.Call(contextId: 0, ObjectExporterMessages.ResolveOxidOpnum, request);
|
||||
return ObjectExporterMessages.ParseResolveOxidFailure(response.StubData.Span);
|
||||
}
|
||||
|
||||
public DceRpcResponsePdu ResolveOxidWithNtlmConnect(ulong oxid, IReadOnlyList<ushort>? requestedProtseqs = null)
|
||||
{
|
||||
using var client = new DceRpcTcpClient(_host, _port);
|
||||
client.Connect();
|
||||
var bind = client.BindWithNtlmConnect(ObjectExporterMessages.IObjectExporter, versionMajor: 0, versionMinor: 0);
|
||||
if (bind.PacketType != DceRpcPacketType.BindAck)
|
||||
{
|
||||
throw new InvalidOperationException($"Unexpected bind response packet type {bind.PacketType}.");
|
||||
}
|
||||
|
||||
byte[] request = ObjectExporterMessages.EncodeResolveOxidRequest(
|
||||
oxid,
|
||||
requestedProtseqs ?? [ObjectExporterMessages.ProtseqNcacnIpTcp]);
|
||||
|
||||
return client.CallBound(ObjectExporterMessages.ResolveOxidOpnum, request);
|
||||
}
|
||||
|
||||
public DceRpcResponsePdu ResolveOxidWithNtlmPacketIntegrity(ulong oxid, IReadOnlyList<ushort>? requestedProtseqs = null)
|
||||
{
|
||||
using var client = new DceRpcTcpClient(_host, _port);
|
||||
client.Connect();
|
||||
var bind = client.BindWithNtlmPacketIntegrity(ObjectExporterMessages.IObjectExporter, versionMajor: 0, versionMinor: 0, targetName: _host);
|
||||
if (bind.PacketType != DceRpcPacketType.BindAck)
|
||||
{
|
||||
throw new InvalidOperationException($"Unexpected bind response packet type {bind.PacketType}.");
|
||||
}
|
||||
|
||||
byte[] request = ObjectExporterMessages.EncodeResolveOxidRequest(
|
||||
oxid,
|
||||
requestedProtseqs ?? [ObjectExporterMessages.ProtseqNcacnIpTcp]);
|
||||
|
||||
return client.CallBound(ObjectExporterMessages.ResolveOxidOpnum, request);
|
||||
}
|
||||
|
||||
public DceRpcResponsePdu ResolveOxidWithManagedNtlmPacketIntegrity(ulong oxid, IReadOnlyList<ushort>? requestedProtseqs = null)
|
||||
{
|
||||
using var client = new DceRpcTcpClient(_host, _port);
|
||||
client.Connect();
|
||||
var bind = client.BindWithManagedNtlmPacketIntegrity(ObjectExporterMessages.IObjectExporter, versionMajor: 0, versionMinor: 0);
|
||||
if (bind.PacketType != DceRpcPacketType.BindAck)
|
||||
{
|
||||
throw new InvalidOperationException($"Unexpected bind response packet type {bind.PacketType}.");
|
||||
}
|
||||
|
||||
byte[] request = ObjectExporterMessages.EncodeResolveOxidRequest(
|
||||
oxid,
|
||||
requestedProtseqs ?? [ObjectExporterMessages.ProtseqNcacnIpTcp]);
|
||||
|
||||
return client.CallBound(ObjectExporterMessages.ResolveOxidOpnum, request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
using System.Buffers.Binary;
|
||||
|
||||
namespace MxNativeClient;
|
||||
|
||||
public static class ObjectExporterMessages
|
||||
{
|
||||
public static readonly Guid IObjectExporter = new("99FCFEC4-5260-101B-BBCB-00AA0021347A");
|
||||
public const ushort ResolveOxidOpnum = 0;
|
||||
public const ushort SimplePingOpnum = 1;
|
||||
public const ushort ComplexPingOpnum = 2;
|
||||
public const ushort ServerAliveOpnum = 3;
|
||||
public const ushort ResolveOxid2Opnum = 4;
|
||||
public const ushort ServerAlive2Opnum = 5;
|
||||
|
||||
public const ushort ProtseqNcacnIpTcp = 0x0007;
|
||||
public const ushort ProtseqNcalRpc = 0x001f;
|
||||
|
||||
public static byte[] EncodeResolveOxidRequest(ulong oxid, IReadOnlyList<ushort> requestedProtseqs)
|
||||
{
|
||||
if (requestedProtseqs.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("At least one protocol sequence is required.", nameof(requestedProtseqs));
|
||||
}
|
||||
|
||||
int length = 8 + 2 + 2 + 4 + requestedProtseqs.Count * sizeof(ushort);
|
||||
length = Align(length, 4);
|
||||
byte[] buffer = new byte[length];
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(buffer.AsSpan(0, 8), oxid);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(8, 2), (ushort)requestedProtseqs.Count);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(12, 4), (uint)requestedProtseqs.Count);
|
||||
for (int i = 0; i < requestedProtseqs.Count; i++)
|
||||
{
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(16 + i * sizeof(ushort), sizeof(ushort)), requestedProtseqs[i]);
|
||||
}
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
public static ResolveOxidFailure ParseResolveOxidFailure(ReadOnlySpan<byte> responseStub)
|
||||
{
|
||||
if (responseStub.Length < 4)
|
||||
{
|
||||
throw new ArgumentException("ResolveOxid response stub is too short.", nameof(responseStub));
|
||||
}
|
||||
|
||||
return new ResolveOxidFailure(BinaryPrimitives.ReadUInt32LittleEndian(responseStub[^4..]));
|
||||
}
|
||||
|
||||
public static ResolveOxidResult ParseResolveOxidResult(ReadOnlySpan<byte> responseStub)
|
||||
{
|
||||
if (responseStub.Length < 32)
|
||||
{
|
||||
throw new ArgumentException("ResolveOxid response stub is too short.", nameof(responseStub));
|
||||
}
|
||||
|
||||
uint referentId = BinaryPrimitives.ReadUInt32LittleEndian(responseStub[0..4]);
|
||||
if (referentId == 0)
|
||||
{
|
||||
uint nullStatus = BinaryPrimitives.ReadUInt32LittleEndian(responseStub[^4..]);
|
||||
return new ResolveOxidResult([], Guid.Empty, 0, nullStatus);
|
||||
}
|
||||
|
||||
uint maxCount = BinaryPrimitives.ReadUInt32LittleEndian(responseStub[4..8]);
|
||||
ushort entries = BinaryPrimitives.ReadUInt16LittleEndian(responseStub[8..10]);
|
||||
ushort securityOffset = BinaryPrimitives.ReadUInt16LittleEndian(responseStub[10..12]);
|
||||
if (maxCount < entries)
|
||||
{
|
||||
throw new ArgumentException("ResolveOxid DUALSTRINGARRAY max count is smaller than entry count.", nameof(responseStub));
|
||||
}
|
||||
|
||||
int arrayOffset = 12;
|
||||
int arrayBytes = checked((int)maxCount * sizeof(ushort));
|
||||
if (arrayOffset + arrayBytes > responseStub.Length)
|
||||
{
|
||||
throw new ArgumentException("ResolveOxid DUALSTRINGARRAY is truncated.", nameof(responseStub));
|
||||
}
|
||||
|
||||
var decoded = DecodeDualStringArray(responseStub.Slice(arrayOffset, entries * sizeof(ushort)), entries, securityOffset);
|
||||
int offset = Align(arrayOffset + arrayBytes, 4);
|
||||
if (offset + 24 > responseStub.Length)
|
||||
{
|
||||
throw new ArgumentException("ResolveOxid trailing fields are truncated.", nameof(responseStub));
|
||||
}
|
||||
|
||||
return new ResolveOxidResult(
|
||||
decoded,
|
||||
new Guid(responseStub.Slice(offset, 16)),
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(responseStub.Slice(offset + 16, 4)),
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(responseStub.Slice(offset + 20, 4)));
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ComDualStringEntry> DecodeDualStringArray(ReadOnlySpan<byte> data, ushort entries, ushort securityOffset)
|
||||
{
|
||||
var strings = new List<ComDualStringEntry>();
|
||||
for (int i = 0; i < entries;)
|
||||
{
|
||||
int entryStart = i;
|
||||
ushort towerId = BinaryPrimitives.ReadUInt16LittleEndian(data.Slice(i * 2, 2));
|
||||
i++;
|
||||
if (towerId == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var text = new List<char>();
|
||||
while (i < entries)
|
||||
{
|
||||
ushort value = BinaryPrimitives.ReadUInt16LittleEndian(data.Slice(i * 2, 2));
|
||||
i++;
|
||||
if (value == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
text.Add(value >= 0x20 && value <= 0x7e ? (char)value : '?');
|
||||
}
|
||||
|
||||
strings.Add(new ComDualStringEntry(
|
||||
towerId,
|
||||
towerId == ProtseqNcacnIpTcp ? "ncacn_ip_tcp" : $"protseq_0x{towerId:x4}",
|
||||
new string(text.ToArray()),
|
||||
IsSecurityBinding: entryStart >= securityOffset));
|
||||
}
|
||||
|
||||
return strings;
|
||||
}
|
||||
|
||||
private static int Align(int value, int alignment)
|
||||
{
|
||||
int remainder = value % alignment;
|
||||
return remainder == 0 ? value : value + alignment - remainder;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record ResolveOxidFailure(uint ErrorStatus);
|
||||
|
||||
public sealed record ResolveOxidResult(
|
||||
IReadOnlyList<ComDualStringEntry> Bindings,
|
||||
Guid RemUnknownIpid,
|
||||
uint AuthnHint,
|
||||
uint ErrorStatus);
|
||||
@@ -0,0 +1,140 @@
|
||||
using System.Buffers.Binary;
|
||||
|
||||
namespace MxNativeClient;
|
||||
|
||||
public readonly record struct ComVersion(ushort Major, ushort Minor)
|
||||
{
|
||||
public static ComVersion Version57 { get; } = new(5, 7);
|
||||
}
|
||||
|
||||
public sealed record OrpcThis(
|
||||
ComVersion Version,
|
||||
uint Flags,
|
||||
uint Reserved1,
|
||||
Guid Cid,
|
||||
uint ExtensionsReferentId)
|
||||
{
|
||||
public const int EncodedLengthWithoutExtensions = 32;
|
||||
|
||||
public static OrpcThis Create(Guid cid, ComVersion? version = null)
|
||||
{
|
||||
return new OrpcThis(version ?? ComVersion.Version57, 0, 0, cid, 0);
|
||||
}
|
||||
|
||||
public static OrpcThis Parse(ReadOnlySpan<byte> buffer)
|
||||
{
|
||||
if (buffer.Length < EncodedLengthWithoutExtensions)
|
||||
{
|
||||
throw new ArgumentException("ORPCTHIS buffer is too short.", nameof(buffer));
|
||||
}
|
||||
|
||||
return new OrpcThis(
|
||||
Version: new ComVersion(
|
||||
BinaryPrimitives.ReadUInt16LittleEndian(buffer[0..2]),
|
||||
BinaryPrimitives.ReadUInt16LittleEndian(buffer[2..4])),
|
||||
Flags: BinaryPrimitives.ReadUInt32LittleEndian(buffer[4..8]),
|
||||
Reserved1: BinaryPrimitives.ReadUInt32LittleEndian(buffer[8..12]),
|
||||
Cid: new Guid(buffer.Slice(12, 16)),
|
||||
ExtensionsReferentId: BinaryPrimitives.ReadUInt32LittleEndian(buffer[28..32]));
|
||||
}
|
||||
|
||||
public byte[] Encode()
|
||||
{
|
||||
byte[] buffer = new byte[EncodedLengthWithoutExtensions];
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(0, 2), Version.Major);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(2, 2), Version.Minor);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(4, 4), Flags);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(8, 4), Reserved1);
|
||||
Cid.TryWriteBytes(buffer.AsSpan(12, 16));
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(28, 4), ExtensionsReferentId);
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record OrpcThat(uint Flags, uint ExtensionsReferentId)
|
||||
{
|
||||
public const int EncodedLengthWithoutExtensions = 8;
|
||||
|
||||
public static OrpcThat Parse(ReadOnlySpan<byte> buffer)
|
||||
{
|
||||
if (buffer.Length < EncodedLengthWithoutExtensions)
|
||||
{
|
||||
throw new ArgumentException("ORPCTHAT buffer is too short.", nameof(buffer));
|
||||
}
|
||||
|
||||
return new OrpcThat(
|
||||
Flags: BinaryPrimitives.ReadUInt32LittleEndian(buffer[0..4]),
|
||||
ExtensionsReferentId: BinaryPrimitives.ReadUInt32LittleEndian(buffer[4..8]));
|
||||
}
|
||||
|
||||
public byte[] Encode()
|
||||
{
|
||||
byte[] buffer = new byte[EncodedLengthWithoutExtensions];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(0, 4), Flags);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(4, 4), ExtensionsReferentId);
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record MInterfacePointer(byte[] ObjRefBytes)
|
||||
{
|
||||
public byte[] Encode()
|
||||
{
|
||||
byte[] buffer = new byte[sizeof(uint) + ObjRefBytes.Length];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(0, sizeof(uint)), (uint)ObjRefBytes.Length);
|
||||
ObjRefBytes.CopyTo(buffer.AsSpan(sizeof(uint)));
|
||||
return buffer;
|
||||
}
|
||||
|
||||
public static MInterfacePointer Parse(ReadOnlySpan<byte> buffer)
|
||||
{
|
||||
if (buffer.Length < sizeof(uint))
|
||||
{
|
||||
throw new ArgumentException("MInterfacePointer buffer is too short.", nameof(buffer));
|
||||
}
|
||||
|
||||
uint size = BinaryPrimitives.ReadUInt32LittleEndian(buffer[..sizeof(uint)]);
|
||||
if (size > buffer.Length - sizeof(uint))
|
||||
{
|
||||
throw new ArgumentException("MInterfacePointer OBJREF payload is truncated.", nameof(buffer));
|
||||
}
|
||||
|
||||
return new MInterfacePointer(buffer.Slice(sizeof(uint), (int)size).ToArray());
|
||||
}
|
||||
|
||||
public ComObjRef ParseObjRef()
|
||||
{
|
||||
return ComObjRef.Parse(ObjRefBytes);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record StdObjRef(uint Flags, uint PublicRefs, ulong Oxid, ulong Oid, Guid Ipid)
|
||||
{
|
||||
public const int EncodedLength = 40;
|
||||
|
||||
public static StdObjRef Parse(ReadOnlySpan<byte> buffer)
|
||||
{
|
||||
if (buffer.Length < EncodedLength)
|
||||
{
|
||||
throw new ArgumentException("STDOBJREF buffer is too short.", nameof(buffer));
|
||||
}
|
||||
|
||||
return new StdObjRef(
|
||||
Flags: BinaryPrimitives.ReadUInt32LittleEndian(buffer[0..4]),
|
||||
PublicRefs: BinaryPrimitives.ReadUInt32LittleEndian(buffer[4..8]),
|
||||
Oxid: BinaryPrimitives.ReadUInt64LittleEndian(buffer[8..16]),
|
||||
Oid: BinaryPrimitives.ReadUInt64LittleEndian(buffer[16..24]),
|
||||
Ipid: new Guid(buffer.Slice(24, 16)));
|
||||
}
|
||||
|
||||
public byte[] Encode()
|
||||
{
|
||||
byte[] buffer = new byte[EncodedLength];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(0, 4), Flags);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(4, 4), PublicRefs);
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(buffer.AsSpan(8, 8), Oxid);
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(buffer.AsSpan(16, 8), Oid);
|
||||
Ipid.TryWriteBytes(buffer.AsSpan(24, 16));
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("MxNativeClient.Tests")]
|
||||
@@ -0,0 +1,79 @@
|
||||
using System.Buffers.Binary;
|
||||
|
||||
namespace MxNativeClient;
|
||||
|
||||
public static class RemUnknownMessages
|
||||
{
|
||||
public static readonly Guid IRemUnknown = new("00000131-0000-0000-C000-000000000046");
|
||||
public const ushort RemQueryInterfaceOpnum = 3;
|
||||
public const ushort RemAddRefOpnum = 4;
|
||||
public const ushort RemReleaseOpnum = 5;
|
||||
|
||||
public static byte[] EncodeRemQueryInterfaceRequest(Guid sourceIpid, Guid requestedIid, Guid causalityId, uint publicRefs = 5)
|
||||
{
|
||||
var orpcThis = OrpcThis.Create(causalityId).Encode();
|
||||
byte[] body = new byte[orpcThis.Length + 16 + 4 + 4 + 4 + 16];
|
||||
int offset = 0;
|
||||
orpcThis.CopyTo(body.AsSpan(offset));
|
||||
offset += orpcThis.Length;
|
||||
|
||||
sourceIpid.TryWriteBytes(body.AsSpan(offset, 16));
|
||||
offset += 16;
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(offset, 4), publicRefs);
|
||||
offset += 4;
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(offset, 2), 1);
|
||||
offset += 2;
|
||||
body[offset++] = 0xce;
|
||||
body[offset++] = 0xce; // NDR alignment before the conformant IID array max count.
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(offset, 4), 1);
|
||||
offset += 4;
|
||||
requestedIid.TryWriteBytes(body.AsSpan(offset, 16));
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
public static RemQueryInterfaceResponse ParseRemQueryInterfaceResponse(ReadOnlySpan<byte> buffer)
|
||||
{
|
||||
if (buffer.Length < OrpcThat.EncodedLengthWithoutExtensions + 4 + RemQiResult.EncodedLength + 4)
|
||||
{
|
||||
throw new ArgumentException("RemQueryInterface response is too short.", nameof(buffer));
|
||||
}
|
||||
|
||||
var orpcThat = OrpcThat.Parse(buffer);
|
||||
uint referentId = BinaryPrimitives.ReadUInt32LittleEndian(buffer.Slice(OrpcThat.EncodedLengthWithoutExtensions, 4));
|
||||
int offset = OrpcThat.EncodedLengthWithoutExtensions + 4;
|
||||
RemQiResult? result = null;
|
||||
if (referentId != 0)
|
||||
{
|
||||
offset += 4; // Conformant array max count for the REMQIRESULT result array.
|
||||
result = RemQiResult.Parse(buffer[offset..]);
|
||||
}
|
||||
|
||||
if (result is not null)
|
||||
{
|
||||
offset += RemQiResult.EncodedLength;
|
||||
}
|
||||
|
||||
uint errorCode = BinaryPrimitives.ReadUInt32LittleEndian(buffer.Slice(offset, 4));
|
||||
return new RemQueryInterfaceResponse(orpcThat, result, errorCode);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record RemQueryInterfaceResponse(OrpcThat OrpcThat, RemQiResult? Result, uint ErrorCode);
|
||||
|
||||
public sealed record RemQiResult(int HResult, StdObjRef StandardObjectReference)
|
||||
{
|
||||
public const int EncodedLength = sizeof(int) + sizeof(int) + StdObjRef.EncodedLength;
|
||||
|
||||
public static RemQiResult Parse(ReadOnlySpan<byte> buffer)
|
||||
{
|
||||
if (buffer.Length < EncodedLength)
|
||||
{
|
||||
throw new ArgumentException("REMQIRESULT buffer is too short.", nameof(buffer));
|
||||
}
|
||||
|
||||
return new RemQiResult(
|
||||
HResult: BinaryPrimitives.ReadInt32LittleEndian(buffer[..4]),
|
||||
StandardObjectReference: StdObjRef.Parse(buffer[8..]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MxNativeCodec\MxNativeCodec.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,860 @@
|
||||
using System.Buffers.Binary;
|
||||
using MxNativeCodec;
|
||||
|
||||
RunRoundTrip("int", MxValueKind.Int32, 109,
|
||||
"37 01 00 05 00 36 d7 02 00 9b 00 0a 00 3e da 00 00 02 6d 00 00 00 ff ff 00 00 00 00 00 00 00 00 c9 14 b1 08 01 00 00 00");
|
||||
|
||||
RunRoundTrip("bool", MxValueKind.Boolean, true,
|
||||
"37 01 00 05 00 36 d7 02 00 9a 00 0a 00 fa 7d 00 00 01 ff ff ff 00 00 00 00 00 00 00 00 e1 d8 b5 08 01 00 00 00");
|
||||
|
||||
RunRoundTrip("float", MxValueKind.Float32, 1.25f,
|
||||
"37 01 00 05 00 36 d7 02 00 9c 00 0a 00 a6 ed 00 00 03 00 00 a0 3f ff ff 00 00 00 00 00 00 00 00 64 6f b6 08 01 00 00 00");
|
||||
|
||||
RunRoundTrip("double", MxValueKind.Float64, 1.125d,
|
||||
"37 01 00 05 00 36 d7 02 00 9d 00 0a 00 1e 95 00 00 04 00 00 00 00 00 00 f2 3f ff ff 00 00 00 00 00 00 00 00 bf 04 b7 08 01 00 00 00");
|
||||
|
||||
RunRoundTrip("string", MxValueKind.String, "AlphaMX",
|
||||
"37 01 00 05 00 36 d7 02 00 9e 00 0a 00 1a 94 00 00 05 14 00 00 00 10 00 00 00 41 00 6c 00 70 00 68 00 61 00 4d 00 58 00 00 00 ff ff 00 00 00 00 00 00 00 00 3d a1 b7 08 01 00 00 00");
|
||||
|
||||
RunRoundTrip("elapsed int caller", MxValueKind.Int32, 1000,
|
||||
"37 01 00 06 00 08 f4 7e 00 7d 00 0a 00 ac 59 00 00 02 e8 03 00 00 ff ff 00 00 00 00 00 00 00 00 e0 bf fe 0b 01 00 00 00");
|
||||
|
||||
RunRoundTrip("internationalized string caller", MxValueKind.String, "hello-native",
|
||||
"37 01 00 05 00 36 d7 02 00 65 00 0a 00 6d 02 00 00 05 1e 00 00 00 1a 00 00 00 68 00 65 00 6c 00 6c 00 6f 00 2d 00 6e 00 61 00 74 00 69 00 76 00 65 00 00 00 ff ff 00 00 00 00 00 00 00 00 94 68 ff 0b 01 00 00 00");
|
||||
|
||||
RunRoundTrip("datetime", MxValueKind.DateTime, new DateTime(2026, 4, 25, 2, 30, 0),
|
||||
"37 01 00 05 00 36 d7 02 00 9f 00 0a 00 62 49 00 00 05 2e 00 00 00 2a 00 00 00 34 00 2f 00 32 00 35 00 2f 00 32 00 30 00 32 00 36 00 20 00 32 00 3a 00 33 00 30 00 3a 00 30 00 30 00 20 00 41 00 4d 00 00 00 ff ff 00 00 00 00 00 00 00 00 58 94 b8 08 01 00 00 00");
|
||||
|
||||
RunRoundTrip("write2 int timestamp", MxValueKind.Int32, 114,
|
||||
"37 01 00 05 00 36 d7 02 00 9b 00 0a 00 3e da 00 00 02 72 00 00 00 00 00 00 72 68 3a 83 d4 dc 01 20 2e d8 08 01 00 00 00",
|
||||
timestamp: new DateTime(2026, 4, 25, 7, 15, 0, DateTimeKind.Utc));
|
||||
|
||||
RunTimestampedGenerated("write2 bool timestamp", MxValueKind.Boolean, false,
|
||||
"37 01 00 05 00 36 d7 02 00 9a 00 0a 00 fa 7d 00 00 01 00 00 00 00 d3 c1 2f ab d4 dc 01 c9 dd a5 0b 01 00 00 00",
|
||||
timestamp: new DateTime(2026, 4, 25, 8, 1, 2));
|
||||
|
||||
RunRoundTrip("write2 float timestamp", MxValueKind.Float32, 6.25f,
|
||||
"37 01 00 05 00 36 d7 02 00 9c 00 0a 00 a6 ed 00 00 03 00 00 c8 40 00 00 80 af 1d 54 ab d4 dc 01 94 c4 a5 0b 01 00 00 00",
|
||||
timestamp: new DateTime(2026, 4, 25, 8, 2, 3));
|
||||
|
||||
RunRoundTrip("write2 double timestamp", MxValueKind.Float64, 7.125d,
|
||||
"37 01 00 05 00 36 d7 02 00 9d 00 0a 00 1e 95 00 00 04 00 00 00 00 00 80 1c 40 00 00 00 8c 79 78 ab d4 dc 01 a3 56 a6 0b 01 00 00 00",
|
||||
timestamp: new DateTime(2026, 4, 25, 8, 3, 4));
|
||||
|
||||
RunRoundTrip("write2 string timestamp", MxValueKind.String, "Write2Alpha",
|
||||
"37 01 00 05 00 36 d7 02 00 9e 00 0a 00 1a 94 00 00 05 1c 00 00 00 18 00 00 00 57 00 72 00 69 00 74 00 65 00 32 00 41 00 6c 00 70 00 68 00 61 00 00 00 00 00 80 68 d5 9c ab d4 dc 01 9f 6e a6 0b 01 00 00 00",
|
||||
timestamp: new DateTime(2026, 4, 25, 8, 4, 5));
|
||||
|
||||
RunRoundTrip("int[]", MxValueKind.Int32Array, new[] { 201, 202, 203, 204, 205, 206, 207, 208, 209, 210 },
|
||||
"37 01 00 05 00 36 d7 02 00 a4 00 0a 00 60 57 ff ff 42 00 00 00 00 0a 00 04 00 00 00 c9 00 00 00 ca 00 00 00 cb 00 00 00 cc 00 00 00 cd 00 00 00 ce 00 00 00 cf 00 00 00 d0 00 00 00 d1 00 00 00 d2 00 00 00 ff ff 00 00 00 00 00 00 00 00 4d c3 c4 08 01 00 00 00");
|
||||
|
||||
RunRoundTrip("bool[]", MxValueKind.BooleanArray, new[] { true, true, false, false, true, true, false, false, true, true },
|
||||
"37 01 00 05 00 36 d7 02 00 a0 00 0a 00 fa 89 ff ff 41 00 00 00 00 0a 00 02 00 00 00 ff ff ff ff 00 00 00 00 ff ff ff ff 00 00 00 00 ff ff ff ff ff ff 00 00 00 00 00 00 00 00 a8 7f c5 08 01 00 00 00");
|
||||
|
||||
RunRoundTrip("bool[] mxaccess safearray projection", MxValueKind.BooleanArray, new[] { true, true, false, false, false, false, true, true, true, true },
|
||||
"37 01 00 05 00 36 d7 02 00 a0 00 0a 00 fa 89 ff ff 41 00 00 00 00 0a 00 02 00 00 00 ff ff ff ff 00 00 00 00 00 00 00 00 ff ff ff ff ff ff ff ff ff ff 00 00 00 00 00 00 00 00 ac ec 08 0c 01 00 00 00");
|
||||
|
||||
RunRoundTrip("float[]", MxValueKind.Float32Array, new[] { 1.25f, 2.5f, 3.75f, 4.25f, 5.5f, 6.75f, 7.25f, 8.5f, 9.75f, 10.25f },
|
||||
"37 01 00 05 00 36 d7 02 00 a3 00 0a 00 95 f1 ff ff 43 00 00 00 00 0a 00 04 00 00 00 00 00 a0 3f 00 00 20 40 00 00 70 40 00 00 88 40 00 00 b0 40 00 00 d8 40 00 00 e8 40 00 00 08 41 00 00 1c 41 00 00 24 41 ff ff 00 00 00 00 00 00 00 00 0c f4 c5 08 01 00 00 00");
|
||||
|
||||
RunRoundTrip("double[]", MxValueKind.Float64Array, new[] { 1.125d, 2.25d, 3.5d, 4.625d, 5.75d, 6.875d, 7.0d, 8.125d, 9.25d, 10.375d },
|
||||
"37 01 00 05 00 36 d7 02 00 a2 00 0a 00 11 0e ff ff 44 00 00 00 00 0a 00 08 00 00 00 00 00 00 00 00 00 f2 3f 00 00 00 00 00 00 02 40 00 00 00 00 00 00 0c 40 00 00 00 00 00 80 12 40 00 00 00 00 00 00 17 40 00 00 00 00 00 80 1b 40 00 00 00 00 00 00 1c 40 00 00 00 00 00 40 20 40 00 00 00 00 00 80 22 40 00 00 00 00 00 c0 24 40 ff ff 00 00 00 00 00 00 00 00 cf 68 c6 08 01 00 00 00");
|
||||
|
||||
RunRoundTrip("string[]", MxValueKind.StringArray, new[] { "A01", "B02", "C03", "D04", "E05", "F06", "G07", "H08", "I09", "J10" },
|
||||
"37 01 00 05 00 36 d7 02 00 a5 00 0a 00 5d 4b ff ff 45 00 00 00 00 0a 00 04 00 00 00 11 00 00 00 05 0c 00 00 00 08 00 00 00 41 00 30 00 31 00 00 00 11 00 00 00 05 0c 00 00 00 08 00 00 00 42 00 30 00 32 00 00 00 11 00 00 00 05 0c 00 00 00 08 00 00 00 43 00 30 00 33 00 00 00 11 00 00 00 05 0c 00 00 00 08 00 00 00 44 00 30 00 34 00 00 00 11 00 00 00 05 0c 00 00 00 08 00 00 00 45 00 30 00 35 00 00 00 11 00 00 00 05 0c 00 00 00 08 00 00 00 46 00 30 00 36 00 00 00 11 00 00 00 05 0c 00 00 00 08 00 00 00 47 00 30 00 37 00 00 00 11 00 00 00 05 0c 00 00 00 08 00 00 00 48 00 30 00 38 00 00 00 11 00 00 00 05 0c 00 00 00 08 00 00 00 49 00 30 00 39 00 00 00 11 00 00 00 05 0c 00 00 00 08 00 00 00 4a 00 31 00 30 00 00 00 ff ff 00 00 00 00 00 00 00 00 c3 da c6 08 01 00 00 00");
|
||||
|
||||
RunRoundTrip("write2 int[] timestamp", MxValueKind.Int32Array, new[] { 301, 302, 303, 304, 305, 306, 307, 308, 309, 310 },
|
||||
"37 01 00 05 00 36 d7 02 00 a4 00 0a 00 60 57 ff ff 42 00 00 00 00 0a 00 04 00 00 00 2d 01 00 00 2e 01 00 00 2f 01 00 00 30 01 00 00 31 01 00 00 32 01 00 00 33 01 00 00 34 01 00 00 35 01 00 00 36 01 00 00 00 00 80 93 fc 76 ac d4 dc 01 43 35 ac 0b 01 00 00 00",
|
||||
timestamp: new DateTime(2026, 4, 25, 8, 10, 11));
|
||||
|
||||
RunRoundTrip("write2 bool[] timestamp", MxValueKind.BooleanArray, new[] { true, true, false, false, true, true, false, false, true, true },
|
||||
"37 01 00 05 00 36 d7 02 00 a0 00 0a 00 fa 89 ff ff 41 00 00 00 00 0a 00 02 00 00 00 ff ff ff ff 00 00 00 00 ff ff ff ff 00 00 00 00 ff ff ff ff 00 00 00 70 58 9b ac d4 dc 01 8b 1c ac 0b 01 00 00 00",
|
||||
timestamp: new DateTime(2026, 4, 25, 8, 11, 12));
|
||||
|
||||
RunRoundTrip("write2 string[] timestamp", MxValueKind.StringArray, new[] { "AA1", "BB2", "CC3", "DD4", "EE5", "FF6", "GG7", "HH8", "II9", "JJ10" },
|
||||
"37 01 00 05 00 36 d7 02 00 a5 00 0a 00 5d 4b ff ff 45 00 00 00 00 0a 00 04 00 00 00 11 00 00 00 05 0c 00 00 00 08 00 00 00 41 00 41 00 31 00 00 00 11 00 00 00 05 0c 00 00 00 08 00 00 00 42 00 42 00 32 00 00 00 11 00 00 00 05 0c 00 00 00 08 00 00 00 43 00 43 00 33 00 00 00 11 00 00 00 05 0c 00 00 00 08 00 00 00 44 00 44 00 34 00 00 00 11 00 00 00 05 0c 00 00 00 08 00 00 00 45 00 45 00 35 00 00 00 11 00 00 00 05 0c 00 00 00 08 00 00 00 46 00 46 00 36 00 00 00 11 00 00 00 05 0c 00 00 00 08 00 00 00 47 00 47 00 37 00 00 00 11 00 00 00 05 0c 00 00 00 08 00 00 00 48 00 48 00 38 00 00 00 11 00 00 00 05 0c 00 00 00 08 00 00 00 49 00 49 00 39 00 00 00 13 00 00 00 05 0e 00 00 00 0a 00 00 00 4a 00 4a 00 31 00 30 00 00 00 00 00 80 4c b4 bf ac d4 dc 01 10 a3 ac 0b 01 00 00 00",
|
||||
timestamp: new DateTime(2026, 4, 25, 8, 12, 13));
|
||||
|
||||
RunRoundTrip("write2 float[] timestamp", MxValueKind.Float32Array, new[] { 1.5f, 2.5f, 3.5f, 4.5f, 5.5f, 6.5f, 7.5f, 8.5f, 9.5f, 10.5f },
|
||||
"37 01 00 05 00 36 d7 02 00 a3 00 0a 00 95 f1 ff ff 43 00 00 00 00 0a 00 04 00 00 00 00 00 c0 3f 00 00 20 40 00 00 60 40 00 00 90 40 00 00 b0 40 00 00 d0 40 00 00 f0 40 00 00 08 41 00 00 18 41 00 00 28 41 00 00 00 29 10 e4 ac d4 dc 01 fa 6a af 0b 01 00 00 00",
|
||||
timestamp: new DateTime(2026, 4, 25, 8, 13, 14));
|
||||
|
||||
RunRoundTrip("write2 double[] timestamp", MxValueKind.Float64Array, new[] { 1.5d, 2.5d, 3.5d, 4.5d, 5.5d, 6.5d, 7.5d, 8.5d, 9.5d, 10.5d },
|
||||
"37 01 00 05 00 36 d7 02 00 a2 00 0a 00 11 0e ff ff 44 00 00 00 00 0a 00 08 00 00 00 00 00 00 00 00 00 f8 3f 00 00 00 00 00 00 04 40 00 00 00 00 00 00 0c 40 00 00 00 00 00 00 12 40 00 00 00 00 00 00 16 40 00 00 00 00 00 00 1a 40 00 00 00 00 00 00 1e 40 00 00 00 00 00 00 21 40 00 00 00 00 00 00 23 40 00 00 00 00 00 00 25 40 00 00 80 05 6c 08 ad d4 dc 01 42 52 af 0b 01 00 00 00",
|
||||
timestamp: new DateTime(2026, 4, 25, 8, 14, 15));
|
||||
|
||||
RunRoundTrip("write2 datetime[] timestamp", MxValueKind.DateTimeArray, new[]
|
||||
{
|
||||
new DateTime(2026, 4, 25, 9, 0, 0),
|
||||
new DateTime(2026, 4, 25, 9, 1, 0),
|
||||
new DateTime(2026, 4, 25, 9, 2, 0),
|
||||
new DateTime(2026, 4, 25, 9, 3, 0),
|
||||
new DateTime(2026, 4, 25, 9, 4, 0),
|
||||
new DateTime(2026, 4, 25, 9, 5, 0),
|
||||
new DateTime(2026, 4, 25, 9, 6, 0),
|
||||
new DateTime(2026, 4, 25, 9, 7, 0),
|
||||
new DateTime(2026, 4, 25, 9, 8, 0),
|
||||
new DateTime(2026, 4, 25, 9, 9, 0),
|
||||
},
|
||||
"37 01 00 05 00 36 d7 02 00 a1 00 0a 00 1b df ff ff 45 00 00 00 00 0a 00 04 00 00 00 33 00 00 00 05 2e 00 00 00 2a 00 00 00 34 00 2f 00 32 00 35 00 2f 00 32 00 30 00 32 00 36 00 20 00 39 00 3a 00 30 00 30 00 3a 00 30 00 30 00 20 00 41 00 4d 00 00 00 33 00 00 00 05 2e 00 00 00 2a 00 00 00 34 00 2f 00 32 00 35 00 2f 00 32 00 30 00 32 00 36 00 20 00 39 00 3a 00 30 00 31 00 3a 00 30 00 30 00 20 00 41 00 4d 00 00 00 33 00 00 00 05 2e 00 00 00 2a 00 00 00 34 00 2f 00 32 00 35 00 2f 00 32 00 30 00 32 00 36 00 20 00 39 00 3a 00 30 00 32 00 3a 00 30 00 30 00 20 00 41 00 4d 00 00 00 33 00 00 00 05 2e 00 00 00 2a 00 00 00 34 00 2f 00 32 00 35 00 2f 00 32 00 30 00 32 00 36 00 20 00 39 00 3a 00 30 00 33 00 3a 00 30 00 30 00 20 00 41 00 4d 00 00 00 33 00 00 00 05 2e 00 00 00 2a 00 00 00 34 00 2f 00 32 00 35 00 2f 00 32 00 30 00 32 00 36 00 20 00 39 00 3a 00 30 00 34 00 3a 00 30 00 30 00 20 00 41 00 4d 00 00 00 33 00 00 00 05 2e 00 00 00 2a 00 00 00 34 00 2f 00 32 00 35 00 2f 00 32 00 30 00 32 00 36 00 20 00 39 00 3a 00 30 00 35 00 3a 00 30 00 30 00 20 00 41 00 4d 00 00 00 33 00 00 00 05 2e 00 00 00 2a 00 00 00 34 00 2f 00 32 00 35 00 2f 00 32 00 30 00 32 00 36 00 20 00 39 00 3a 00 30 00 36 00 3a 00 30 00 30 00 20 00 41 00 4d 00 00 00 33 00 00 00 05 2e 00 00 00 2a 00 00 00 34 00 2f 00 32 00 35 00 2f 00 32 00 30 00 32 00 36 00 20 00 39 00 3a 00 30 00 37 00 3a 00 30 00 30 00 20 00 41 00 4d 00 00 00 33 00 00 00 05 2e 00 00 00 2a 00 00 00 34 00 2f 00 32 00 35 00 2f 00 32 00 30 00 32 00 36 00 20 00 39 00 3a 00 30 00 38 00 3a 00 30 00 30 00 20 00 41 00 4d 00 00 00 33 00 00 00 05 2e 00 00 00 2a 00 00 00 34 00 2f 00 32 00 35 00 2f 00 32 00 30 00 32 00 36 00 20 00 39 00 3a 00 30 00 39 00 3a 00 30 00 30 00 20 00 41 00 4d 00 00 00 00 00 00 e2 c7 2c ad d4 dc 01 35 07 b1 0b 01 00 00 00",
|
||||
timestamp: new DateTime(2026, 4, 25, 8, 15, 16));
|
||||
|
||||
RunTransferEnvelopeRoundTrip(
|
||||
"int transfer envelope",
|
||||
"37 01 00 05 00 36 d7 02 00 9b 00 0a 00 3e da 00 00 02 17 cd 5b 07 ff ff 00 00 00 00 00 00 00 00 24 3c ed 08 01 00 00 00",
|
||||
"01 00 28 00 00 00 00 00 00 00 03 00 00 00 01 00 00 00 01 00 00 00 fb 7f 00 00 01 00 00 00 01 00 00 00 02 00 00 00 01 02 00 00 30 75 00 00 37 01 00 05 00 36 d7 02 00 9b 00 0a 00 3e da 00 00 02 17 cd 5b 07 ff ff 00 00 00 00 00 00 00 00 24 3c ed 08 01 00 00 00");
|
||||
|
||||
RunObservedFrameTests();
|
||||
RunItemControlMessageTests();
|
||||
RunMxReferenceHandleTests();
|
||||
RunGeneratedTransferEnvelopeTests();
|
||||
RunReferenceRegistrationMessageTests();
|
||||
RunProtectedWriteGenerationTests();
|
||||
RunSecuredWrite2GenerationTests();
|
||||
RunMxStatusDetailTests();
|
||||
RunMxDataTypeSupportTests();
|
||||
|
||||
Console.WriteLine("MxNativeCodec observed write-body round trips passed.");
|
||||
|
||||
static void RunRoundTrip(string name, MxValueKind kind, object value, string hex, DateTime? timestamp = null)
|
||||
{
|
||||
byte[] observed = FromHex(hex);
|
||||
var template = ObservedWriteBodyTemplate.FromObserved(kind, observed);
|
||||
|
||||
object decoded = template.Decode(observed);
|
||||
AssertValue(name, value, decoded);
|
||||
AssertEqual(name + " write index", 1, template.DecodeWriteIndex(observed));
|
||||
|
||||
byte[] encoded = template.Encode(value, writeIndex: 1);
|
||||
AssertBytes(name, observed, encoded);
|
||||
|
||||
if (timestamp.HasValue)
|
||||
{
|
||||
var handle = HandleFromObservedWriteBody(observed);
|
||||
uint clientToken = BinaryPrimitives.ReadUInt32LittleEndian(observed.AsSpan(observed.Length - 8, sizeof(uint)));
|
||||
byte[] generated = NmxWriteMessage.EncodeTimestamped(
|
||||
handle,
|
||||
kind,
|
||||
decoded,
|
||||
timestamp.Value,
|
||||
template.DecodeWriteIndex(observed),
|
||||
clientToken);
|
||||
AssertBytes(name + " generated", observed, generated);
|
||||
}
|
||||
else
|
||||
{
|
||||
var handle = HandleFromObservedWriteBody(observed);
|
||||
uint clientToken = BinaryPrimitives.ReadUInt32LittleEndian(observed.AsSpan(observed.Length - 8, sizeof(uint)));
|
||||
byte[] generated = NmxWriteMessage.Encode(
|
||||
handle,
|
||||
kind,
|
||||
decoded,
|
||||
template.DecodeWriteIndex(observed),
|
||||
clientToken);
|
||||
AssertBytes(name + " generated", observed, generated);
|
||||
}
|
||||
}
|
||||
|
||||
static void RunTimestampedGenerated(string name, MxValueKind kind, object value, string hex, DateTime timestamp)
|
||||
{
|
||||
byte[] observed = FromHex(hex);
|
||||
var handle = HandleFromObservedWriteBody(observed);
|
||||
uint clientToken = BinaryPrimitives.ReadUInt32LittleEndian(observed.AsSpan(observed.Length - 8, sizeof(uint)));
|
||||
int writeIndex = BinaryPrimitives.ReadInt32LittleEndian(observed.AsSpan(observed.Length - sizeof(int), sizeof(int)));
|
||||
byte[] generated = NmxWriteMessage.EncodeTimestamped(
|
||||
handle,
|
||||
kind,
|
||||
value,
|
||||
timestamp,
|
||||
writeIndex,
|
||||
clientToken);
|
||||
AssertBytes(name + " generated", observed, generated);
|
||||
}
|
||||
|
||||
static void RunTransferEnvelopeRoundTrip(string name, string innerHex, string transferHex)
|
||||
{
|
||||
byte[] inner = FromHex(innerHex);
|
||||
byte[] transfer = FromHex(transferHex);
|
||||
var template = NmxTransferEnvelopeTemplate.FromObserved(transfer);
|
||||
|
||||
AssertBytes(name + " decoded inner", inner, template.DecodeInner(transfer).Span);
|
||||
AssertBytes(name, transfer, template.Encode(inner));
|
||||
}
|
||||
|
||||
static void RunObservedFrameTests()
|
||||
{
|
||||
byte[] adviseTransfer = FromHex(
|
||||
"01 00 27 00 00 00 00 00 00 00 02 00 00 00 01 00 00 00 01 00 00 00 fb 7f 00 00 01 00 00 00 01 00 00 00 02 00 00 00 01 02 00 00 30 75 00 00 " +
|
||||
"1f 01 00 c0 ca 9c cd 32 65 b0 46 a5 85 a5 83 b2 e7 7a 5d 00 00 05 00 36 d7 02 00 9b 00 0a 00 3e da 00 00 03 00 00 00");
|
||||
var adviseEnvelope = NmxObservedEnvelope.ParseTransferDataBody(adviseTransfer);
|
||||
AssertEqual("advise declared inner", 39, adviseEnvelope.DeclaredInnerLength);
|
||||
AssertEqual("advise actual inner", 39, adviseEnvelope.ActualInnerLength);
|
||||
|
||||
var advise = NmxObservedMessage.Parse(adviseEnvelope.InnerBody.Span);
|
||||
AssertEqual("advise command", (byte)0x1f, advise.Command);
|
||||
AssertEqual("advise command name", "AdviseSupervisory", advise.CommandName);
|
||||
AssertEqual("advise version major", (byte)1, advise.VersionMajor);
|
||||
AssertEqual("advise correlation", new Guid("cd9ccac0-6532-46b0-a585-a583b2e77a5d"), advise.ItemCorrelationId);
|
||||
|
||||
byte[] statusReceived = FromHex(
|
||||
"6c 00 00 00 01 00 3e 00 00 00 00 00 00 00 5d 89 05 00 01 00 00 00 01 00 00 00 02 00 00 00 01 00 00 00 01 00 00 00 fb 7f 00 00 01 02 00 00 30 75 00 00 " +
|
||||
"32 01 00 01 00 00 00 db 4e 14 ba e9 25 85 47 81 e4 e0 e7 1a d2 b1 aa c0 ca 9c cd 32 65 b0 46 a5 85 a5 83 b2 e7 7a 5d 03 00 00 00 03 00 00 00 c0 00 60 9a 60 95 84 d4 dc 01 02");
|
||||
var statusEnvelope = NmxObservedEnvelope.ParseProcessDataReceivedBody(statusReceived);
|
||||
AssertEqual("status total prefix", 108, statusEnvelope.TotalLengthPrefix!.Value);
|
||||
AssertEqual("status declared inner", 62, statusEnvelope.DeclaredInnerLength);
|
||||
AssertEqual("status actual inner", 58, statusEnvelope.ActualInnerLength);
|
||||
|
||||
var status = NmxObservedMessage.Parse(statusEnvelope.InnerBody.Span);
|
||||
AssertEqual("status command", (byte)0x32, status.Command);
|
||||
AssertEqual("status command name", "SubscriptionStatus", status.CommandName);
|
||||
|
||||
var typedStatus = NmxSubscriptionMessage.ParseProcessDataReceivedBody(statusReceived);
|
||||
AssertEqual("typed status command", NmxSubscriptionMessage.SubscriptionStatusCommand, typedStatus.Command);
|
||||
AssertEqual("typed status count", 1, typedStatus.RecordCount);
|
||||
AssertEqual("typed status operation id", new Guid("ba144edb-25e9-4785-81e4-e0e71ad2b1aa"), typedStatus.OperationId);
|
||||
AssertEqual("typed status item correlation", new Guid("cd9ccac0-6532-46b0-a585-a583b2e77a5d"), typedStatus.ItemCorrelationId!.Value);
|
||||
AssertEqual("typed status record count", 1, typedStatus.Records.Count);
|
||||
AssertEqual("typed status status", 3, typedStatus.Records[0].Status);
|
||||
AssertEqual("typed status detail", 3, typedStatus.Records[0].DetailStatus!.Value);
|
||||
AssertEqual("typed status quality", (ushort)0x00c0, typedStatus.Records[0].Quality);
|
||||
AssertEqual("typed status wire kind", (byte)0x02, typedStatus.Records[0].WireKind);
|
||||
AssertEqual("typed status value", null, typedStatus.Records[0].Value);
|
||||
AssertEqual("typed status mx status", MxStatus.DataChangeOk, typedStatus.Records[0].ToDataChangeStatus());
|
||||
|
||||
byte[] dataUpdateReceived = FromHex(
|
||||
"01 00 2a 00 00 00 00 00 00 00 cd 86 04 00 01 00 00 00 01 00 00 00 02 00 00 00 01 00 00 00 01 00 00 00 fb 7f 00 00 01 02 00 00 30 75 00 00 " +
|
||||
"33 01 00 01 00 00 00 1c c8 68 ce e5 13 e8 48 89 87 21 91 a8 dc 42 93 03 00 00 00 c0 00 00 72 68 3a 83 d4 dc 01 02 72 00 00 00");
|
||||
var dataUpdateEnvelope = NmxObservedEnvelope.ParseProcessDataReceivedBodyFlexible(dataUpdateReceived);
|
||||
AssertEqual("data update has no length prefix", false, dataUpdateEnvelope.HasLengthPrefix);
|
||||
AssertEqual("data update declared inner", 42, dataUpdateEnvelope.DeclaredInnerLength);
|
||||
AssertEqual("data update actual inner", 42, dataUpdateEnvelope.ActualInnerLength);
|
||||
|
||||
var dataUpdate = NmxSubscriptionMessage.ParseProcessDataReceivedBody(dataUpdateReceived);
|
||||
AssertEqual("data update command", NmxSubscriptionMessage.DataUpdateCommand, dataUpdate.Command);
|
||||
AssertEqual("data update count", 1, dataUpdate.RecordCount);
|
||||
AssertEqual("data update operation id", new Guid("ce68c81c-13e5-48e8-8987-2191a8dc4293"), dataUpdate.OperationId);
|
||||
AssertEqual("data update item correlation", null, dataUpdate.ItemCorrelationId);
|
||||
AssertEqual("data update status", 3, dataUpdate.Records[0].Status);
|
||||
AssertEqual("data update detail", null, dataUpdate.Records[0].DetailStatus);
|
||||
AssertEqual("data update quality", (ushort)0x00c0, dataUpdate.Records[0].Quality);
|
||||
AssertEqual("data update timestamp", new DateTime(2026, 4, 25, 7, 15, 0, DateTimeKind.Utc), dataUpdate.Records[0].TimestampUtc);
|
||||
AssertEqual("data update wire kind", (byte)0x02, dataUpdate.Records[0].WireKind);
|
||||
AssertEqual("data update value", 114, dataUpdate.Records[0].Value!);
|
||||
AssertEqual("data update mx status", MxStatus.DataChangeOk, dataUpdate.Records[0].ToDataChangeStatus());
|
||||
|
||||
byte[] writeCompleteReceived = FromHex(
|
||||
"01 00 05 00 00 00 00 00 00 00 03 00 00 00 01 00 00 00 01 00 00 00 02 00 00 00 01 00 00 00 01 00 00 00 fb 7f 00 00 02 02 00 00 30 75 00 00 00 00 50 80 00");
|
||||
AssertEqual(
|
||||
"write complete operation parse",
|
||||
true,
|
||||
NmxOperationStatusMessage.TryParseProcessDataReceivedBody(writeCompleteReceived, out var writeComplete));
|
||||
AssertEqual("write complete format", NmxOperationStatusFormat.StatusWord, writeComplete.Format);
|
||||
AssertEqual("write complete command", (byte)0x00, writeComplete.Command);
|
||||
AssertEqual("write complete status code", (ushort)0x8050, writeComplete.StatusCode);
|
||||
AssertEqual("write complete completion code", (byte)0x00, writeComplete.CompletionCode);
|
||||
AssertEqual("write complete mx status", MxStatus.WriteCompleteOk, writeComplete.Status);
|
||||
AssertEqual("write complete mxaccess event", true, writeComplete.IsMxAccessWriteComplete);
|
||||
|
||||
byte[] writeWrongTypeStatusReceived = FromHex(
|
||||
"33 00 00 00 01 00 05 00 00 00 00 00 00 00 03 00 00 00 01 00 00 00 01 00 00 00 02 00 00 00 01 00 00 00 01 00 00 00 f9 7f 00 00 02 02 00 00 30 75 00 00 41");
|
||||
AssertEqual(
|
||||
"write wrong-type status parse",
|
||||
true,
|
||||
NmxOperationStatusMessage.TryParseProcessDataReceivedBody(writeWrongTypeStatusReceived, out var writeWrongTypeStatus));
|
||||
AssertEqual("write wrong-type format", NmxOperationStatusFormat.CompletionOnly, writeWrongTypeStatus.Format);
|
||||
AssertEqual("write wrong-type status code", (ushort)0, writeWrongTypeStatus.StatusCode);
|
||||
AssertEqual("write wrong-type completion code", (byte)0x41, writeWrongTypeStatus.CompletionCode);
|
||||
AssertEqual("write wrong-type mx status success", (short)0, writeWrongTypeStatus.Status.Success);
|
||||
AssertEqual("write wrong-type mx status detail", (short)0x41, writeWrongTypeStatus.Status.Detail);
|
||||
AssertEqual("write wrong-type mxaccess event", false, writeWrongTypeStatus.IsMxAccessWriteComplete);
|
||||
|
||||
byte[] writeCompletionOnlyOkReceived = FromHex(
|
||||
"33 00 00 00 01 00 05 00 00 00 00 00 00 00 03 00 00 00 01 00 00 00 01 00 00 00 02 00 00 00 01 00 00 00 01 00 00 00 f9 7f 00 00 02 02 00 00 30 75 00 00 00");
|
||||
AssertEqual(
|
||||
"write completion-only ok parse",
|
||||
true,
|
||||
NmxOperationStatusMessage.TryParseProcessDataReceivedBody(writeCompletionOnlyOkReceived, out var writeCompletionOnlyOk));
|
||||
AssertEqual("write completion-only ok format", NmxOperationStatusFormat.CompletionOnly, writeCompletionOnlyOk.Format);
|
||||
AssertEqual("write completion-only ok completion code", (byte)0x00, writeCompletionOnlyOk.CompletionCode);
|
||||
AssertEqual("write completion-only ok mxaccess event", false, writeCompletionOnlyOk.IsMxAccessWriteComplete);
|
||||
|
||||
byte[] statusWithValueReceived = FromHex(
|
||||
"01 00 3e 00 00 00 00 00 00 00 ca 86 04 00 01 00 00 00 01 00 00 00 02 00 00 00 01 00 00 00 01 00 00 00 fb 7f 00 00 01 02 00 00 30 75 00 00 " +
|
||||
"32 01 00 01 00 00 00 1c c8 68 ce e5 13 e8 48 89 87 21 91 a8 dc 42 93 b0 b8 80 11 ae ae f8 40 9a c9 8b 3c 9e 79 f4 a4 03 00 00 00 03 00 00 00 c0 00 90 06 82 68 7b d4 dc 01 02 6f 00 00 00");
|
||||
var statusWithValue = NmxSubscriptionMessage.ParseProcessDataReceivedBody(statusWithValueReceived);
|
||||
AssertEqual("status with value command", NmxSubscriptionMessage.SubscriptionStatusCommand, statusWithValue.Command);
|
||||
AssertEqual("status with value count", 1, statusWithValue.RecordCount);
|
||||
AssertEqual("status with value operation id", new Guid("ce68c81c-13e5-48e8-8987-2191a8dc4293"), statusWithValue.OperationId);
|
||||
AssertEqual("status with value item correlation", new Guid("1180b8b0-aeae-40f8-9ac9-8b3c9e79f4a4"), statusWithValue.ItemCorrelationId!.Value);
|
||||
AssertEqual("status with value status", 3, statusWithValue.Records[0].Status);
|
||||
AssertEqual("status with value detail", 3, statusWithValue.Records[0].DetailStatus!.Value);
|
||||
AssertEqual("status with value quality", (ushort)0x00c0, statusWithValue.Records[0].Quality);
|
||||
AssertEqual("status with value wire kind", (byte)0x02, statusWithValue.Records[0].WireKind);
|
||||
AssertEqual("status with value value", 111, statusWithValue.Records[0].Value!);
|
||||
|
||||
byte[] multiRecordStatusReceived = FromHex(
|
||||
"01 00 69 00 00 00 00 00 00 00 ef e4 08 00 01 00 00 00 01 00 00 00 01 00 00 00 01 00 00 00 01 00 00 00 fb 7f 00 00 01 02 00 00 30 75 00 00 " +
|
||||
"32 01 00 02 00 00 00 13 27 1c 4c c8 d3 95 40 b9 dd 17 80 70 23 4a 9d 42 95 8e 96 10 37 0c 4a b8 f8 79 bf ac 46 b8 e4 01 00 00 00 03 00 00 00 c0 00 20 2e 5a 46 28 d3 dc 01 06 0a 00 00 00 00 a0 41 c3 55 bd dc 01 00 00 02 00 00 00 03 00 00 00 c0 00 80 18 5b 46 28 d3 dc 01 06 0a 00 00 00 80 c1 75 25 a5 bd dc 01 00 00");
|
||||
var multiRecordStatus = NmxSubscriptionMessage.ParseProcessDataReceivedBody(multiRecordStatusReceived);
|
||||
AssertEqual("multi status count", 2, multiRecordStatus.RecordCount);
|
||||
AssertEqual("multi status parsed records", 2, multiRecordStatus.Records.Count);
|
||||
AssertEqual("multi status first status", 1, multiRecordStatus.Records[0].Status);
|
||||
AssertEqual("multi status second status", 2, multiRecordStatus.Records[1].Status);
|
||||
AssertEqual("multi status first kind", (byte)0x06, multiRecordStatus.Records[0].WireKind);
|
||||
AssertEqual("multi status second kind", (byte)0x06, multiRecordStatus.Records[1].WireKind);
|
||||
AssertEqual("multi status first value type", true, multiRecordStatus.Records[0].Value is DateTime);
|
||||
AssertEqual("multi status second value type", true, multiRecordStatus.Records[1].Value is DateTime);
|
||||
|
||||
byte[] boolUpdateReceived = FromHex(
|
||||
"01 00 27 00 00 00 00 00 00 00 f9 74 04 00 01 00 00 00 01 00 00 00 02 00 00 00 01 00 00 00 01 00 00 00 fb 7f 00 00 01 02 00 00 30 75 00 00 " +
|
||||
"33 01 00 01 00 00 00 88 b0 59 be 24 f0 f8 43 a7 89 b6 95 d8 11 13 9e 03 00 00 00 c0 00 f0 58 d4 21 7c d4 dc 01 01 ff");
|
||||
AssertCallbackValue("bool update", boolUpdateReceived, true);
|
||||
|
||||
byte[] floatUpdateReceived = FromHex(
|
||||
"01 00 2a 00 00 00 00 00 00 00 4d 75 04 00 01 00 00 00 01 00 00 00 02 00 00 00 01 00 00 00 01 00 00 00 fb 7f 00 00 01 02 00 00 30 75 00 00 " +
|
||||
"33 01 00 01 00 00 00 e1 29 46 93 4a c7 6d 43 b8 9b ad df bc ee f8 61 03 00 00 00 c0 00 10 8f cb 38 7c d4 dc 01 03 00 00 a0 3f");
|
||||
AssertCallbackValue("float update", floatUpdateReceived, 1.25f);
|
||||
|
||||
byte[] doubleUpdateReceived = FromHex(
|
||||
"01 00 2e 00 00 00 00 00 00 00 a1 75 04 00 01 00 00 00 01 00 00 00 02 00 00 00 01 00 00 00 01 00 00 00 fb 7f 00 00 01 02 00 00 30 75 00 00 " +
|
||||
"33 01 00 01 00 00 00 3b 39 15 ab 40 13 c5 4c 8d 6c b6 74 e5 9a db bf 03 00 00 00 c0 00 20 16 af 4f 7c d4 dc 01 04 00 00 00 00 00 00 f2 3f");
|
||||
AssertCallbackValue("double update", doubleUpdateReceived, 1.125d);
|
||||
|
||||
byte[] stringUpdateReceived = FromHex(
|
||||
"01 00 3e 00 00 00 00 00 00 00 f8 75 04 00 01 00 00 00 01 00 00 00 02 00 00 00 01 00 00 00 01 00 00 00 fb 7f 00 00 01 02 00 00 30 75 00 00 " +
|
||||
"33 01 00 01 00 00 00 e1 40 1f 90 80 e4 7d 43 bf ab 6f a2 ea 23 97 ed 03 00 00 00 c0 00 10 5c 75 67 7c d4 dc 01 05 14 00 00 00 10 00 00 00 41 00 6c 00 70 00 68 00 61 00 4d 00 58 00 00 00");
|
||||
AssertCallbackValue("string update", stringUpdateReceived, "AlphaMX");
|
||||
|
||||
byte[] stringStatusReceived = FromHex(
|
||||
"01 00 60 00 00 00 00 00 00 00 f5 75 04 00 01 00 00 00 01 00 00 00 02 00 00 00 01 00 00 00 01 00 00 00 fb 7f 00 00 01 02 00 00 30 75 00 00 " +
|
||||
"32 01 00 01 00 00 00 e1 40 1f 90 80 e4 7d 43 bf ab 6f a2 ea 23 97 ed b2 36 c6 09 ef 0d 00 43 9e 1d 5e d9 16 c8 7c cc 03 00 00 00 03 00 00 00 c0 00 b0 a0 e2 57 47 bd dc 01 05 22 00 00 00 1e 00 00 00 48 00 65 00 6c 00 6c 00 6f 00 46 00 72 00 6f 00 6d 00 4f 00 70 00 63 00 55 00 61 00 00 00");
|
||||
AssertCallbackValue("string status", stringStatusReceived, "HelloFromOpcUa");
|
||||
|
||||
byte[] compactEmptyStringStatusReceived = FromHex(
|
||||
"32 01 00 01 00 00 00 bd 26 31 07 1d d2 76 4d b4 48 29 dd a2 53 1e 29 e6 5c 35 81 5e f9 79 44 b3 5a d0 5d 9f c6 60 a8 03 00 00 00 00 00 00 00 c0 00 30 69 98 49 28 d3 dc 01 05 04 00 00 00");
|
||||
var compactEmptyStringStatus = NmxSubscriptionMessage.ParseInner(compactEmptyStringStatusReceived);
|
||||
AssertEqual("compact empty string kind", (byte)0x05, compactEmptyStringStatus.Records[0].WireKind);
|
||||
AssertEqual("compact empty string value", string.Empty, compactEmptyStringStatus.Records[0].Value!);
|
||||
|
||||
byte[] elapsedTimeStatusReceived = FromHex(
|
||||
"32 01 00 01 00 00 00 00 c0 ac 3a 1a 83 b4 48 88 e6 43 56 87 4b 87 97 45 d3 97 30 68 7f b9 48 b8 c4 bf cd 99 a7 a3 51 03 00 00 00 00 00 00 00 c0 00 90 5c d6 49 28 d3 dc 01 07 00 00 00 00");
|
||||
var elapsedTimeStatus = NmxSubscriptionMessage.ParseInner(elapsedTimeStatusReceived);
|
||||
AssertEqual("elapsed time kind", (byte)0x07, elapsedTimeStatus.Records[0].WireKind);
|
||||
AssertEqual("elapsed time value", TimeSpan.Zero, elapsedTimeStatus.Records[0].Value!);
|
||||
|
||||
byte[] nonZeroElapsedTimeStatusReceived = FromHex(
|
||||
"70 00 00 00 01 00 42 00 00 00 00 00 00 00 cd 89 05 00 01 00 00 00 01 00 00 00 02 00 00 00 01 00 00 00 01 00 00 00 fb 7f 00 00 01 02 00 00 30 75 00 00 " +
|
||||
"32 01 00 01 00 00 00 80 d0 62 c4 97 13 d4 43 a0 bc 8b a5 a6 e2 69 86 7c 32 dd c9 b6 70 af 4a 89 a5 18 67 bc 51 66 71 03 00 00 00 00 00 00 00 c0 00 90 5c d6 49 28 d3 dc 01 07 00 e4 0b 54");
|
||||
var nonZeroElapsedTimeStatus = NmxSubscriptionMessage.ParseProcessDataReceivedBody(nonZeroElapsedTimeStatusReceived);
|
||||
AssertEqual("nonzero elapsed time kind", (byte)0x07, nonZeroElapsedTimeStatus.Records[0].WireKind);
|
||||
AssertEqual("nonzero elapsed time value", TimeSpan.FromMilliseconds(0x540be400), nonZeroElapsedTimeStatus.Records[0].Value!);
|
||||
|
||||
byte[] intArrayUpdateReceived = FromHex(
|
||||
"01 00 58 00 00 00 00 00 00 00 c0 7c 04 00 01 00 00 00 01 00 00 00 02 00 00 00 01 00 00 00 01 00 00 00 fb 7f 00 00 01 02 00 00 30 75 00 00 " +
|
||||
"33 01 00 01 00 00 00 46 5a 30 af 6a 87 65 46 ac c5 4d 98 f2 e2 12 fe 03 00 00 00 c0 00 d0 ba 8f 68 7e d4 dc 01 42 00 00 00 00 0a 00 04 00 00 00 c9 00 00 00 ca 00 00 00 cb 00 00 00 cc 00 00 00 cd 00 00 00 ce 00 00 00 cf 00 00 00 d0 00 00 00 d1 00 00 00 d2 00 00 00");
|
||||
AssertCallbackValue("int array update", intArrayUpdateReceived, new[] { 201, 202, 203, 204, 205, 206, 207, 208, 209, 210 });
|
||||
|
||||
byte[] boolArrayUpdateReceived = FromHex(
|
||||
"01 00 44 00 00 00 00 00 00 00 23 7d 04 00 01 00 00 00 01 00 00 00 02 00 00 00 01 00 00 00 01 00 00 00 fb 7f 00 00 01 02 00 00 30 75 00 00 " +
|
||||
"33 01 00 01 00 00 00 f6 8c cc e9 ef 7e 36 4e ab 35 e3 d0 27 51 db 55 03 00 00 00 c0 00 20 73 4c 85 7e d4 dc 01 41 00 00 00 00 0a 00 02 00 00 00 ff ff ff ff 00 00 00 00 ff ff ff ff 00 00 00 00 ff ff ff ff");
|
||||
AssertCallbackValue("bool array update", boolArrayUpdateReceived, new[] { true, true, false, false, true, true, false, false, true, true });
|
||||
|
||||
byte[] floatArrayUpdateReceived = FromHex(
|
||||
"01 00 58 00 00 00 00 00 00 00 62 7d 04 00 01 00 00 00 01 00 00 00 02 00 00 00 01 00 00 00 01 00 00 00 fb 7f 00 00 01 02 00 00 30 75 00 00 " +
|
||||
"33 01 00 01 00 00 00 43 cd bf c8 a6 e1 a7 44 b7 14 99 a0 52 81 ec 43 03 00 00 00 c0 00 b0 47 0d 97 7e d4 dc 01 43 00 00 00 00 0a 00 04 00 00 00 00 00 a0 3f 00 00 20 40 00 00 70 40 00 00 88 40 00 00 b0 40 00 00 d8 40 00 00 e8 40 00 00 08 41 00 00 1c 41 00 00 24 41");
|
||||
AssertCallbackValue("float array update", floatArrayUpdateReceived, new[] { 1.25f, 2.5f, 3.75f, 4.25f, 5.5f, 6.75f, 7.25f, 8.5f, 9.75f, 10.25f });
|
||||
|
||||
byte[] doubleArrayUpdateReceived = FromHex(
|
||||
"01 00 80 00 00 00 00 00 00 00 a0 7d 04 00 01 00 00 00 01 00 00 00 02 00 00 00 01 00 00 00 01 00 00 00 fb 7f 00 00 01 02 00 00 30 75 00 00 " +
|
||||
"33 01 00 01 00 00 00 eb 4d ab 7d 2e d8 02 49 b9 04 34 db b0 c9 41 06 03 00 00 00 c0 00 b0 bc cc a8 7e d4 dc 01 44 00 00 00 00 0a 00 08 00 00 00 00 00 00 00 00 00 f2 3f 00 00 00 00 00 00 02 40 00 00 00 00 00 00 0c 40 00 00 00 00 00 80 12 40 00 00 00 00 00 00 17 40 00 00 00 00 00 80 1b 40 00 00 00 00 00 00 1c 40 00 00 00 00 00 40 20 40 00 00 00 00 00 80 22 40 00 00 00 00 00 c0 24 40");
|
||||
AssertCallbackValue("double array update", doubleArrayUpdateReceived, new[] { 1.125d, 2.25d, 3.5d, 4.625d, 5.75d, 6.875d, 7.0d, 8.125d, 9.25d, 10.375d });
|
||||
|
||||
byte[] dateTimeArrayUpdateReceived = FromHex(
|
||||
"01 00 a8 00 00 00 00 00 00 00 e4 7e 04 00 01 00 00 00 01 00 00 00 02 00 00 00 01 00 00 00 01 00 00 00 fb 7f 00 00 01 02 00 00 30 75 00 00 " +
|
||||
"33 01 00 01 00 00 00 c4 10 20 21 f5 14 45 42 aa 5a 7e 63 fc e1 d7 72 03 00 00 00 c0 00 40 85 bb 06 7f d4 dc 01 46 00 00 00 00 0a 00 0c 00 00 00 00 58 f7 21 81 d4 dc 01 00 00 00 00 00 9e ba 45 81 d4 dc 01 00 00 00 00 00 e4 7d 69 81 d4 dc 01 00 00 00 00 00 2a 41 8d 81 d4 dc 01 00 00 00 00 00 70 04 b1 81 d4 dc 01 00 00 00 00 00 b6 c7 d4 81 d4 dc 01 00 00 00 00 00 fc 8a f8 81 d4 dc 01 00 00 00 00 00 42 4e 1c 82 d4 dc 01 00 00 00 00 00 88 11 40 82 d4 dc 01 00 00 00 00 00 ce d4 63 82 d4 dc 01 00 00 00 00");
|
||||
var dateTimeArrayUpdate = NmxSubscriptionMessage.ParseProcessDataReceivedBody(dateTimeArrayUpdateReceived);
|
||||
AssertEqual("datetime array update value type", true, dateTimeArrayUpdate.Records[0].Value is DateTime[]);
|
||||
AssertEqual("datetime array update count", 10, ((DateTime[])dateTimeArrayUpdate.Records[0].Value!).Length);
|
||||
|
||||
byte[] metadataTransfer = FromHex(
|
||||
"01 00 3a 01 00 00 00 00 00 00 01 00 00 00 01 00 00 00 01 00 00 00 fb 7f 00 00 01 00 00 00 01 00 00 00 01 00 00 00 01 02 00 00 30 75 00 00 " +
|
||||
"17 01 00 01 01 00 01 00 00 00 65 00 71 00 0a 00 00 00 00 00 08 6a 00 00 00 40 00 00 81 44 00 65 00 76 00 50 00 6c 00 61 00 74 00 66 00 6f 00 72 00 6d 00 2e 00 47 00 52 00 2e 00 54 00 69 00 6d 00 65 00 4f 00 66 00 4c 00 61 00 73 00 74 00 44 00 65 00 70 00 6c 00 6f 00 79 00 00 00 02 00 00 00 00 00 02 00 00 00 00 00 02 00 00 00 00 00 01 01 00 01 00 01 00 00 00 00 00 00 00 00 00 00 00 00 00 01 d0 fc 40 09 1f 01 00 c0 ca 9c cd 32 65 b0 46 a5 85 a5 83 b2 e7 7a 5d 00 00 01 00 00 00 " +
|
||||
"17 01 00 01 01 00 01 00 00 00 65 00 71 00 0a 00 00 00 00 00 08 76 00 00 00 4c 00 00 81 44 00 65 00 76 00 50 00 6c 00 61 00 74 00 66 00 6f 00 72 00 6d 00 2e 00 47 00 52 00 2e 00 54 00 69 00 6d 00 65 00 4f 00 66 00 4c 00 61 00 73 00 74 00 43 00 6f 00 6e 00 66 00 69 00 67 00 43 00 68 00 61 00 6e 00 67 00 65 00 00 00 02 00 00 00 00 00 02 00 00 00 00 00 02 00 00 00 00 00 01 01 00 01 00 01 00 00 00 00 00 00 00 00 00 00 00 00 00 01 50 03 41 09 20 01 00 02 00 00 00");
|
||||
var metadataEnvelope = NmxObservedEnvelope.ParseTransferDataBody(metadataTransfer);
|
||||
var metadata = NmxObservedMessage.Parse(metadataEnvelope.InnerBody.Span);
|
||||
AssertEqual("metadata command", (byte)0x17, metadata.Command);
|
||||
AssertEqual("metadata command name", "MetadataQuery", metadata.CommandName);
|
||||
AssertEqual("metadata string 1", true, metadata.Strings.Any(s => s.Value == "DevPlatform.GR.TimeOfLastDeploy"));
|
||||
AssertEqual("metadata string 2", true, metadata.Strings.Any(s => s.Value == "DevPlatform.GR.TimeOfLastConfigChange"));
|
||||
AssertBytes(
|
||||
"observed pre-advise metadata encode",
|
||||
metadataEnvelope.InnerBody.ToArray(),
|
||||
NmxMetadataQueryMessage.EncodeObservedPreAdvise(new Guid("cd9ccac0-6532-46b0-a585-a583b2e77a5d")));
|
||||
|
||||
byte[] metadataResponseReceived = FromHex(
|
||||
"C2 02 00 00 01 00 94 02 00 00 00 00 00 00 01 00 00 00 01 00 00 00 01 00 00 00 01 00 00 00 01 00 00 00 24 72 00 00 02 02 00 00 30 75 00 00 " +
|
||||
"40 1F 50 80 08 A6 00 00 00 40 00 00 91 44 00 65 00 76 00 50 00 6C 00 61 00 74 00 66 00 6F 00 72 00 6D 00 2E 00 47 00 52 00 2E 00 54 00 69 00 6D 00 65 00 4F 00 66 00 4C 00 61 00 73 00 74 00 44 00 65 00 70 00 6C 00 6F 00 79 00 00 00 18 00 00 00 44 00 65 00 76 00 50 00 6C 00 61 00 74 00 66 00 6F 00 72 00 6D 00 00 00 28 00 00 00 47 00 52 00 2E 00 54 00 69 00 6D 00 65 00 4F 00 66 00 4C 00 61 00 73 00 74 00 44 00 65 00 70 00 6C 00 6F 00 79 00 00 00 02 00 00 00 00 00 01 01 00 01 00 01 00 53 F2 9A 00 6A 00 0A 00 5F F1 00 00 01 6C 00 00 00 41 00 6E 00 20 00 69 00 6E 00 74 00 65 00 72 00 6E 00 61 00 6C 00 20 00 65 00 72 00 72 00 6F 00 72 00 20 00 6F 00 63 00 63 00 75 00 72 00 72 00 65 00 64 00 20 00 69 00 6E 00 20 00 74 00 68 00 65 00 20 00 42 00 61 00 73 00 65 00 20 00 52 00 75 00 6E 00 74 00 69 00 6D 00 65 00 20 00 4F 00 62 00 6A 00 65 00 63 00 74 00 00 00 1F 00 00 50 80 01 00 01 00 01 00 30 75 00 00 D3 57 0D 95 6B 8F 7C 43 A6 09 63 4D 53 53 A2 C2 66 6B F0 7D 78 52 AC 46 82 E5 AF 0A CF 91 34 F5 40 1F 50 80 08 BE 00 00 00 4C 00 00 91 44 00 65 00 76 00 50 00 6C 00 61 00 74 00 66 00 6F 00 72 00 6D 00 2E 00 47 00 52 00 2E 00 54 00 69 00 6D 00 65 00 4F 00 66 00 4C 00 61 00 73 00 74 00 43 00 6F 00 6E 00 66 00 69 00 67 00 43 00 68 00 61 00 6E 00 67 00 65 00 00 00 18 00 00 00 44 00 65 00 76 00 50 00 6C 00 61 00 74 00 66 00 6F 00 72 00 6D 00 00 00 34 00 00 00 47 00 52 00 2E 00 54 00 69 00 6D 00 65 00 4F 00 66 00 4C 00 61 00 73 00 74 00 43 00 6F 00 6E 00 66 00 69 00 67 00 43 00 68 00 61 00 6E 00 67 00 65 00 00 00 02 00 00 00 00 00 01 01 00 01 00 01 00 53 F2 9A 00 6B 00 0A 00 87 3A 00 00 01 6C 00 00 00 41 00 6E 00 20 00 69 00 6E 00 74 00 65 00 72 00 6E 00 61 00 6C 00 20 00 65 00 72 00 72 00 6F 00 72 00 20 00 6F 00 63 00 63 00 75 00 72 00 72 00 65 00 64 00 20 00 69 00 6E 00 20 00 74 00 68 00 65 00 20 00 42 00 61 00 73 00 65 00 20 00 52 00 75 00 6E 00 74 00 69 00 6D 00 65 00 20 00 4F 00 62 00 6A 00 65 00 63 00 74 00 00 00 20 00 00 50 80 01 00 01 00 01 00");
|
||||
var metadataResponse = NmxObservedMessage.Parse(metadataResponseReceived.AsSpan(46));
|
||||
AssertEqual("metadata response command", (byte)0x40, metadataResponse.Command);
|
||||
AssertEqual("metadata response command name", "MetadataResponse", metadataResponse.CommandName);
|
||||
AssertEqual("metadata response string 1", true, metadataResponse.Strings.Any(s => s.Value == "DevPlatform.GR.TimeOfLastDeploy"));
|
||||
AssertEqual("metadata response string 2", true, metadataResponse.Strings.Any(s => s.Value == "DevPlatform.GR.TimeOfLastConfigChange"));
|
||||
AssertEqual("metadata response error string", true, metadataResponse.Strings.Any(s => s.Value == "An internal error occurred in the Base Runtime Object"));
|
||||
}
|
||||
|
||||
static void RunItemControlMessageTests()
|
||||
{
|
||||
RunItemControlRoundTrip(
|
||||
"TestInt advise",
|
||||
"1f 01 00 c0 ca 9c cd 32 65 b0 46 a5 85 a5 83 b2 e7 7a 5d 00 00 05 00 36 d7 02 00 9b 00 0a 00 3e da 00 00 03 00 00 00",
|
||||
NmxItemControlCommand.AdviseSupervisory,
|
||||
new Guid("cd9ccac0-6532-46b0-a585-a583b2e77a5d"),
|
||||
5,
|
||||
0xd736,
|
||||
2,
|
||||
155,
|
||||
10,
|
||||
0xda3e,
|
||||
0);
|
||||
|
||||
RunItemControlRoundTrip(
|
||||
"TestInt unadvise",
|
||||
"21 01 00 c0 ca 9c cd 32 65 b0 46 a5 85 a5 83 b2 e7 7a 5d 05 00 36 d7 02 00 9b 00 0a 00 3e da 00 00 03 00 00 00",
|
||||
NmxItemControlCommand.UnAdvise,
|
||||
new Guid("cd9ccac0-6532-46b0-a585-a583b2e77a5d"),
|
||||
5,
|
||||
0xd736,
|
||||
2,
|
||||
155,
|
||||
10,
|
||||
0xda3e,
|
||||
0);
|
||||
|
||||
RunItemControlRoundTrip(
|
||||
"TestBool advise",
|
||||
"1f 01 00 46 86 88 6b e0 16 13 4f 9a 88 2b bc 8f ae 13 04 00 00 05 00 36 d7 02 00 9a 00 0a 00 fa 7d 00 00 03 00 00 00",
|
||||
NmxItemControlCommand.AdviseSupervisory,
|
||||
new Guid("6b888646-16e0-4f13-9a88-2bbc8fae1304"),
|
||||
5,
|
||||
0xd736,
|
||||
2,
|
||||
154,
|
||||
10,
|
||||
0x7dfa,
|
||||
0);
|
||||
|
||||
RunItemControlRoundTrip(
|
||||
"TestString advise",
|
||||
"1f 01 00 7d 4e 8d dc 01 db 02 41 9e 5d a4 f3 44 10 c4 73 00 00 05 00 36 d7 02 00 9e 00 0a 00 1a 94 00 00 03 00 00 00",
|
||||
NmxItemControlCommand.AdviseSupervisory,
|
||||
new Guid("dc8d4e7d-db01-4102-9e5d-a4f34410c473"),
|
||||
5,
|
||||
0xd736,
|
||||
2,
|
||||
158,
|
||||
10,
|
||||
0x941a,
|
||||
0);
|
||||
}
|
||||
|
||||
static void RunMxStatusDetailTests()
|
||||
{
|
||||
var denied = new MxStatus(
|
||||
Success: 0,
|
||||
Category: MxStatusCategory.SecurityError,
|
||||
DetectedBy: MxStatusSource.RespondingAutomationObject,
|
||||
Detail: 33);
|
||||
AssertEqual("status detail denied", "Write access denied", denied.DetailText!);
|
||||
AssertEqual("status detail secured", "Secured Write", MxStatusDetails.GetKnownText(56)!);
|
||||
AssertEqual("status detail conversion", "Conversion to intended data type is not supported", MxStatusDetails.GetKnownText(541)!);
|
||||
AssertEqual("status detail configure classification", "Object must be offscan to modify attributes that have an MxSecurityConfigure security classification", MxStatusDetails.GetKnownText(8017)!);
|
||||
AssertEqual("status detail unknown", null, MxStatusDetails.GetKnownText(999));
|
||||
AssertEqual("invalid reference status success", (short)0, MxStatus.InvalidReferenceConfiguration.Success);
|
||||
AssertEqual("invalid reference status category", MxStatusCategory.ConfigurationError, MxStatus.InvalidReferenceConfiguration.Category);
|
||||
AssertEqual("invalid reference status source", MxStatusSource.RequestingLmx, MxStatus.InvalidReferenceConfiguration.DetectedBy);
|
||||
AssertEqual("invalid reference status detail", (short)6, MxStatus.InvalidReferenceConfiguration.Detail);
|
||||
}
|
||||
|
||||
static void RunMxDataTypeSupportTests()
|
||||
{
|
||||
AssertEqual("int type kind supported", true, NmxWriteMessage.TryGetValueKind((short)MxDataType.Integer, isArray: false, out var intKind));
|
||||
AssertEqual("int type kind", MxValueKind.Int32, intKind);
|
||||
AssertEqual("int array type kind supported", true, NmxWriteMessage.TryGetValueKind((short)MxDataType.Integer, isArray: true, out var intArrayKind));
|
||||
AssertEqual("int array type kind", MxValueKind.Int32Array, intArrayKind);
|
||||
AssertEqual("elapsed unsupported", false, NmxWriteMessage.TryGetValueKind((short)MxDataType.ElapsedTime, isArray: false, out _));
|
||||
AssertEqual("internationalized string unsupported", false, NmxWriteMessage.TryGetValueKind((short)MxDataType.InternationalizedString, isArray: false, out _));
|
||||
}
|
||||
|
||||
static void RunMxReferenceHandleTests()
|
||||
{
|
||||
byte[] observedHandle = FromHex("01 00 01 00 02 00 05 00 36 d7 02 00 9b 00 0a 00 3e da 00 00");
|
||||
var handle = MxReferenceHandle.Parse(observedHandle);
|
||||
|
||||
AssertEqual("handle platform", (ushort)1, handle.PlatformId);
|
||||
AssertEqual("handle engine", (ushort)2, handle.EngineId);
|
||||
AssertEqual("handle object id", (ushort)5, handle.ObjectId);
|
||||
AssertEqual("handle object signature", (ushort)0xd736, handle.ObjectSignature);
|
||||
AssertEqual("handle primitive id", (short)2, handle.PrimitiveId);
|
||||
AssertEqual("handle attribute id", (short)155, handle.AttributeId);
|
||||
AssertEqual("handle property id", (short)10, handle.PropertyId);
|
||||
AssertEqual("handle attribute signature", (ushort)0xda3e, handle.AttributeSignature);
|
||||
AssertEqual("handle attribute index", (short)0, handle.AttributeIndex);
|
||||
AssertBytes("handle encode", observedHandle, handle.Encode());
|
||||
AssertEqual("object signature crc", (ushort)0xd736, MxReferenceHandle.ComputeNameSignature("TestChildObject"));
|
||||
AssertEqual("int signature crc", (ushort)0xda3e, MxReferenceHandle.ComputeNameSignature("TestInt"));
|
||||
AssertEqual("bool signature crc", (ushort)0x7dfa, MxReferenceHandle.ComputeNameSignature("TestBool"));
|
||||
AssertEqual("string signature crc", (ushort)0x941a, MxReferenceHandle.ComputeNameSignature("TestString"));
|
||||
AssertEqual("int array signature crc", (ushort)0x5760, MxReferenceHandle.ComputeNameSignature("TestIntArray"));
|
||||
|
||||
var synthesized = MxReferenceHandle.Create(
|
||||
galaxyId: 1,
|
||||
platformId: 1,
|
||||
engineId: 2,
|
||||
objectId: 5,
|
||||
objectTagName: "TestChildObject",
|
||||
primitiveId: 2,
|
||||
attributeId: 155,
|
||||
propertyId: 10,
|
||||
attributeName: "TestInt",
|
||||
isArray: false);
|
||||
AssertEqual("synthesized handle", handle, synthesized);
|
||||
|
||||
var advise = NmxItemControlMessage.FromReferenceHandle(
|
||||
NmxItemControlCommand.AdviseSupervisory,
|
||||
new Guid("e56c1aff-d3a3-4a1d-921f-f12e18f0d95d"),
|
||||
handle);
|
||||
|
||||
AssertEqual("handle to advise object id", handle.ObjectId, advise.ObjectId);
|
||||
AssertEqual("handle to advise object signature", handle.ObjectSignature, advise.ObjectSignature);
|
||||
AssertEqual("handle to advise primitive", handle.PrimitiveId, advise.PrimitiveId);
|
||||
AssertEqual("handle to advise attr id", handle.AttributeId, advise.AttributeId);
|
||||
AssertEqual("handle to advise property id", handle.PropertyId, advise.PropertyId);
|
||||
AssertEqual("handle to advise attr signature", handle.AttributeSignature, advise.AttributeSignature);
|
||||
AssertEqual("handle to advise attr index", handle.AttributeIndex, advise.AttributeIndex);
|
||||
AssertEqual("advise to handle", handle, advise.ToReferenceHandle(engineId: 2));
|
||||
|
||||
AssertEqual("GR bool kind", MxValueKind.Boolean, NmxWriteMessage.GetValueKind(1, isArray: false));
|
||||
AssertEqual("GR int kind", MxValueKind.Int32, NmxWriteMessage.GetValueKind(2, isArray: false));
|
||||
AssertEqual("GR float kind", MxValueKind.Float32, NmxWriteMessage.GetValueKind(3, isArray: false));
|
||||
AssertEqual("GR double kind", MxValueKind.Float64, NmxWriteMessage.GetValueKind(4, isArray: false));
|
||||
AssertEqual("GR string kind", MxValueKind.String, NmxWriteMessage.GetValueKind(5, isArray: false));
|
||||
AssertEqual("GR datetime kind", MxValueKind.DateTime, NmxWriteMessage.GetValueKind(6, isArray: false));
|
||||
AssertEqual("GR int array kind", MxValueKind.Int32Array, NmxWriteMessage.GetValueKind(2, isArray: true));
|
||||
}
|
||||
|
||||
static void RunGeneratedTransferEnvelopeTests()
|
||||
{
|
||||
byte[] inner = FromHex(
|
||||
"1f 01 00 c0 ca 9c cd 32 65 b0 46 a5 85 a5 83 b2 e7 7a 5d 00 00 05 00 36 d7 02 00 9b 00 0a 00 3e da 00 00 03 00 00 00");
|
||||
byte[] expected = FromHex(
|
||||
"01 00 27 00 00 00 00 00 00 00 02 00 00 00 01 00 00 00 01 00 00 00 fb 7f 00 00 01 00 00 00 01 00 00 00 02 00 00 00 01 02 00 00 30 75 00 00 " +
|
||||
"1f 01 00 c0 ca 9c cd 32 65 b0 46 a5 85 a5 83 b2 e7 7a 5d 00 00 05 00 36 d7 02 00 9b 00 0a 00 3e da 00 00 03 00 00 00");
|
||||
|
||||
byte[] generated = NmxTransferEnvelope.Encode(
|
||||
NmxTransferMessageKind.ItemControl,
|
||||
localEngineId: 0x7ffb,
|
||||
targetGalaxyId: 1,
|
||||
targetPlatformId: 1,
|
||||
targetEngineId: 2,
|
||||
inner);
|
||||
AssertBytes("generated item-control transfer", expected, generated);
|
||||
|
||||
var parsed = NmxTransferEnvelope.Parse(generated);
|
||||
AssertEqual("transfer kind", NmxTransferMessageKind.ItemControl, parsed.MessageKind);
|
||||
AssertEqual("transfer source galaxy", 1, parsed.SourceGalaxyId);
|
||||
AssertEqual("transfer source platform", 1, parsed.SourcePlatformId);
|
||||
AssertEqual("transfer local engine", 0x7ffb, parsed.LocalEngineId);
|
||||
AssertEqual("transfer target galaxy", 1, parsed.TargetGalaxyId);
|
||||
AssertEqual("transfer target platform", 1, parsed.TargetPlatformId);
|
||||
AssertEqual("transfer target engine", 2, parsed.TargetEngineId);
|
||||
AssertEqual("transfer timeout", 30000, parsed.TimeoutMilliseconds);
|
||||
AssertBytes("transfer inner", inner, parsed.InnerBody.Span);
|
||||
|
||||
byte[] unadviseInner = FromHex(
|
||||
"21 01 00 c0 ca 9c cd 32 65 b0 46 a5 85 a5 83 b2 e7 7a 5d 05 00 36 d7 02 00 9b 00 0a 00 3e da 00 00 03 00 00 00");
|
||||
byte[] expectedUnadvise = FromHex(
|
||||
"01 00 25 00 00 00 00 00 00 00 03 00 00 00 01 00 00 00 01 00 00 00 fb 7f 00 00 01 00 00 00 01 00 00 00 02 00 00 00 01 02 00 00 30 75 00 00 " +
|
||||
"21 01 00 c0 ca 9c cd 32 65 b0 46 a5 85 a5 83 b2 e7 7a 5d 05 00 36 d7 02 00 9b 00 0a 00 3e da 00 00 03 00 00 00");
|
||||
AssertBytes(
|
||||
"generated unadvise transfer",
|
||||
expectedUnadvise,
|
||||
NmxTransferEnvelope.Encode(
|
||||
NmxTransferMessageKind.Write,
|
||||
localEngineId: 0x7ffb,
|
||||
targetGalaxyId: 1,
|
||||
targetPlatformId: 1,
|
||||
targetEngineId: 2,
|
||||
unadviseInner));
|
||||
}
|
||||
|
||||
static void RunReferenceRegistrationMessageTests()
|
||||
{
|
||||
byte[] observedNormal = FromHex(
|
||||
"10 01 00 02 00 00 00 35 bd 0b 74 63 e8 54 41 ad 3a a0 68 61 a6 f9 9c ff ff 00 00 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 10 00 00 81 54 00 65 00 73 00 74 00 49 00 6e 00 74 00 00 00 00 00 00 00 00 00 00 00 20 00 00 00 54 00 65 00 73 00 74 00 43 00 68 00 69 00 6c 00 64 00 4f 00 62 00 6a 00 65 00 63 00 74 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 01");
|
||||
var normal = NmxReferenceRegistrationMessage.Parse(observedNormal);
|
||||
AssertEqual("reference registration handle", 2, normal.ItemHandle);
|
||||
AssertEqual("reference registration guid", new Guid("740bbd35-e863-4154-ad3a-a06861a6f99c"), normal.ItemCorrelationId);
|
||||
AssertEqual("reference registration item", "TestInt", normal.ItemDefinition);
|
||||
AssertEqual("reference registration context", "TestChildObject", normal.ItemContext);
|
||||
AssertEqual("reference registration subscribe", true, normal.Subscribe);
|
||||
AssertBytes("reference registration encode", observedNormal, normal.Encode());
|
||||
|
||||
byte[] observedBuffered = FromHex(
|
||||
"10 01 00 01 00 00 00 90 29 63 fc 46 69 16 4d bc 65 19 f6 d3 06 07 37 ff ff 00 00 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 32 00 00 81 54 00 65 00 73 00 74 00 49 00 6e 00 74 00 2e 00 70 00 72 00 6f 00 70 00 65 00 72 00 74 00 79 00 28 00 62 00 75 00 66 00 66 00 65 00 72 00 29 00 00 00 00 00 00 00 00 00 00 00 20 00 00 00 54 00 65 00 73 00 74 00 43 00 68 00 69 00 6c 00 64 00 4f 00 62 00 6a 00 65 00 63 00 74 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 01");
|
||||
var buffered = NmxReferenceRegistrationMessage.Parse(observedBuffered);
|
||||
AssertEqual("buffered reference registration handle", 1, buffered.ItemHandle);
|
||||
AssertEqual("buffered reference registration guid", new Guid("fc632990-6946-4d16-bc65-19f6d3060737"), buffered.ItemCorrelationId);
|
||||
AssertEqual("buffered reference registration item", "TestInt.property(buffer)", buffered.ItemDefinition);
|
||||
AssertEqual("buffered reference registration context", "TestChildObject", buffered.ItemContext);
|
||||
AssertEqual("buffered helper", "TestInt.property(buffer)", NmxReferenceRegistrationMessage.ToBufferedItemDefinition("TestInt"));
|
||||
AssertBytes("buffered reference registration encode", observedBuffered, buffered.Encode());
|
||||
|
||||
byte[] observedBufferedWithContext = FromHex(
|
||||
"10 01 00 01 00 00 00 66 9b 96 d6 a8 42 20 4b 9e 9b dc 3e 2b 84 4e 21 ff ff 00 00 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 44 00 00 81 54 00 65 00 73 00 74 00 48 00 69 00 73 00 74 00 6f 00 72 00 79 00 56 00 61 00 6c 00 75 00 65 00 2e 00 70 00 72 00 6f 00 70 00 65 00 72 00 74 00 79 00 28 00 62 00 75 00 66 00 66 00 65 00 72 00 29 00 00 00 00 00 00 00 00 00 00 00 20 00 00 00 54 00 65 00 73 00 74 00 4d 00 61 00 63 00 68 00 69 00 6e 00 65 00 5f 00 30 00 30 00 31 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 01");
|
||||
var bufferedWithContext = NmxReferenceRegistrationMessage.Parse(observedBufferedWithContext);
|
||||
AssertEqual("context buffered registration handle", 1, bufferedWithContext.ItemHandle);
|
||||
AssertEqual("context buffered registration guid", new Guid("d6969b66-42a8-4b20-9e9b-dc3e2b844e21"), bufferedWithContext.ItemCorrelationId);
|
||||
AssertEqual("context buffered registration item", "TestHistoryValue.property(buffer)", bufferedWithContext.ItemDefinition);
|
||||
AssertEqual("context buffered registration context", "TestMachine_001", bufferedWithContext.ItemContext);
|
||||
AssertBytes("context buffered registration encode", observedBufferedWithContext, bufferedWithContext.Encode());
|
||||
|
||||
byte[] transfer = NmxTransferEnvelope.Encode(
|
||||
NmxTransferMessageKind.ItemControl,
|
||||
localEngineId: 0x7ff6,
|
||||
targetGalaxyId: 1,
|
||||
targetPlatformId: 1,
|
||||
targetEngineId: 2,
|
||||
observedBuffered);
|
||||
AssertEqual("buffered registration transfer kind", NmxTransferMessageKind.ItemControl, NmxTransferEnvelope.Parse(transfer).MessageKind);
|
||||
|
||||
byte[] observedNormalResult = FromHex(
|
||||
"11 01 00 02 00 00 00 35 bd 0b 74 63 e8 54 41 ad 3a a0 68 61 a6 f9 9c 00 a0 41 c3 55 bd dc 01 80 c1 75 25 a5 bd dc 01 01 08 56 00 00 00 10 00 00 81 54 00 65 00 73 00 74 00 49 00 6e 00 74 00 00 00 02 00 00 00 00 00 00 00 00 00 20 00 00 00 54 00 65 00 73 00 74 00 43 00 68 00 69 00 6c 00 64 00 4f 00 62 00 6a 00 65 00 63 00 74 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00");
|
||||
var normalResult = NmxReferenceRegistrationResultMessage.Parse(observedNormalResult);
|
||||
AssertEqual("normal result handle", 2, normalResult.ItemHandle);
|
||||
AssertEqual("normal result item", "TestInt", normalResult.ItemDefinition);
|
||||
AssertEqual("normal result type", 2, normalResult.MxDataType);
|
||||
AssertEqual("normal result context", "TestChildObject", normalResult.ItemContext);
|
||||
AssertEqual("normal result status category byte", (byte)1, normalResult.StatusCategory);
|
||||
AssertEqual("normal result status detail byte", (byte)8, normalResult.StatusDetail);
|
||||
|
||||
byte[] observedBufferedResult = FromHex(
|
||||
"11 01 00 01 00 00 00 90 29 63 fc 46 69 16 4d bc 65 19 f6 d3 06 07 37 00 a0 41 c3 55 bd dc 01 80 c1 75 25 a5 bd dc 01 01 08 78 00 00 00 32 00 00 81 54 00 65 00 73 00 74 00 49 00 6e 00 74 00 2e 00 70 00 72 00 6f 00 70 00 65 00 72 00 74 00 79 00 28 00 62 00 75 00 66 00 66 00 65 00 72 00 29 00 00 00 02 00 00 00 00 00 00 00 00 00 20 00 00 00 54 00 65 00 73 00 74 00 43 00 68 00 69 00 6c 00 64 00 4f 00 62 00 6a 00 65 00 63 00 74 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00");
|
||||
var bufferedResult = NmxReferenceRegistrationResultMessage.Parse(observedBufferedResult);
|
||||
AssertEqual("buffered result handle", 1, bufferedResult.ItemHandle);
|
||||
AssertEqual("buffered result item", "TestInt.property(buffer)", bufferedResult.ItemDefinition);
|
||||
AssertEqual("buffered result type", 2, bufferedResult.MxDataType);
|
||||
AssertEqual("buffered result context", "TestChildObject", bufferedResult.ItemContext);
|
||||
|
||||
byte[] observedBufferedContextResult = FromHex(
|
||||
"11 01 00 01 00 00 00 66 9b 96 d6 a8 42 20 4b 9e 9b dc 3e 2b 84 4e 21 00 a0 41 c3 55 bd dc 01 80 c1 75 25 a5 bd dc 01 01 08 8a 00 00 00 44 00 00 81 54 00 65 00 73 00 74 00 48 00 69 00 73 00 74 00 6f 00 72 00 79 00 56 00 61 00 6c 00 75 00 65 00 2e 00 70 00 72 00 6f 00 70 00 65 00 72 00 74 00 79 00 28 00 62 00 75 00 66 00 66 00 65 00 72 00 29 00 00 00 02 00 00 00 00 00 00 00 00 00 20 00 00 00 54 00 65 00 73 00 74 00 4d 00 61 00 63 00 68 00 69 00 6e 00 65 00 5f 00 30 00 30 00 31 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00");
|
||||
var bufferedContextResult = NmxReferenceRegistrationResultMessage.Parse(observedBufferedContextResult);
|
||||
AssertEqual("context buffered result handle", 1, bufferedContextResult.ItemHandle);
|
||||
AssertEqual("context buffered result item", "TestHistoryValue.property(buffer)", bufferedContextResult.ItemDefinition);
|
||||
AssertEqual("context buffered result type", 2, bufferedContextResult.MxDataType);
|
||||
AssertEqual("context buffered result context", "TestMachine_001", bufferedContextResult.ItemContext);
|
||||
}
|
||||
|
||||
static void RunProtectedWriteGenerationTests()
|
||||
{
|
||||
byte[] securedWrite = FromHex(
|
||||
"37 01 00 06 00 08 f4 02 00 a6 00 0a 00 bb 67 00 00 01 ff ff ff 00 00 00 00 00 00 00 00 a1 fa d6 08 01 00 00 00");
|
||||
var securedHandle = HandleFromObservedWriteBody(securedWrite);
|
||||
AssertEqual("secured object signature", (ushort)0xf408, securedHandle.ObjectSignature);
|
||||
AssertEqual("secured attr signature", (ushort)0x67bb, securedHandle.AttributeSignature);
|
||||
AssertEqual("secured object signature crc", securedHandle.ObjectSignature, MxReferenceHandle.ComputeNameSignature("TestMachine_001"));
|
||||
AssertEqual("secured attr signature crc", securedHandle.AttributeSignature, MxReferenceHandle.ComputeNameSignature("ProtectedValue"));
|
||||
AssertBytes(
|
||||
"secured generated bool write",
|
||||
securedWrite,
|
||||
NmxWriteMessage.Encode(
|
||||
securedHandle,
|
||||
MxValueKind.Boolean,
|
||||
true,
|
||||
writeIndex: 1,
|
||||
clientToken: BinaryPrimitives.ReadUInt32LittleEndian(securedWrite.AsSpan(securedWrite.Length - 8, sizeof(uint)))));
|
||||
|
||||
byte[] verifiedWrite = FromHex(
|
||||
"37 01 00 06 00 08 f4 02 00 a7 00 0a 00 26 8a 00 00 01 ff ff ff 00 00 00 00 00 00 00 00 67 64 d7 08 01 00 00 00");
|
||||
var verifiedHandle = HandleFromObservedWriteBody(verifiedWrite);
|
||||
AssertEqual("verified object signature", (ushort)0xf408, verifiedHandle.ObjectSignature);
|
||||
AssertEqual("verified attr signature", (ushort)0x8a26, verifiedHandle.AttributeSignature);
|
||||
AssertEqual("verified attr signature crc", verifiedHandle.AttributeSignature, MxReferenceHandle.ComputeNameSignature("ProtectedValue1"));
|
||||
AssertBytes(
|
||||
"verified generated bool write",
|
||||
verifiedWrite,
|
||||
NmxWriteMessage.Encode(
|
||||
verifiedHandle,
|
||||
MxValueKind.Boolean,
|
||||
true,
|
||||
writeIndex: 1,
|
||||
clientToken: BinaryPrimitives.ReadUInt32LittleEndian(verifiedWrite.AsSpan(verifiedWrite.Length - 8, sizeof(uint)))));
|
||||
}
|
||||
|
||||
static void RunSecuredWrite2GenerationTests()
|
||||
{
|
||||
byte[] secured = FromHex(
|
||||
"38 01 00 06 00 08 f4 02 00 a6 00 0a 00 bb 67 00 00 01 ff 00 00 00 f6 bf e8 1b d5 dc 01 07 b9 a9 f4 72 6e ae 48 83 b5 bb de 91 8c 89 0f 7e 00 00 00 4d 00 78 00 46 00 72 00 69 00 64 00 61 00 54 00 72 00 61 00 63 00 65 00 2d 00 31 00 31 00 35 00 2d 00 66 00 72 00 69 00 64 00 61 00 2d 00 77 00 72 00 69 00 74 00 65 00 2d 00 73 00 65 00 63 00 75 00 72 00 65 00 64 00 32 00 2d 00 61 00 75 00 74 00 68 00 2d 00 70 00 72 00 6f 00 74 00 65 00 63 00 74 00 65 00 64 00 76 00 61 00 6c 00 75 00 65 00 2d 00 74 00 72 00 75 00 65 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ff ff 92 f4 cc 0c 01 00 00 00");
|
||||
AssertSecuredWrite2(
|
||||
"secured WriteSecured2 bool",
|
||||
secured,
|
||||
MxValueKind.Boolean,
|
||||
true,
|
||||
"MxFridaTrace-115-frida-write-secured2-auth-protectedvalue-true",
|
||||
verifierToken: new byte[NmxSecuredWrite2Message.AuthenticatorTokenLength]);
|
||||
|
||||
byte[] verified = FromHex(
|
||||
"38 01 00 06 00 08 f4 02 00 a7 00 0a 00 26 8a 00 00 01 ff 00 00 00 41 76 50 1c d5 dc 01 07 b9 a9 f4 72 6e ae 48 83 b5 bb de 91 8c 89 0f 88 00 00 00 4d 00 78 00 46 00 72 00 69 00 64 00 61 00 54 00 72 00 61 00 63 00 65 00 2d 00 31 00 31 00 36 00 2d 00 66 00 72 00 69 00 64 00 61 00 2d 00 77 00 72 00 69 00 74 00 65 00 2d 00 73 00 65 00 63 00 75 00 72 00 65 00 64 00 32 00 2d 00 61 00 75 00 74 00 68 00 2d 00 76 00 65 00 72 00 69 00 66 00 69 00 65 00 64 00 2d 00 70 00 72 00 6f 00 74 00 65 00 63 00 74 00 65 00 64 00 76 00 61 00 6c 00 75 00 65 00 31 00 00 00 07 b9 a9 f4 72 6e ae 48 83 b5 bb de 91 8c 89 0f ff ff 1d 9d cf 0c 01 00 00 00");
|
||||
AssertSecuredWrite2(
|
||||
"verified WriteSecured2 bool",
|
||||
verified,
|
||||
MxValueKind.Boolean,
|
||||
true,
|
||||
"MxFridaTrace-116-frida-write-secured2-auth-verified-protectedvalue1",
|
||||
verifierToken: NmxSecuredWrite2Message.ObservedAuthenticatedUserToken);
|
||||
|
||||
byte[] securedInt = FromHex(
|
||||
"38 01 00 05 00 36 d7 02 00 9b 00 0a 00 3e da 00 00 02 09 03 00 00 00 00 80 8d 64 10 1f d5 dc 01 07 b9 a9 f4 72 6e ae 48 83 b5 bb de 91 8c 89 0f 66 00 00 00 4d 00 78 00 46 00 72 00 69 00 64 00 61 00 54 00 72 00 61 00 63 00 65 00 2d 00 31 00 31 00 37 00 2d 00 66 00 72 00 69 00 64 00 61 00 2d 00 77 00 72 00 69 00 74 00 65 00 2d 00 73 00 65 00 63 00 75 00 72 00 65 00 64 00 32 00 2d 00 61 00 75 00 74 00 68 00 2d 00 74 00 65 00 73 00 74 00 69 00 6e 00 74 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ff ff 2a a4 e1 0c 01 00 00 00");
|
||||
AssertSecuredWrite2(
|
||||
"secured WriteSecured2 int",
|
||||
securedInt,
|
||||
MxValueKind.Int32,
|
||||
777,
|
||||
"MxFridaTrace-117-frida-write-secured2-auth-testint",
|
||||
verifierToken: new byte[NmxSecuredWrite2Message.AuthenticatorTokenLength]);
|
||||
}
|
||||
|
||||
static void AssertSecuredWrite2(
|
||||
string name,
|
||||
byte[] observed,
|
||||
MxValueKind valueKind,
|
||||
object value,
|
||||
string clientName,
|
||||
ReadOnlySpan<byte> verifierToken)
|
||||
{
|
||||
var handle = HandleFromObservedWriteBody(observed);
|
||||
int prefixLength = valueKind == MxValueKind.Boolean ? 21 : 24;
|
||||
long fileTime = BinaryPrimitives.ReadInt64LittleEndian(observed.AsSpan(prefixLength, sizeof(long)));
|
||||
uint clientToken = BinaryPrimitives.ReadUInt32LittleEndian(observed.AsSpan(observed.Length - 8, sizeof(uint)));
|
||||
int writeIndex = BinaryPrimitives.ReadInt32LittleEndian(observed.AsSpan(observed.Length - sizeof(int), sizeof(int)));
|
||||
|
||||
byte[] generated = NmxSecuredWrite2Message.Encode(
|
||||
handle,
|
||||
valueKind,
|
||||
value,
|
||||
DateTime.FromFileTime(fileTime),
|
||||
clientName,
|
||||
NmxSecuredWrite2Message.ObservedAuthenticatedUserToken,
|
||||
verifierToken,
|
||||
writeIndex,
|
||||
clientToken);
|
||||
AssertBytes(name, observed, generated);
|
||||
}
|
||||
|
||||
static void RunItemControlRoundTrip(
|
||||
string name,
|
||||
string hex,
|
||||
NmxItemControlCommand command,
|
||||
Guid correlationId,
|
||||
ushort objectId,
|
||||
ushort objectSignature,
|
||||
short primitiveId,
|
||||
short attributeId,
|
||||
short propertyId,
|
||||
ushort attributeSignature,
|
||||
short attributeIndex)
|
||||
{
|
||||
byte[] observed = FromHex(hex);
|
||||
var parsed = NmxItemControlMessage.Parse(observed);
|
||||
AssertEqual(name + " command", command, parsed.Command);
|
||||
AssertEqual(name + " correlation", correlationId, parsed.ItemCorrelationId);
|
||||
AssertEqual(name + " object id", objectId, parsed.ObjectId);
|
||||
AssertEqual(name + " object signature", objectSignature, parsed.ObjectSignature);
|
||||
AssertEqual(name + " primitive id", primitiveId, parsed.PrimitiveId);
|
||||
AssertEqual(name + " attribute id", attributeId, parsed.AttributeId);
|
||||
AssertEqual(name + " property id", propertyId, parsed.PropertyId);
|
||||
AssertEqual(name + " attribute signature", attributeSignature, parsed.AttributeSignature);
|
||||
AssertEqual(name + " attribute index", attributeIndex, parsed.AttributeIndex);
|
||||
AssertEqual(name + " tail", 3u, parsed.Tail);
|
||||
AssertBytes(name + " encode", observed, parsed.Encode());
|
||||
}
|
||||
|
||||
static void AssertCallbackValue(string name, byte[] processDataBody, object expected)
|
||||
{
|
||||
var callback = NmxSubscriptionMessage.ParseProcessDataReceivedBody(processDataBody);
|
||||
AssertEqual(name + " record count", 1, callback.Records.Count);
|
||||
AssertEqual(name + " quality", (ushort)0x00c0, callback.Records[0].Quality);
|
||||
AssertValue(name + " value", expected, callback.Records[0].Value!);
|
||||
}
|
||||
|
||||
static byte[] FromHex(string hex)
|
||||
{
|
||||
string[] parts = hex.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
byte[] bytes = new byte[parts.Length];
|
||||
for (int i = 0; i < parts.Length; i++)
|
||||
{
|
||||
bytes[i] = Convert.ToByte(parts[i], 16);
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
static MxReferenceHandle HandleFromObservedWriteBody(ReadOnlySpan<byte> body)
|
||||
{
|
||||
return new MxReferenceHandle(
|
||||
GalaxyId: 1,
|
||||
PlatformId: 1,
|
||||
EngineId: 2,
|
||||
ObjectId: BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(3, sizeof(ushort))),
|
||||
ObjectSignature: BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(5, sizeof(ushort))),
|
||||
PrimitiveId: BinaryPrimitives.ReadInt16LittleEndian(body.Slice(7, sizeof(short))),
|
||||
AttributeId: BinaryPrimitives.ReadInt16LittleEndian(body.Slice(9, sizeof(short))),
|
||||
PropertyId: BinaryPrimitives.ReadInt16LittleEndian(body.Slice(11, sizeof(short))),
|
||||
AttributeSignature: BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(13, sizeof(ushort))),
|
||||
AttributeIndex: BinaryPrimitives.ReadInt16LittleEndian(body.Slice(15, sizeof(short))));
|
||||
}
|
||||
|
||||
static void AssertValue(string name, object expected, object actual)
|
||||
{
|
||||
bool equals = expected switch
|
||||
{
|
||||
float f => actual is float a && Math.Abs(f - a) < 0.0001f,
|
||||
double d => actual is double a && Math.Abs(d - a) < 0.0000001d,
|
||||
DateTime d => actual is DateTime a && d == a,
|
||||
bool[] expectedValues => actual is bool[] actualValues && expectedValues.SequenceEqual(actualValues),
|
||||
int[] expectedValues => actual is int[] actualValues && expectedValues.SequenceEqual(actualValues),
|
||||
float[] expectedValues => actual is float[] actualValues && expectedValues.Zip(actualValues, static (e, a) => Math.Abs(e - a) < 0.0001f).All(static x => x),
|
||||
double[] expectedValues => actual is double[] actualValues && expectedValues.Zip(actualValues, static (e, a) => Math.Abs(e - a) < 0.0000001d).All(static x => x),
|
||||
string[] expectedValues => actual is string[] actualValues && expectedValues.SequenceEqual(actualValues),
|
||||
DateTime[] expectedValues => actual is DateTime[] actualValues && expectedValues.SequenceEqual(actualValues),
|
||||
_ => Equals(expected, actual),
|
||||
};
|
||||
|
||||
if (!equals)
|
||||
{
|
||||
throw new InvalidOperationException($"{name}: expected decoded value {expected}, got {actual}.");
|
||||
}
|
||||
}
|
||||
|
||||
static void AssertEqual<T>(string name, T expected, T actual)
|
||||
{
|
||||
if (!EqualityComparer<T>.Default.Equals(expected, actual))
|
||||
{
|
||||
throw new InvalidOperationException($"{name}: expected {expected}, got {actual}.");
|
||||
}
|
||||
}
|
||||
|
||||
static void AssertBytes(string name, ReadOnlySpan<byte> expected, ReadOnlySpan<byte> actual)
|
||||
{
|
||||
if (!expected.SequenceEqual(actual))
|
||||
{
|
||||
int diff = 0;
|
||||
while (diff < expected.Length && diff < actual.Length && expected[diff] == actual[diff])
|
||||
{
|
||||
diff++;
|
||||
}
|
||||
|
||||
string expectedWindow = Convert.ToHexString(expected.Slice(diff, Math.Min(24, expected.Length - diff))).ToLowerInvariant();
|
||||
string actualWindow = Convert.ToHexString(actual.Slice(diff, Math.Min(24, actual.Length - diff))).ToLowerInvariant();
|
||||
throw new InvalidOperationException($"{name}: encoded bytes do not match observed bytes at offset {diff}; expected length {expected.Length}, actual length {actual.Length}; expected {expectedWindow}; actual {actualWindow}.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace MxNativeCodec;
|
||||
|
||||
public enum MxDataType : short
|
||||
{
|
||||
Unknown = -1,
|
||||
NoData = 0,
|
||||
Boolean = 1,
|
||||
Integer = 2,
|
||||
Float = 3,
|
||||
Double = 4,
|
||||
String = 5,
|
||||
Time = 6,
|
||||
ElapsedTime = 7,
|
||||
ReferenceType = 8,
|
||||
StatusType = 9,
|
||||
Enum = 10,
|
||||
SecurityClassificationEnum = 11,
|
||||
DataQualityType = 12,
|
||||
QualifiedEnum = 13,
|
||||
QualifiedStruct = 14,
|
||||
InternationalizedString = 15,
|
||||
BigString = 16,
|
||||
End = 17,
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,120 @@
|
||||
using System.Buffers.Binary;
|
||||
|
||||
namespace MxNativeCodec;
|
||||
|
||||
public readonly record struct MxReferenceHandle(
|
||||
byte GalaxyId,
|
||||
ushort PlatformId,
|
||||
ushort EngineId,
|
||||
ushort ObjectId,
|
||||
ushort ObjectSignature,
|
||||
short PrimitiveId,
|
||||
short AttributeId,
|
||||
short PropertyId,
|
||||
ushort AttributeSignature,
|
||||
short AttributeIndex)
|
||||
{
|
||||
public const int EncodedLength = 20;
|
||||
private const ushort Crc16IbmPolynomial = 0xa001;
|
||||
|
||||
public ushort Reserved0 => 0;
|
||||
|
||||
public static MxReferenceHandle Create(
|
||||
byte galaxyId,
|
||||
ushort platformId,
|
||||
ushort engineId,
|
||||
ushort objectId,
|
||||
string objectTagName,
|
||||
short primitiveId,
|
||||
short attributeId,
|
||||
short propertyId,
|
||||
string attributeName,
|
||||
bool isArray)
|
||||
{
|
||||
return new MxReferenceHandle(
|
||||
galaxyId,
|
||||
platformId,
|
||||
engineId,
|
||||
objectId,
|
||||
ComputeNameSignature(objectTagName),
|
||||
primitiveId,
|
||||
attributeId,
|
||||
propertyId,
|
||||
ComputeNameSignature(attributeName),
|
||||
isArray ? (short)-1 : (short)0);
|
||||
}
|
||||
|
||||
public static ushort ComputeNameSignature(string name)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
||||
|
||||
ushort crc = 0;
|
||||
foreach (char character in name.ToLowerInvariant())
|
||||
{
|
||||
crc = UpdateCrc16Ibm(crc, (byte)character);
|
||||
crc = UpdateCrc16Ibm(crc, (byte)(character >> 8));
|
||||
}
|
||||
|
||||
return crc;
|
||||
}
|
||||
|
||||
public static MxReferenceHandle Parse(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
if (bytes.Length != EncodedLength)
|
||||
{
|
||||
throw new ArgumentException($"MX reference handle must be {EncodedLength} bytes.", nameof(bytes));
|
||||
}
|
||||
|
||||
return new MxReferenceHandle(
|
||||
GalaxyId: bytes[0],
|
||||
PlatformId: BinaryPrimitives.ReadUInt16LittleEndian(bytes.Slice(2, sizeof(ushort))),
|
||||
EngineId: BinaryPrimitives.ReadUInt16LittleEndian(bytes.Slice(4, sizeof(ushort))),
|
||||
ObjectId: BinaryPrimitives.ReadUInt16LittleEndian(bytes.Slice(6, sizeof(ushort))),
|
||||
ObjectSignature: BinaryPrimitives.ReadUInt16LittleEndian(bytes.Slice(8, sizeof(ushort))),
|
||||
PrimitiveId: BinaryPrimitives.ReadInt16LittleEndian(bytes.Slice(10, sizeof(short))),
|
||||
AttributeId: BinaryPrimitives.ReadInt16LittleEndian(bytes.Slice(12, sizeof(short))),
|
||||
PropertyId: BinaryPrimitives.ReadInt16LittleEndian(bytes.Slice(14, sizeof(short))),
|
||||
AttributeSignature: BinaryPrimitives.ReadUInt16LittleEndian(bytes.Slice(16, sizeof(ushort))),
|
||||
AttributeIndex: BinaryPrimitives.ReadInt16LittleEndian(bytes.Slice(18, sizeof(short))));
|
||||
}
|
||||
|
||||
public byte[] Encode()
|
||||
{
|
||||
byte[] bytes = new byte[EncodedLength];
|
||||
WriteTo(bytes);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
public void WriteTo(Span<byte> destination)
|
||||
{
|
||||
if (destination.Length < EncodedLength)
|
||||
{
|
||||
throw new ArgumentException($"Destination must be at least {EncodedLength} bytes.", nameof(destination));
|
||||
}
|
||||
|
||||
destination[0] = GalaxyId;
|
||||
destination[1] = 0;
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(destination.Slice(2, sizeof(ushort)), PlatformId);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(destination.Slice(4, sizeof(ushort)), EngineId);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(destination.Slice(6, sizeof(ushort)), ObjectId);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(destination.Slice(8, sizeof(ushort)), ObjectSignature);
|
||||
BinaryPrimitives.WriteInt16LittleEndian(destination.Slice(10, sizeof(short)), PrimitiveId);
|
||||
BinaryPrimitives.WriteInt16LittleEndian(destination.Slice(12, sizeof(short)), AttributeId);
|
||||
BinaryPrimitives.WriteInt16LittleEndian(destination.Slice(14, sizeof(short)), PropertyId);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(destination.Slice(16, sizeof(ushort)), AttributeSignature);
|
||||
BinaryPrimitives.WriteInt16LittleEndian(destination.Slice(18, sizeof(short)), AttributeIndex);
|
||||
}
|
||||
|
||||
private static ushort UpdateCrc16Ibm(ushort crc, byte value)
|
||||
{
|
||||
crc ^= value;
|
||||
for (int bit = 0; bit < 8; bit++)
|
||||
{
|
||||
crc = (ushort)(((crc & 1) != 0)
|
||||
? ((crc >> 1) ^ Crc16IbmPolynomial)
|
||||
: (crc >> 1));
|
||||
}
|
||||
|
||||
return crc;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
namespace MxNativeCodec;
|
||||
|
||||
public enum MxStatusCategory : short
|
||||
{
|
||||
Unknown = -1,
|
||||
Ok = 0,
|
||||
Pending = 1,
|
||||
Warning = 2,
|
||||
CommunicationError = 3,
|
||||
ConfigurationError = 4,
|
||||
OperationalError = 5,
|
||||
SecurityError = 6,
|
||||
SoftwareError = 7,
|
||||
OtherError = 8,
|
||||
}
|
||||
|
||||
public enum MxStatusSource : short
|
||||
{
|
||||
Unknown = -1,
|
||||
RequestingLmx = 0,
|
||||
RespondingLmx = 1,
|
||||
RequestingNmx = 2,
|
||||
RespondingNmx = 3,
|
||||
RequestingAutomationObject = 4,
|
||||
RespondingAutomationObject = 5,
|
||||
}
|
||||
|
||||
public sealed record MxStatus(
|
||||
short Success,
|
||||
MxStatusCategory Category,
|
||||
MxStatusSource DetectedBy,
|
||||
short Detail)
|
||||
{
|
||||
public string? DetailText => MxStatusDetails.GetKnownText(Detail);
|
||||
|
||||
public static MxStatus DataChangeOk { get; } = new(
|
||||
Success: -1,
|
||||
Category: MxStatusCategory.Ok,
|
||||
DetectedBy: MxStatusSource.RequestingLmx,
|
||||
Detail: 0);
|
||||
|
||||
public static MxStatus WriteCompleteOk { get; } = new(
|
||||
Success: -1,
|
||||
Category: MxStatusCategory.Ok,
|
||||
DetectedBy: MxStatusSource.RespondingAutomationObject,
|
||||
Detail: 0);
|
||||
|
||||
public static MxStatus SuspendPending { get; } = new(
|
||||
Success: -1,
|
||||
Category: MxStatusCategory.Pending,
|
||||
DetectedBy: MxStatusSource.RequestingLmx,
|
||||
Detail: 0);
|
||||
|
||||
public static MxStatus ActivateOk { get; } = new(
|
||||
Success: -1,
|
||||
Category: MxStatusCategory.Ok,
|
||||
DetectedBy: MxStatusSource.RequestingLmx,
|
||||
Detail: 0);
|
||||
|
||||
public static MxStatus InvalidReferenceConfiguration { get; } = new(
|
||||
Success: 0,
|
||||
Category: MxStatusCategory.ConfigurationError,
|
||||
DetectedBy: MxStatusSource.RequestingLmx,
|
||||
Detail: 6);
|
||||
}
|
||||
|
||||
public static class MxStatusDetails
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<short, string> KnownDetails = new Dictionary<short, string>
|
||||
{
|
||||
[16] = "Request timed out",
|
||||
[17] = "Platform communication error",
|
||||
[18] = "Invalid platform ID",
|
||||
[19] = "Invalid engine ID",
|
||||
[20] = "Engine communication error",
|
||||
[21] = "Invalid reference",
|
||||
[22] = "No Galaxy Repository",
|
||||
[23] = "Invalid object ID",
|
||||
[24] = "Object signature mismatch",
|
||||
[25] = "Invalid primitive ID",
|
||||
[26] = "Invalid attribute ID",
|
||||
[27] = "Invalid property ID",
|
||||
[28] = "Index out of range",
|
||||
[29] = "Data out of range",
|
||||
[30] = "Incorrect data type",
|
||||
[31] = "Attribute not readable",
|
||||
[32] = "Attribute not writeable",
|
||||
[33] = "Write access denied",
|
||||
[34] = "Unknown error",
|
||||
[35] = "detected by",
|
||||
[36] = "Wrong data type",
|
||||
[37] = "Wrong number of dimensions",
|
||||
[38] = "Invalid index",
|
||||
[39] = "Index out of order",
|
||||
[40] = "Dimension does not exist",
|
||||
[41] = "Conversion not supported",
|
||||
[42] = "Unable to convert string",
|
||||
[43] = "Overflow",
|
||||
[44] = "Attribute signature mismatch",
|
||||
[45] = "Resolving local portion of reference",
|
||||
[46] = "Resolving global portion of reference",
|
||||
[47] = "Nmx version mismatch",
|
||||
[48] = "Nmx command not valid",
|
||||
[49] = "Lmx version mismatch",
|
||||
[50] = "Lmx command not valid",
|
||||
[51] = "However, the object could not be put On Scan - Permission to modify \"Operate\" attributes is required",
|
||||
[52] = "Unable to resolve reference for 'set' request because Galaxy Repository is busy performing a 'Deploy/Undeploy' operation",
|
||||
[53] = "Too many outstanding pending requests to engine",
|
||||
[54] = "Object Initializing",
|
||||
[55] = "Engine Initializing",
|
||||
[56] = "Secured Write",
|
||||
[57] = "Verified Write",
|
||||
[58] = "No Alarm Ack Privilege",
|
||||
[59] = "Alarm Acked Already",
|
||||
[60] = "User did not have the necessary permissions to write",
|
||||
[61] = "Verifier did not have the necessary permissions to verify",
|
||||
[541] = "Conversion to intended data type is not supported",
|
||||
[542] = "Unable to convert the input string to intended data type",
|
||||
[8017] = "Object must be offscan to modify attributes that have an MxSecurityConfigure security classification",
|
||||
};
|
||||
|
||||
public static string? GetKnownText(short detail)
|
||||
{
|
||||
return KnownDetails.TryGetValue(detail, out string? text) ? text : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace MxNativeCodec;
|
||||
|
||||
public enum MxValueKind
|
||||
{
|
||||
Boolean,
|
||||
Int32,
|
||||
Float32,
|
||||
Float64,
|
||||
String,
|
||||
DateTime,
|
||||
ElapsedTime,
|
||||
BooleanArray,
|
||||
Int32Array,
|
||||
Float32Array,
|
||||
Float64Array,
|
||||
StringArray,
|
||||
DateTimeArray,
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
using System.Buffers.Binary;
|
||||
|
||||
namespace MxNativeCodec;
|
||||
|
||||
public enum NmxItemControlCommand : byte
|
||||
{
|
||||
Advise = 0x1f,
|
||||
AdviseSupervisory = 0x1f,
|
||||
UnAdvise = 0x21,
|
||||
}
|
||||
|
||||
public sealed record NmxItemControlMessage(
|
||||
NmxItemControlCommand Command,
|
||||
Guid ItemCorrelationId,
|
||||
ushort ObjectId,
|
||||
ushort ObjectSignature,
|
||||
short PrimitiveId,
|
||||
short AttributeId,
|
||||
short PropertyId,
|
||||
ushort AttributeSignature,
|
||||
short AttributeIndex,
|
||||
uint Tail)
|
||||
{
|
||||
private const ushort Version = 1;
|
||||
private const int HeaderLength = 3;
|
||||
private const int GuidLength = 16;
|
||||
private const int AdviseExtraLength = 2;
|
||||
private const int PayloadLength = 18;
|
||||
|
||||
public static int GetEncodedLength(NmxItemControlCommand command)
|
||||
{
|
||||
return HeaderLength
|
||||
+ GuidLength
|
||||
+ (command == NmxItemControlCommand.AdviseSupervisory ? AdviseExtraLength : 0)
|
||||
+ PayloadLength;
|
||||
}
|
||||
|
||||
public static NmxItemControlMessage Parse(ReadOnlySpan<byte> body)
|
||||
{
|
||||
if (body.Length < HeaderLength + GuidLength + PayloadLength)
|
||||
{
|
||||
throw new ArgumentException("NMX item-control body is too short.", nameof(body));
|
||||
}
|
||||
|
||||
var command = (NmxItemControlCommand)body[0];
|
||||
if (command is not (NmxItemControlCommand.AdviseSupervisory or NmxItemControlCommand.UnAdvise))
|
||||
{
|
||||
throw new ArgumentException($"Unsupported item-control command 0x{body[0]:X2}.", nameof(body));
|
||||
}
|
||||
|
||||
ushort version = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(1, sizeof(ushort)));
|
||||
if (version != Version)
|
||||
{
|
||||
throw new ArgumentException($"Unsupported item-control version {version}.", nameof(body));
|
||||
}
|
||||
|
||||
int expectedLength = GetEncodedLength(command);
|
||||
if (body.Length != expectedLength)
|
||||
{
|
||||
throw new ArgumentException($"Unexpected item-control body length {body.Length}; expected {expectedLength}.", nameof(body));
|
||||
}
|
||||
|
||||
int offset = HeaderLength;
|
||||
var itemCorrelationId = new Guid(body.Slice(offset, GuidLength));
|
||||
offset += GuidLength;
|
||||
if (command == NmxItemControlCommand.AdviseSupervisory)
|
||||
{
|
||||
offset += AdviseExtraLength;
|
||||
}
|
||||
|
||||
return new NmxItemControlMessage(
|
||||
command,
|
||||
itemCorrelationId,
|
||||
BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(offset, sizeof(ushort))),
|
||||
BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(offset + 2, sizeof(ushort))),
|
||||
BinaryPrimitives.ReadInt16LittleEndian(body.Slice(offset + 4, sizeof(short))),
|
||||
BinaryPrimitives.ReadInt16LittleEndian(body.Slice(offset + 6, sizeof(short))),
|
||||
BinaryPrimitives.ReadInt16LittleEndian(body.Slice(offset + 8, sizeof(short))),
|
||||
BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(offset + 10, sizeof(ushort))),
|
||||
BinaryPrimitives.ReadInt16LittleEndian(body.Slice(offset + 12, sizeof(short))),
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(offset + 14, sizeof(uint))));
|
||||
}
|
||||
|
||||
public static NmxItemControlMessage FromReferenceHandle(
|
||||
NmxItemControlCommand command,
|
||||
Guid itemCorrelationId,
|
||||
MxReferenceHandle referenceHandle,
|
||||
uint tail = 3)
|
||||
{
|
||||
return new NmxItemControlMessage(
|
||||
command,
|
||||
itemCorrelationId,
|
||||
referenceHandle.ObjectId,
|
||||
referenceHandle.ObjectSignature,
|
||||
referenceHandle.PrimitiveId,
|
||||
referenceHandle.AttributeId,
|
||||
referenceHandle.PropertyId,
|
||||
referenceHandle.AttributeSignature,
|
||||
referenceHandle.AttributeIndex,
|
||||
tail);
|
||||
}
|
||||
|
||||
public MxReferenceHandle ToReferenceHandle(
|
||||
byte galaxyId = 1,
|
||||
ushort platformId = 1,
|
||||
ushort engineId = 1)
|
||||
{
|
||||
return new MxReferenceHandle(
|
||||
galaxyId,
|
||||
platformId,
|
||||
engineId,
|
||||
ObjectId,
|
||||
ObjectSignature,
|
||||
PrimitiveId,
|
||||
AttributeId,
|
||||
PropertyId,
|
||||
AttributeSignature,
|
||||
AttributeIndex);
|
||||
}
|
||||
|
||||
public byte[] Encode()
|
||||
{
|
||||
byte[] body = new byte[GetEncodedLength(Command)];
|
||||
body[0] = (byte)Command;
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(1, sizeof(ushort)), Version);
|
||||
int offset = HeaderLength;
|
||||
ItemCorrelationId.TryWriteBytes(body.AsSpan(offset, GuidLength));
|
||||
offset += GuidLength;
|
||||
if (Command == NmxItemControlCommand.AdviseSupervisory)
|
||||
{
|
||||
offset += AdviseExtraLength;
|
||||
}
|
||||
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(offset, sizeof(ushort)), ObjectId);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(offset + 2, sizeof(ushort)), ObjectSignature);
|
||||
BinaryPrimitives.WriteInt16LittleEndian(body.AsSpan(offset + 4, sizeof(short)), PrimitiveId);
|
||||
BinaryPrimitives.WriteInt16LittleEndian(body.AsSpan(offset + 6, sizeof(short)), AttributeId);
|
||||
BinaryPrimitives.WriteInt16LittleEndian(body.AsSpan(offset + 8, sizeof(short)), PropertyId);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(offset + 10, sizeof(ushort)), AttributeSignature);
|
||||
BinaryPrimitives.WriteInt16LittleEndian(body.AsSpan(offset + 12, sizeof(short)), AttributeIndex);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(offset + 14, sizeof(uint)), Tail);
|
||||
return body;
|
||||
}
|
||||
|
||||
public NmxItemControlMessage ToUnAdvise()
|
||||
{
|
||||
return this with { Command = NmxItemControlCommand.UnAdvise };
|
||||
}
|
||||
|
||||
public NmxItemControlMessage ToAdviseSupervisory()
|
||||
{
|
||||
return this with { Command = NmxItemControlCommand.AdviseSupervisory };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace MxNativeCodec;
|
||||
|
||||
public static class NmxMetadataQueryMessage
|
||||
{
|
||||
private const int PreAdviseCorrelationOffset = 0x8a;
|
||||
|
||||
public static byte[] EncodeObservedPreAdvise(Guid itemCorrelationId)
|
||||
{
|
||||
byte[] body = Convert.FromHexString(
|
||||
"17010001010001000000650071000a0000000000086a0000004000008144006500760050006c006100740066006f0072006d002e00470052002e00540069006d0065004f0066004c006100730074004400650070006c006f00790000000200000000000200000000000200000000000101000100010000000000000000000000000001d0fc40091f0100c0ca9ccd3265b046a585a583b2e77a5d000001000000" +
|
||||
"17010001010001000000650071000a000000000008760000004c00008144006500760050006c006100740066006f0072006d002e00470052002e00540069006d0065004f0066004c0061007300740043006f006e006600690067004300680061006e0067006500000002000000000002000000000002000000000001010001000100000000000000000000000000015003410920010002000000");
|
||||
itemCorrelationId.TryWriteBytes(body.AsSpan(PreAdviseCorrelationOffset, 16));
|
||||
return body;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
|
||||
namespace MxNativeCodec;
|
||||
|
||||
public sealed record NmxObservedEnvelope(
|
||||
bool HasLengthPrefix,
|
||||
int? TotalLengthPrefix,
|
||||
int DeclaredInnerLength,
|
||||
int ActualInnerLength,
|
||||
ReadOnlyMemory<byte> Header,
|
||||
ReadOnlyMemory<byte> InnerBody)
|
||||
{
|
||||
public const int HeaderLength = 46;
|
||||
public const int InnerLengthOffset = 2;
|
||||
|
||||
public static NmxObservedEnvelope ParseTransferDataBody(ReadOnlyMemory<byte> body)
|
||||
{
|
||||
if (body.Length < HeaderLength)
|
||||
{
|
||||
throw new ArgumentException("NMX TransferData body is too short.", nameof(body));
|
||||
}
|
||||
|
||||
int declaredInnerLength = BinaryPrimitives.ReadInt32LittleEndian(body.Span.Slice(InnerLengthOffset, sizeof(int)));
|
||||
int actualInnerLength = body.Length - HeaderLength;
|
||||
if (declaredInnerLength != actualInnerLength)
|
||||
{
|
||||
throw new ArgumentException("NMX TransferData inner length does not match body size.", nameof(body));
|
||||
}
|
||||
|
||||
return new NmxObservedEnvelope(
|
||||
false,
|
||||
null,
|
||||
declaredInnerLength,
|
||||
actualInnerLength,
|
||||
body[..HeaderLength],
|
||||
body.Slice(HeaderLength, actualInnerLength));
|
||||
}
|
||||
|
||||
public static NmxObservedEnvelope ParseProcessDataReceivedBody(ReadOnlyMemory<byte> body)
|
||||
{
|
||||
if (body.Length < sizeof(int) + HeaderLength)
|
||||
{
|
||||
throw new ArgumentException("NMX ProcessDataReceived body is too short.", nameof(body));
|
||||
}
|
||||
|
||||
int totalLengthPrefix = BinaryPrimitives.ReadInt32LittleEndian(body.Span[..sizeof(int)]);
|
||||
if (totalLengthPrefix != body.Length)
|
||||
{
|
||||
throw new ArgumentException("NMX ProcessDataReceived length prefix does not match body size.", nameof(body));
|
||||
}
|
||||
|
||||
int headerOffset = sizeof(int);
|
||||
int declaredInnerLength = BinaryPrimitives.ReadInt32LittleEndian(
|
||||
body.Span.Slice(headerOffset + InnerLengthOffset, sizeof(int)));
|
||||
int actualInnerLength = declaredInnerLength - sizeof(int);
|
||||
if (actualInnerLength < 0 || headerOffset + HeaderLength + actualInnerLength != body.Length)
|
||||
{
|
||||
throw new ArgumentException("NMX ProcessDataReceived inner length does not match body size.", nameof(body));
|
||||
}
|
||||
|
||||
return new NmxObservedEnvelope(
|
||||
true,
|
||||
totalLengthPrefix,
|
||||
declaredInnerLength,
|
||||
actualInnerLength,
|
||||
body.Slice(headerOffset, HeaderLength),
|
||||
body.Slice(headerOffset + HeaderLength, actualInnerLength));
|
||||
}
|
||||
|
||||
public static NmxObservedEnvelope ParseProcessDataReceivedBodyFlexible(ReadOnlyMemory<byte> body)
|
||||
{
|
||||
if (body.Length >= sizeof(int) + HeaderLength)
|
||||
{
|
||||
int totalLengthPrefix = BinaryPrimitives.ReadInt32LittleEndian(body.Span[..sizeof(int)]);
|
||||
if (totalLengthPrefix == body.Length)
|
||||
{
|
||||
return ParseProcessDataReceivedBody(body);
|
||||
}
|
||||
}
|
||||
|
||||
if (body.Length < HeaderLength)
|
||||
{
|
||||
throw new ArgumentException("NMX ProcessDataReceived body is too short.", nameof(body));
|
||||
}
|
||||
|
||||
int declaredInnerLength = BinaryPrimitives.ReadInt32LittleEndian(body.Span.Slice(InnerLengthOffset, sizeof(int)));
|
||||
int actualInnerLength = body.Length - HeaderLength;
|
||||
if (declaredInnerLength != actualInnerLength)
|
||||
{
|
||||
throw new ArgumentException("NMX ProcessDataReceived header inner length does not match body size.", nameof(body));
|
||||
}
|
||||
|
||||
return new NmxObservedEnvelope(
|
||||
false,
|
||||
null,
|
||||
declaredInnerLength,
|
||||
actualInnerLength,
|
||||
body[..HeaderLength],
|
||||
body.Slice(HeaderLength, actualInnerLength));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record NmxObservedString(int Offset, string Value);
|
||||
|
||||
public sealed record NmxObservedMessage(
|
||||
byte Command,
|
||||
string CommandName,
|
||||
byte VersionMajor,
|
||||
byte VersionMinor,
|
||||
Guid? ItemCorrelationId,
|
||||
IReadOnlyList<NmxObservedString> Strings)
|
||||
{
|
||||
public static NmxObservedMessage Parse(ReadOnlySpan<byte> body)
|
||||
{
|
||||
if (body.Length < 3)
|
||||
{
|
||||
throw new ArgumentException("NMX message body is too short.", nameof(body));
|
||||
}
|
||||
|
||||
byte command = body[0];
|
||||
Guid? itemCorrelationId = null;
|
||||
if (command is 0x1f or 0x21 && body.Length >= 19)
|
||||
{
|
||||
itemCorrelationId = new Guid(body.Slice(3, 16));
|
||||
}
|
||||
|
||||
return new NmxObservedMessage(
|
||||
command,
|
||||
GetCommandName(command),
|
||||
body[1],
|
||||
body[2],
|
||||
itemCorrelationId,
|
||||
ExtractUtf16Strings(body));
|
||||
}
|
||||
|
||||
private static string GetCommandName(byte command)
|
||||
{
|
||||
return command switch
|
||||
{
|
||||
0x17 => "MetadataQuery",
|
||||
0x1f => "AdviseSupervisory",
|
||||
0x21 => "UnAdvise",
|
||||
0x32 => "SubscriptionStatus",
|
||||
0x33 => "DataUpdate",
|
||||
0x37 => "Write",
|
||||
0x40 => "MetadataResponse",
|
||||
_ => $"Unknown0x{command:X2}",
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<NmxObservedString> ExtractUtf16Strings(ReadOnlySpan<byte> body)
|
||||
{
|
||||
List<NmxObservedString> strings = [];
|
||||
int offset = 0;
|
||||
while (offset + 8 <= body.Length)
|
||||
{
|
||||
int start = offset;
|
||||
int chars = 0;
|
||||
while (offset + 1 < body.Length)
|
||||
{
|
||||
byte lo = body[offset];
|
||||
byte hi = body[offset + 1];
|
||||
if (lo == 0 && hi == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (hi != 0 || lo < 0x20 || lo > 0x7e)
|
||||
{
|
||||
chars = 0;
|
||||
break;
|
||||
}
|
||||
|
||||
chars++;
|
||||
offset += 2;
|
||||
}
|
||||
|
||||
if (chars >= 3 && offset + 1 < body.Length && body[offset] == 0 && body[offset + 1] == 0)
|
||||
{
|
||||
string value = Encoding.Unicode.GetString(body.Slice(start, chars * 2));
|
||||
strings.Add(new NmxObservedString(start, value));
|
||||
offset += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
offset = start + 1;
|
||||
}
|
||||
|
||||
return strings;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
namespace MxNativeCodec;
|
||||
|
||||
public enum NmxOperationStatusFormat
|
||||
{
|
||||
CompletionOnly,
|
||||
StatusWord,
|
||||
}
|
||||
|
||||
public sealed record NmxOperationStatusMessage(
|
||||
NmxOperationStatusFormat Format,
|
||||
byte Command,
|
||||
ushort StatusCode,
|
||||
byte CompletionCode,
|
||||
MxStatus Status)
|
||||
{
|
||||
public bool IsMxAccessWriteComplete => Format == NmxOperationStatusFormat.StatusWord
|
||||
&& StatusCode == 0x8050
|
||||
&& CompletionCode == 0x00;
|
||||
|
||||
public static bool TryParseProcessDataReceivedBody(ReadOnlyMemory<byte> body, out NmxOperationStatusMessage message)
|
||||
{
|
||||
try
|
||||
{
|
||||
var envelope = NmxObservedEnvelope.ParseProcessDataReceivedBodyFlexible(body);
|
||||
return TryParseInner(envelope.InnerBody.Span, out message);
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
message = null!;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool TryParseInner(ReadOnlySpan<byte> inner, out NmxOperationStatusMessage message)
|
||||
{
|
||||
if (inner.Length == 1)
|
||||
{
|
||||
byte completionCode = inner[0];
|
||||
message = new NmxOperationStatusMessage(
|
||||
Format: NmxOperationStatusFormat.CompletionOnly,
|
||||
Command: 0,
|
||||
StatusCode: 0,
|
||||
CompletionCode: completionCode,
|
||||
Status: CreateUnpromotedCompletionStatus(completionCode));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (inner.Length == 5 && inner[0] == 0x00 && inner[1] == 0x00)
|
||||
{
|
||||
ushort statusCode = (ushort)(inner[2] | (inner[3] << 8));
|
||||
byte completionCode = inner[4];
|
||||
message = new NmxOperationStatusMessage(
|
||||
Format: NmxOperationStatusFormat.StatusWord,
|
||||
Command: inner[0],
|
||||
StatusCode: statusCode,
|
||||
CompletionCode: completionCode,
|
||||
Status: statusCode == 0x8050 && completionCode == 0x00 ? MxStatus.WriteCompleteOk : new MxStatus(
|
||||
Success: 0,
|
||||
Category: MxStatusCategory.Unknown,
|
||||
DetectedBy: MxStatusSource.Unknown,
|
||||
Detail: completionCode == 0x00 ? unchecked((short)statusCode) : completionCode));
|
||||
return true;
|
||||
}
|
||||
|
||||
message = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static MxStatus CreateUnpromotedCompletionStatus(byte completionCode)
|
||||
{
|
||||
return new MxStatus(
|
||||
Success: 0,
|
||||
Category: MxStatusCategory.Unknown,
|
||||
DetectedBy: MxStatusSource.Unknown,
|
||||
Detail: completionCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
|
||||
namespace MxNativeCodec;
|
||||
|
||||
public sealed record NmxReferenceRegistrationMessage(
|
||||
int ItemHandle,
|
||||
Guid ItemCorrelationId,
|
||||
string ItemDefinition,
|
||||
string ItemContext,
|
||||
bool Subscribe)
|
||||
{
|
||||
private const byte Command = 0x10;
|
||||
private const ushort Version = 1;
|
||||
private const int HeaderLength = 55;
|
||||
private const int ItemStringReservedLength = 8;
|
||||
private const int TailLength = 20;
|
||||
|
||||
public static NmxReferenceRegistrationMessage Parse(ReadOnlySpan<byte> body)
|
||||
{
|
||||
if (body.Length < HeaderLength + ItemStringReservedLength + TailLength)
|
||||
{
|
||||
throw new ArgumentException("NMX reference-registration body is too short.", nameof(body));
|
||||
}
|
||||
|
||||
if (body[0] != Command)
|
||||
{
|
||||
throw new ArgumentException($"Unsupported reference-registration command 0x{body[0]:X2}.", nameof(body));
|
||||
}
|
||||
|
||||
ushort version = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(1, sizeof(ushort)));
|
||||
if (version != Version)
|
||||
{
|
||||
throw new ArgumentException($"Unsupported reference-registration version {version}.", nameof(body));
|
||||
}
|
||||
|
||||
int itemHandle = BinaryPrimitives.ReadInt32LittleEndian(body.Slice(3, sizeof(int)));
|
||||
var correlationId = new Guid(body.Slice(7, 16));
|
||||
int offset = HeaderLength;
|
||||
string itemDefinition = ReadRegisteredString(body, ref offset, taggedLength: true);
|
||||
|
||||
if (body.Slice(offset, ItemStringReservedLength).IndexOfAnyExcept((byte)0) >= 0)
|
||||
{
|
||||
throw new ArgumentException("Unexpected nonzero reference-registration item string reserved bytes.", nameof(body));
|
||||
}
|
||||
|
||||
offset += ItemStringReservedLength;
|
||||
string itemContext = ReadRegisteredString(body, ref offset, taggedLength: false);
|
||||
if (body.Length - offset != TailLength)
|
||||
{
|
||||
throw new ArgumentException($"Unexpected reference-registration tail length {body.Length - offset}.", nameof(body));
|
||||
}
|
||||
|
||||
if (body.Slice(offset, TailLength - 1).IndexOfAnyExcept((byte)0) >= 0)
|
||||
{
|
||||
throw new ArgumentException("Unexpected nonzero reference-registration tail bytes.", nameof(body));
|
||||
}
|
||||
|
||||
return new NmxReferenceRegistrationMessage(
|
||||
itemHandle,
|
||||
correlationId,
|
||||
itemDefinition,
|
||||
itemContext,
|
||||
body[offset + TailLength - 1] != 0);
|
||||
}
|
||||
|
||||
public byte[] Encode()
|
||||
{
|
||||
byte[] itemDefinitionBytes = EncodeNullTerminatedUtf16(ItemDefinition);
|
||||
byte[] itemContextBytes = EncodeNullTerminatedUtf16(ItemContext);
|
||||
byte[] body = new byte[
|
||||
HeaderLength
|
||||
+ sizeof(int)
|
||||
+ itemDefinitionBytes.Length
|
||||
+ ItemStringReservedLength
|
||||
+ sizeof(int)
|
||||
+ itemContextBytes.Length
|
||||
+ TailLength];
|
||||
|
||||
body[0] = Command;
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(1, sizeof(ushort)), Version);
|
||||
BinaryPrimitives.WriteInt32LittleEndian(body.AsSpan(3, sizeof(int)), ItemHandle);
|
||||
ItemCorrelationId.TryWriteBytes(body.AsSpan(7, 16));
|
||||
|
||||
BinaryPrimitives.WriteInt16LittleEndian(body.AsSpan(23, sizeof(short)), -1);
|
||||
BinaryPrimitives.WriteInt32LittleEndian(body.AsSpan(27, sizeof(int)), 1);
|
||||
|
||||
int offset = HeaderLength;
|
||||
WriteRegisteredString(body, ref offset, itemDefinitionBytes, taggedLength: true);
|
||||
offset += ItemStringReservedLength;
|
||||
WriteRegisteredString(body, ref offset, itemContextBytes, taggedLength: false);
|
||||
body[offset + TailLength - 1] = Subscribe ? (byte)1 : (byte)0;
|
||||
return body;
|
||||
}
|
||||
|
||||
public static string ToBufferedItemDefinition(string itemDefinition)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(itemDefinition);
|
||||
return itemDefinition.EndsWith(".property(buffer)", StringComparison.OrdinalIgnoreCase)
|
||||
? itemDefinition
|
||||
: itemDefinition + ".property(buffer)";
|
||||
}
|
||||
|
||||
private static string ReadRegisteredString(ReadOnlySpan<byte> body, ref int offset, bool taggedLength)
|
||||
{
|
||||
int rawLength = BinaryPrimitives.ReadInt32LittleEndian(body.Slice(offset, sizeof(int)));
|
||||
int byteLength = taggedLength ? rawLength & 0x00FF_FFFF : rawLength;
|
||||
if (taggedLength && (rawLength & unchecked((int)0xFF00_0000)) != unchecked((int)0x8100_0000))
|
||||
{
|
||||
throw new ArgumentException("Reference-registration item definition is missing the observed 0x81 string marker.");
|
||||
}
|
||||
|
||||
offset += sizeof(int);
|
||||
if (byteLength < 2 || byteLength % 2 != 0 || offset + byteLength > body.Length)
|
||||
{
|
||||
throw new ArgumentException($"Invalid reference-registration string byte length {byteLength}.");
|
||||
}
|
||||
|
||||
string value = Encoding.Unicode.GetString(body.Slice(offset, byteLength - 2));
|
||||
if (body[offset + byteLength - 2] != 0 || body[offset + byteLength - 1] != 0)
|
||||
{
|
||||
throw new ArgumentException("Reference-registration string is not null terminated.");
|
||||
}
|
||||
|
||||
offset += byteLength;
|
||||
return value;
|
||||
}
|
||||
|
||||
private static void WriteRegisteredString(byte[] body, ref int offset, byte[] value, bool taggedLength)
|
||||
{
|
||||
int rawLength = taggedLength ? value.Length | unchecked((int)0x8100_0000) : value.Length;
|
||||
BinaryPrimitives.WriteInt32LittleEndian(body.AsSpan(offset, sizeof(int)), rawLength);
|
||||
offset += sizeof(int);
|
||||
value.CopyTo(body.AsSpan(offset));
|
||||
offset += value.Length;
|
||||
}
|
||||
|
||||
private static byte[] EncodeNullTerminatedUtf16(string value)
|
||||
{
|
||||
return Encoding.Unicode.GetBytes(value + '\0');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
|
||||
namespace MxNativeCodec;
|
||||
|
||||
public sealed record NmxReferenceRegistrationResultMessage(
|
||||
int ItemHandle,
|
||||
Guid ItemCorrelationId,
|
||||
DateTime FirstTimestampUtc,
|
||||
DateTime SecondTimestampUtc,
|
||||
byte StatusCategory,
|
||||
byte StatusDetail,
|
||||
string ItemDefinition,
|
||||
int MxDataType,
|
||||
string ItemContext)
|
||||
{
|
||||
private const byte Command = 0x11;
|
||||
private const ushort Version = 1;
|
||||
private const int HeaderLength = 45;
|
||||
private const int BetweenStringsLength = 10;
|
||||
private const int TailLength = 16;
|
||||
|
||||
public static NmxReferenceRegistrationResultMessage Parse(ReadOnlySpan<byte> body)
|
||||
{
|
||||
if (body.Length < HeaderLength + TailLength)
|
||||
{
|
||||
throw new ArgumentException("NMX reference-registration result body is too short.", nameof(body));
|
||||
}
|
||||
|
||||
if (body[0] != Command)
|
||||
{
|
||||
throw new ArgumentException($"Unsupported reference-registration result command 0x{body[0]:X2}.", nameof(body));
|
||||
}
|
||||
|
||||
ushort version = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(1, sizeof(ushort)));
|
||||
if (version != Version)
|
||||
{
|
||||
throw new ArgumentException($"Unsupported reference-registration result version {version}.", nameof(body));
|
||||
}
|
||||
|
||||
int blockLength = BinaryPrimitives.ReadInt32LittleEndian(body.Slice(41, sizeof(int)));
|
||||
if (blockLength != body.Length - 41)
|
||||
{
|
||||
throw new ArgumentException("Reference-registration result block length does not match body size.", nameof(body));
|
||||
}
|
||||
|
||||
int offset = HeaderLength;
|
||||
string itemDefinition = ReadRegisteredString(body, ref offset, taggedLength: true);
|
||||
int mxDataType = BinaryPrimitives.ReadInt32LittleEndian(body.Slice(offset, sizeof(int)));
|
||||
offset += sizeof(int);
|
||||
|
||||
if (body.Slice(offset, BetweenStringsLength - sizeof(int)).IndexOfAnyExcept((byte)0) >= 0)
|
||||
{
|
||||
throw new ArgumentException("Unexpected nonzero reference-registration result reserved bytes.", nameof(body));
|
||||
}
|
||||
|
||||
offset += BetweenStringsLength - sizeof(int);
|
||||
string itemContext = ReadRegisteredString(body, ref offset, taggedLength: false);
|
||||
if (body.Length - offset != TailLength)
|
||||
{
|
||||
throw new ArgumentException($"Unexpected reference-registration result tail length {body.Length - offset}.", nameof(body));
|
||||
}
|
||||
|
||||
if (body.Slice(offset, TailLength).IndexOfAnyExcept((byte)0) >= 0)
|
||||
{
|
||||
throw new ArgumentException("Unexpected nonzero reference-registration result tail bytes.", nameof(body));
|
||||
}
|
||||
|
||||
return new NmxReferenceRegistrationResultMessage(
|
||||
BinaryPrimitives.ReadInt32LittleEndian(body.Slice(3, sizeof(int))),
|
||||
new Guid(body.Slice(7, 16)),
|
||||
DateTime.FromFileTimeUtc(BinaryPrimitives.ReadInt64LittleEndian(body.Slice(23, sizeof(long)))),
|
||||
DateTime.FromFileTimeUtc(BinaryPrimitives.ReadInt64LittleEndian(body.Slice(31, sizeof(long)))),
|
||||
body[39],
|
||||
body[40],
|
||||
itemDefinition,
|
||||
mxDataType,
|
||||
itemContext);
|
||||
}
|
||||
|
||||
public static bool TryParseProcessDataReceivedBody(ReadOnlyMemory<byte> processDataBody, out NmxReferenceRegistrationResultMessage? message)
|
||||
{
|
||||
try
|
||||
{
|
||||
var envelope = NmxObservedEnvelope.ParseProcessDataReceivedBodyFlexible(processDataBody);
|
||||
message = Parse(envelope.InnerBody.Span);
|
||||
return true;
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
message = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ReadRegisteredString(ReadOnlySpan<byte> body, ref int offset, bool taggedLength)
|
||||
{
|
||||
int rawLength = BinaryPrimitives.ReadInt32LittleEndian(body.Slice(offset, sizeof(int)));
|
||||
int byteLength = taggedLength ? rawLength & 0x00FF_FFFF : rawLength;
|
||||
if (taggedLength && (rawLength & unchecked((int)0xFF00_0000)) != unchecked((int)0x8100_0000))
|
||||
{
|
||||
throw new ArgumentException("Reference-registration result item definition is missing the observed 0x81 string marker.");
|
||||
}
|
||||
|
||||
offset += sizeof(int);
|
||||
if (byteLength < 2 || byteLength % 2 != 0 || offset + byteLength > body.Length)
|
||||
{
|
||||
throw new ArgumentException($"Invalid reference-registration result string byte length {byteLength}.");
|
||||
}
|
||||
|
||||
string value = Encoding.Unicode.GetString(body.Slice(offset, byteLength - 2));
|
||||
if (body[offset + byteLength - 2] != 0 || body[offset + byteLength - 1] != 0)
|
||||
{
|
||||
throw new ArgumentException("Reference-registration result string is not null terminated.");
|
||||
}
|
||||
|
||||
offset += byteLength;
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
|
||||
namespace MxNativeCodec;
|
||||
|
||||
public static class NmxSecuredWrite2Message
|
||||
{
|
||||
public const byte Command = 0x38;
|
||||
public const ushort Version = 1;
|
||||
public const int AuthenticatorTokenLength = 16;
|
||||
|
||||
public static readonly byte[] ObservedAuthenticatedUserToken =
|
||||
[
|
||||
0x07, 0xb9, 0xa9, 0xf4,
|
||||
0x72, 0x6e, 0xae, 0x48,
|
||||
0x83, 0xb5, 0xbb, 0xde,
|
||||
0x91, 0x8c, 0x89, 0x0f,
|
||||
];
|
||||
|
||||
public static byte[] Encode(
|
||||
MxReferenceHandle referenceHandle,
|
||||
MxValueKind valueKind,
|
||||
object value,
|
||||
DateTime timestamp,
|
||||
string clientName,
|
||||
ReadOnlySpan<byte> currentUserToken,
|
||||
ReadOnlySpan<byte> verifierUserToken,
|
||||
int writeIndex = 1,
|
||||
uint clientToken = 0)
|
||||
{
|
||||
if (currentUserToken.Length != AuthenticatorTokenLength)
|
||||
{
|
||||
throw new ArgumentException("Current user token must be 16 bytes.", nameof(currentUserToken));
|
||||
}
|
||||
|
||||
if (verifierUserToken.Length != AuthenticatorTokenLength)
|
||||
{
|
||||
throw new ArgumentException("Verifier user token must be 16 bytes.", nameof(verifierUserToken));
|
||||
}
|
||||
|
||||
byte[] timestampedPrefix = NmxWriteMessage.EncodeTimestamped(
|
||||
referenceHandle,
|
||||
valueKind,
|
||||
value,
|
||||
timestamp,
|
||||
writeIndex,
|
||||
clientToken: 0);
|
||||
timestampedPrefix[0] = Command;
|
||||
|
||||
byte[] clientNameBytes = Encoding.Unicode.GetBytes((clientName ?? string.Empty) + '\0');
|
||||
int prefixLength = timestampedPrefix.Length - sizeof(uint) - sizeof(int);
|
||||
byte[] body = new byte[prefixLength + AuthenticatorTokenLength + sizeof(int) + clientNameBytes.Length + AuthenticatorTokenLength + sizeof(short) + sizeof(uint) + sizeof(int)];
|
||||
|
||||
timestampedPrefix.AsSpan(0, prefixLength).CopyTo(body);
|
||||
int offset = prefixLength;
|
||||
currentUserToken.CopyTo(body.AsSpan(offset, AuthenticatorTokenLength));
|
||||
offset += AuthenticatorTokenLength;
|
||||
BinaryPrimitives.WriteInt32LittleEndian(body.AsSpan(offset, sizeof(int)), clientNameBytes.Length);
|
||||
offset += sizeof(int);
|
||||
clientNameBytes.CopyTo(body.AsSpan(offset));
|
||||
offset += clientNameBytes.Length;
|
||||
verifierUserToken.CopyTo(body.AsSpan(offset, AuthenticatorTokenLength));
|
||||
offset += AuthenticatorTokenLength;
|
||||
BinaryPrimitives.WriteInt16LittleEndian(body.AsSpan(offset, sizeof(short)), -1);
|
||||
offset += sizeof(short);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(offset, sizeof(uint)), clientToken);
|
||||
offset += sizeof(uint);
|
||||
BinaryPrimitives.WriteInt32LittleEndian(body.AsSpan(offset, sizeof(int)), writeIndex);
|
||||
return body;
|
||||
}
|
||||
|
||||
public static byte[] EncodeBoolean(
|
||||
MxReferenceHandle referenceHandle,
|
||||
bool value,
|
||||
DateTime timestamp,
|
||||
string clientName,
|
||||
ReadOnlySpan<byte> currentUserToken,
|
||||
ReadOnlySpan<byte> verifierUserToken,
|
||||
int writeIndex = 1,
|
||||
uint clientToken = 0)
|
||||
{
|
||||
return Encode(
|
||||
referenceHandle,
|
||||
MxValueKind.Boolean,
|
||||
value,
|
||||
timestamp,
|
||||
clientName,
|
||||
currentUserToken,
|
||||
verifierUserToken,
|
||||
writeIndex,
|
||||
clientToken);
|
||||
}
|
||||
|
||||
public static byte[] ResolveObservedUserToken(int userId)
|
||||
{
|
||||
return userId == 0
|
||||
? new byte[AuthenticatorTokenLength]
|
||||
: ObservedAuthenticatedUserToken.ToArray();
|
||||
}
|
||||
|
||||
public static string FormatToken(ReadOnlySpan<byte> token)
|
||||
{
|
||||
return Convert.ToHexString(token).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
|
||||
namespace MxNativeCodec;
|
||||
|
||||
public sealed record NmxCallbackValue(
|
||||
byte WireKind,
|
||||
MxValueKind? ValueKind,
|
||||
object? Value,
|
||||
int EncodedLength);
|
||||
|
||||
public sealed record NmxSubscriptionRecord(
|
||||
int Status,
|
||||
int? DetailStatus,
|
||||
ushort Quality,
|
||||
DateTime TimestampUtc,
|
||||
byte WireKind,
|
||||
object? Value,
|
||||
int Offset,
|
||||
int Length)
|
||||
{
|
||||
public MxStatus ToDataChangeStatus()
|
||||
{
|
||||
return MxStatus.DataChangeOk;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record NmxSubscriptionMessage(
|
||||
byte Command,
|
||||
ushort Version,
|
||||
int RecordCount,
|
||||
Guid OperationId,
|
||||
Guid? ItemCorrelationId,
|
||||
IReadOnlyList<NmxSubscriptionRecord> Records)
|
||||
{
|
||||
public const byte SubscriptionStatusCommand = 0x32;
|
||||
public const byte DataUpdateCommand = 0x33;
|
||||
|
||||
public static NmxSubscriptionMessage ParseProcessDataReceivedBody(ReadOnlyMemory<byte> body)
|
||||
{
|
||||
var envelope = NmxObservedEnvelope.ParseProcessDataReceivedBodyFlexible(body);
|
||||
return ParseInner(envelope.InnerBody.Span);
|
||||
}
|
||||
|
||||
public static NmxSubscriptionMessage ParseInner(ReadOnlySpan<byte> inner)
|
||||
{
|
||||
if (inner.Length < 23)
|
||||
{
|
||||
throw new ArgumentException("NMX subscription callback body is too short.", nameof(inner));
|
||||
}
|
||||
|
||||
byte command = inner[0];
|
||||
ushort version = BinaryPrimitives.ReadUInt16LittleEndian(inner.Slice(1, sizeof(ushort)));
|
||||
int recordCount = BinaryPrimitives.ReadInt32LittleEndian(inner.Slice(3, sizeof(int)));
|
||||
var operationId = new Guid(inner.Slice(7, 16));
|
||||
|
||||
return command switch
|
||||
{
|
||||
SubscriptionStatusCommand => ParseSubscriptionStatus(inner, version, recordCount, operationId),
|
||||
DataUpdateCommand => ParseDataUpdate(inner, version, recordCount, operationId),
|
||||
_ => throw new ArgumentException($"Unsupported NMX subscription callback command 0x{command:X2}.", nameof(inner)),
|
||||
};
|
||||
}
|
||||
|
||||
private static NmxSubscriptionMessage ParseDataUpdate(
|
||||
ReadOnlySpan<byte> inner,
|
||||
ushort version,
|
||||
int recordCount,
|
||||
Guid operationId)
|
||||
{
|
||||
if (recordCount != 1)
|
||||
{
|
||||
throw new ArgumentException("Observed NMX DataUpdate callback parser currently supports one record per body.", nameof(inner));
|
||||
}
|
||||
|
||||
const int recordOffset = 23;
|
||||
var record = ParseRecord(inner, recordOffset, hasDetailStatus: false);
|
||||
return new NmxSubscriptionMessage(
|
||||
DataUpdateCommand,
|
||||
version,
|
||||
recordCount,
|
||||
operationId,
|
||||
null,
|
||||
[record]);
|
||||
}
|
||||
|
||||
private static NmxSubscriptionMessage ParseSubscriptionStatus(
|
||||
ReadOnlySpan<byte> inner,
|
||||
ushort version,
|
||||
int recordCount,
|
||||
Guid operationId)
|
||||
{
|
||||
if (inner.Length < 39)
|
||||
{
|
||||
throw new ArgumentException("NMX SubscriptionStatus callback body is too short.", nameof(inner));
|
||||
}
|
||||
|
||||
var itemCorrelationId = new Guid(inner.Slice(23, 16));
|
||||
int offset = 39;
|
||||
List<NmxSubscriptionRecord> records = [];
|
||||
for (int i = 0; i < recordCount; i++)
|
||||
{
|
||||
var record = ParseRecord(inner, offset, hasDetailStatus: true);
|
||||
records.Add(record);
|
||||
offset += record.Length;
|
||||
}
|
||||
|
||||
return new NmxSubscriptionMessage(
|
||||
SubscriptionStatusCommand,
|
||||
version,
|
||||
recordCount,
|
||||
operationId,
|
||||
itemCorrelationId,
|
||||
records);
|
||||
}
|
||||
|
||||
private static NmxSubscriptionRecord ParseRecord(ReadOnlySpan<byte> body, int offset, bool hasDetailStatus)
|
||||
{
|
||||
int minimumLength = hasDetailStatus ? 19 : 15;
|
||||
if (offset < 0 || offset + minimumLength > body.Length)
|
||||
{
|
||||
throw new ArgumentException("NMX subscription record is too short.", nameof(body));
|
||||
}
|
||||
|
||||
int start = offset;
|
||||
int status = BinaryPrimitives.ReadInt32LittleEndian(body.Slice(offset, sizeof(int)));
|
||||
offset += sizeof(int);
|
||||
|
||||
int? detailStatus = null;
|
||||
if (hasDetailStatus)
|
||||
{
|
||||
detailStatus = BinaryPrimitives.ReadInt32LittleEndian(body.Slice(offset, sizeof(int)));
|
||||
offset += sizeof(int);
|
||||
}
|
||||
|
||||
ushort quality = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(offset, sizeof(ushort)));
|
||||
offset += sizeof(ushort);
|
||||
|
||||
long fileTime = BinaryPrimitives.ReadInt64LittleEndian(body.Slice(offset, sizeof(long)));
|
||||
offset += sizeof(long);
|
||||
|
||||
byte wireKind = body[offset++];
|
||||
var value = DecodeValue(wireKind, body[offset..]);
|
||||
offset += value.EncodedLength;
|
||||
|
||||
return new NmxSubscriptionRecord(
|
||||
status,
|
||||
detailStatus,
|
||||
quality,
|
||||
DateTime.FromFileTimeUtc(fileTime),
|
||||
wireKind,
|
||||
value.Value,
|
||||
start,
|
||||
offset - start);
|
||||
}
|
||||
|
||||
private static NmxCallbackValue DecodeValue(byte wireKind, ReadOnlySpan<byte> body)
|
||||
{
|
||||
if (body.Length == 0)
|
||||
{
|
||||
return new NmxCallbackValue(wireKind, ToValueKindOrNull(wireKind), null, 0);
|
||||
}
|
||||
|
||||
return wireKind switch
|
||||
{
|
||||
0x01 when body.Length >= 1 => new NmxCallbackValue(wireKind, MxValueKind.Boolean, body[0] != 0, 1),
|
||||
0x02 when body.Length >= sizeof(int) => new NmxCallbackValue(wireKind, MxValueKind.Int32, BinaryPrimitives.ReadInt32LittleEndian(body[..sizeof(int)]), sizeof(int)),
|
||||
0x03 when body.Length >= sizeof(float) => new NmxCallbackValue(wireKind, MxValueKind.Float32, BitConverter.Int32BitsToSingle(BinaryPrimitives.ReadInt32LittleEndian(body[..sizeof(int)])), sizeof(float)),
|
||||
0x04 when body.Length >= sizeof(double) => new NmxCallbackValue(wireKind, MxValueKind.Float64, BitConverter.Int64BitsToDouble(BinaryPrimitives.ReadInt64LittleEndian(body[..sizeof(long)])), sizeof(double)),
|
||||
0x05 => DecodeStringValue(wireKind, body),
|
||||
0x06 => DecodeDateTimeValue(wireKind, body),
|
||||
0x07 => DecodeElapsedTimeValue(wireKind, body),
|
||||
0x41 or 0x42 or 0x43 or 0x44 or 0x45 or 0x46 => DecodeArrayValue(wireKind, body),
|
||||
_ => new NmxCallbackValue(wireKind, ToValueKindOrNull(wireKind), null, 0),
|
||||
};
|
||||
}
|
||||
|
||||
private static NmxCallbackValue DecodeStringValue(byte wireKind, ReadOnlySpan<byte> body)
|
||||
{
|
||||
if (body.Length < sizeof(int))
|
||||
{
|
||||
return new NmxCallbackValue(wireKind, MxValueKind.String, null, 0);
|
||||
}
|
||||
|
||||
int recordLength = BinaryPrimitives.ReadInt32LittleEndian(body[..sizeof(int)]);
|
||||
if (recordLength == sizeof(int))
|
||||
{
|
||||
return new NmxCallbackValue(wireKind, MxValueKind.String, string.Empty, sizeof(int));
|
||||
}
|
||||
|
||||
if (body.Length < 8)
|
||||
{
|
||||
return new NmxCallbackValue(wireKind, MxValueKind.String, null, 0);
|
||||
}
|
||||
|
||||
int textByteLength = BinaryPrimitives.ReadInt32LittleEndian(body.Slice(4, sizeof(int)));
|
||||
if (recordLength < 8 || textByteLength < 0 || recordLength != textByteLength + 4 || body.Length < 8 + textByteLength)
|
||||
{
|
||||
return new NmxCallbackValue(wireKind, MxValueKind.String, null, 0);
|
||||
}
|
||||
|
||||
ReadOnlySpan<byte> textBytes = body.Slice(8, textByteLength);
|
||||
if (textBytes.Length >= 2 && textBytes[^2] == 0 && textBytes[^1] == 0)
|
||||
{
|
||||
textBytes = textBytes[..^2];
|
||||
}
|
||||
|
||||
string value = Encoding.Unicode.GetString(textBytes);
|
||||
return new NmxCallbackValue(wireKind, MxValueKind.String, value, 8 + textByteLength);
|
||||
}
|
||||
|
||||
private static NmxCallbackValue DecodeDateTimeValue(byte wireKind, ReadOnlySpan<byte> body)
|
||||
{
|
||||
if (body.Length >= 14)
|
||||
{
|
||||
int recordLength = BinaryPrimitives.ReadInt32LittleEndian(body[..sizeof(int)]);
|
||||
if (recordLength >= 10 && body.Length >= sizeof(int) + recordLength)
|
||||
{
|
||||
long fileTime = BinaryPrimitives.ReadInt64LittleEndian(body.Slice(sizeof(int), sizeof(long)));
|
||||
if (TryFromFileTimeUtc(fileTime, out DateTime timestamp))
|
||||
{
|
||||
return new NmxCallbackValue(
|
||||
wireKind,
|
||||
MxValueKind.DateTime,
|
||||
timestamp,
|
||||
sizeof(int) + recordLength);
|
||||
}
|
||||
|
||||
return new NmxCallbackValue(wireKind, MxValueKind.DateTime, null, sizeof(int) + recordLength);
|
||||
}
|
||||
}
|
||||
|
||||
if (body.Length >= sizeof(long))
|
||||
{
|
||||
long fileTime = BinaryPrimitives.ReadInt64LittleEndian(body[..sizeof(long)]);
|
||||
if (TryFromFileTimeUtc(fileTime, out DateTime timestamp))
|
||||
{
|
||||
return new NmxCallbackValue(wireKind, MxValueKind.DateTime, timestamp, sizeof(long));
|
||||
}
|
||||
}
|
||||
|
||||
return new NmxCallbackValue(wireKind, MxValueKind.DateTime, null, 0);
|
||||
}
|
||||
|
||||
private static NmxCallbackValue DecodeElapsedTimeValue(byte wireKind, ReadOnlySpan<byte> body)
|
||||
{
|
||||
if (body.Length < sizeof(int))
|
||||
{
|
||||
return new NmxCallbackValue(wireKind, MxValueKind.ElapsedTime, null, 0);
|
||||
}
|
||||
|
||||
int milliseconds = BinaryPrimitives.ReadInt32LittleEndian(body[..sizeof(int)]);
|
||||
return new NmxCallbackValue(wireKind, MxValueKind.ElapsedTime, TimeSpan.FromMilliseconds(milliseconds), sizeof(int));
|
||||
}
|
||||
|
||||
private static NmxCallbackValue DecodeArrayValue(byte wireKind, ReadOnlySpan<byte> body)
|
||||
{
|
||||
const int arrayHeaderLength = 10;
|
||||
if (body.Length < arrayHeaderLength)
|
||||
{
|
||||
return new NmxCallbackValue(wireKind, ToValueKindOrNull(wireKind), null, 0);
|
||||
}
|
||||
|
||||
ushort count = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(4, sizeof(ushort)));
|
||||
int elementWidth = BinaryPrimitives.ReadInt32LittleEndian(body.Slice(6, sizeof(int)));
|
||||
ReadOnlySpan<byte> values = body[arrayHeaderLength..];
|
||||
|
||||
return wireKind switch
|
||||
{
|
||||
0x41 => DecodeBooleanArray(wireKind, count, elementWidth, body.Length, values),
|
||||
0x42 => DecodeInt32Array(wireKind, count, elementWidth, body.Length, values),
|
||||
0x43 => DecodeFloat32Array(wireKind, count, elementWidth, body.Length, values),
|
||||
0x44 => DecodeFloat64Array(wireKind, count, elementWidth, body.Length, values),
|
||||
0x45 => DecodeStringArray(wireKind, count, values),
|
||||
0x46 => DecodeDateTimeArray(wireKind, count, elementWidth, body.Length, values),
|
||||
_ => new NmxCallbackValue(wireKind, ToValueKindOrNull(wireKind), null, 0),
|
||||
};
|
||||
}
|
||||
|
||||
private static NmxCallbackValue DecodeBooleanArray(byte wireKind, ushort count, int elementWidth, int bodyLength, ReadOnlySpan<byte> values)
|
||||
{
|
||||
if (elementWidth != sizeof(short) || values.Length < count * elementWidth)
|
||||
{
|
||||
return new NmxCallbackValue(wireKind, MxValueKind.BooleanArray, null, 0);
|
||||
}
|
||||
|
||||
bool[] decoded = new bool[count];
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
decoded[i] = BinaryPrimitives.ReadInt16LittleEndian(values.Slice(i * elementWidth, sizeof(short))) != 0;
|
||||
}
|
||||
|
||||
return new NmxCallbackValue(wireKind, MxValueKind.BooleanArray, decoded, 10 + count * elementWidth);
|
||||
}
|
||||
|
||||
private static NmxCallbackValue DecodeInt32Array(byte wireKind, ushort count, int elementWidth, int bodyLength, ReadOnlySpan<byte> values)
|
||||
{
|
||||
if (elementWidth != sizeof(int) || values.Length < count * elementWidth)
|
||||
{
|
||||
return new NmxCallbackValue(wireKind, MxValueKind.Int32Array, null, 0);
|
||||
}
|
||||
|
||||
int[] decoded = new int[count];
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
decoded[i] = BinaryPrimitives.ReadInt32LittleEndian(values.Slice(i * elementWidth, sizeof(int)));
|
||||
}
|
||||
|
||||
return new NmxCallbackValue(wireKind, MxValueKind.Int32Array, decoded, 10 + count * elementWidth);
|
||||
}
|
||||
|
||||
private static NmxCallbackValue DecodeFloat32Array(byte wireKind, ushort count, int elementWidth, int bodyLength, ReadOnlySpan<byte> values)
|
||||
{
|
||||
if (elementWidth != sizeof(float) || values.Length < count * elementWidth)
|
||||
{
|
||||
return new NmxCallbackValue(wireKind, MxValueKind.Float32Array, null, 0);
|
||||
}
|
||||
|
||||
float[] decoded = new float[count];
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
decoded[i] = BitConverter.Int32BitsToSingle(BinaryPrimitives.ReadInt32LittleEndian(values.Slice(i * elementWidth, sizeof(int))));
|
||||
}
|
||||
|
||||
return new NmxCallbackValue(wireKind, MxValueKind.Float32Array, decoded, 10 + count * elementWidth);
|
||||
}
|
||||
|
||||
private static NmxCallbackValue DecodeFloat64Array(byte wireKind, ushort count, int elementWidth, int bodyLength, ReadOnlySpan<byte> values)
|
||||
{
|
||||
if (elementWidth != sizeof(double) || values.Length < count * elementWidth)
|
||||
{
|
||||
return new NmxCallbackValue(wireKind, MxValueKind.Float64Array, null, 0);
|
||||
}
|
||||
|
||||
double[] decoded = new double[count];
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
decoded[i] = BitConverter.Int64BitsToDouble(BinaryPrimitives.ReadInt64LittleEndian(values.Slice(i * elementWidth, sizeof(long))));
|
||||
}
|
||||
|
||||
return new NmxCallbackValue(wireKind, MxValueKind.Float64Array, decoded, 10 + count * elementWidth);
|
||||
}
|
||||
|
||||
private static NmxCallbackValue DecodeDateTimeArray(byte wireKind, ushort count, int elementWidth, int bodyLength, ReadOnlySpan<byte> values)
|
||||
{
|
||||
if (elementWidth != 12 || values.Length < count * elementWidth)
|
||||
{
|
||||
return new NmxCallbackValue(wireKind, MxValueKind.DateTimeArray, null, 0);
|
||||
}
|
||||
|
||||
DateTime[] decoded = new DateTime[count];
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
long fileTime = BinaryPrimitives.ReadInt64LittleEndian(values.Slice(i * elementWidth, sizeof(long)));
|
||||
decoded[i] = DateTime.FromFileTimeUtc(fileTime);
|
||||
}
|
||||
|
||||
return new NmxCallbackValue(wireKind, MxValueKind.DateTimeArray, decoded, 10 + count * elementWidth);
|
||||
}
|
||||
|
||||
private static NmxCallbackValue DecodeStringArray(byte wireKind, ushort count, ReadOnlySpan<byte> values)
|
||||
{
|
||||
string[] decoded = new string[count];
|
||||
int offset = 0;
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
if (offset + 13 > values.Length)
|
||||
{
|
||||
return new NmxCallbackValue(wireKind, MxValueKind.StringArray, null, 0);
|
||||
}
|
||||
|
||||
int recordLength = BinaryPrimitives.ReadInt32LittleEndian(values.Slice(offset, sizeof(int)));
|
||||
byte elementKind = values[offset + 4];
|
||||
int textRecordLength = BinaryPrimitives.ReadInt32LittleEndian(values.Slice(offset + 5, sizeof(int)));
|
||||
int textByteLength = BinaryPrimitives.ReadInt32LittleEndian(values.Slice(offset + 9, sizeof(int)));
|
||||
if (recordLength < 9 || elementKind != 0x05 || textRecordLength != textByteLength + sizeof(int) || recordLength != 1 + sizeof(int) + sizeof(int) + textByteLength || offset + 13 + textByteLength > values.Length)
|
||||
{
|
||||
return new NmxCallbackValue(wireKind, MxValueKind.StringArray, null, 0);
|
||||
}
|
||||
|
||||
ReadOnlySpan<byte> textBytes = values.Slice(offset + 13, textByteLength);
|
||||
if (textBytes.Length >= 2 && textBytes[^2] == 0 && textBytes[^1] == 0)
|
||||
{
|
||||
textBytes = textBytes[..^2];
|
||||
}
|
||||
|
||||
decoded[i] = Encoding.Unicode.GetString(textBytes);
|
||||
offset += 13 + textByteLength;
|
||||
}
|
||||
|
||||
return new NmxCallbackValue(wireKind, MxValueKind.StringArray, decoded, 10 + offset);
|
||||
}
|
||||
|
||||
private static MxValueKind? ToValueKindOrNull(byte wireKind)
|
||||
{
|
||||
return wireKind switch
|
||||
{
|
||||
0x01 => MxValueKind.Boolean,
|
||||
0x02 => MxValueKind.Int32,
|
||||
0x03 => MxValueKind.Float32,
|
||||
0x04 => MxValueKind.Float64,
|
||||
0x05 => MxValueKind.String,
|
||||
0x06 => MxValueKind.DateTime,
|
||||
0x07 => MxValueKind.ElapsedTime,
|
||||
0x41 => MxValueKind.BooleanArray,
|
||||
0x42 => MxValueKind.Int32Array,
|
||||
0x43 => MxValueKind.Float32Array,
|
||||
0x44 => MxValueKind.Float64Array,
|
||||
0x45 => MxValueKind.StringArray,
|
||||
0x46 => MxValueKind.DateTimeArray,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
private static bool TryFromFileTimeUtc(long fileTime, out DateTime timestamp)
|
||||
{
|
||||
try
|
||||
{
|
||||
timestamp = DateTime.FromFileTimeUtc(fileTime);
|
||||
return true;
|
||||
}
|
||||
catch (ArgumentOutOfRangeException)
|
||||
{
|
||||
timestamp = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using System.Buffers.Binary;
|
||||
|
||||
namespace MxNativeCodec;
|
||||
|
||||
public enum NmxTransferMessageKind : int
|
||||
{
|
||||
Metadata = 1,
|
||||
ItemControl = 2,
|
||||
Write = 3,
|
||||
}
|
||||
|
||||
public sealed record NmxTransferEnvelope(
|
||||
NmxTransferMessageKind MessageKind,
|
||||
int SourceGalaxyId,
|
||||
int SourcePlatformId,
|
||||
int LocalEngineId,
|
||||
int TargetGalaxyId,
|
||||
int TargetPlatformId,
|
||||
int TargetEngineId,
|
||||
int TimeoutMilliseconds,
|
||||
ReadOnlyMemory<byte> InnerBody)
|
||||
{
|
||||
public const int HeaderLength = 46;
|
||||
private const ushort Version = 1;
|
||||
private const int InnerLengthOffset = 2;
|
||||
private const int ReservedOffset = 6;
|
||||
private const int MessageKindOffset = 10;
|
||||
private const int SourceGalaxyOffset = 14;
|
||||
private const int SourcePlatformOffset = 18;
|
||||
private const int LocalEngineOffset = 22;
|
||||
private const int TargetGalaxyOffset = 26;
|
||||
private const int TargetPlatformOffset = 30;
|
||||
private const int TargetEngineOffset = 34;
|
||||
private const int ProtocolMarkerOffset = 38;
|
||||
private const int TimeoutOffset = 42;
|
||||
private const int ProtocolMarker = 0x0201;
|
||||
private const int DefaultTimeoutMilliseconds = 30000;
|
||||
|
||||
public static NmxTransferEnvelope Parse(ReadOnlyMemory<byte> transferBody)
|
||||
{
|
||||
if (transferBody.Length < HeaderLength)
|
||||
{
|
||||
throw new ArgumentException("NMX TransferData body is too short.", nameof(transferBody));
|
||||
}
|
||||
|
||||
ReadOnlySpan<byte> span = transferBody.Span;
|
||||
ushort version = BinaryPrimitives.ReadUInt16LittleEndian(span[..sizeof(ushort)]);
|
||||
if (version != Version)
|
||||
{
|
||||
throw new ArgumentException($"Unsupported NMX TransferData version {version}.", nameof(transferBody));
|
||||
}
|
||||
|
||||
int innerLength = BinaryPrimitives.ReadInt32LittleEndian(span.Slice(InnerLengthOffset, sizeof(int)));
|
||||
if (innerLength != transferBody.Length - HeaderLength)
|
||||
{
|
||||
throw new ArgumentException("NMX TransferData inner length does not match the body size.", nameof(transferBody));
|
||||
}
|
||||
|
||||
int protocolMarker = BinaryPrimitives.ReadInt32LittleEndian(span.Slice(ProtocolMarkerOffset, sizeof(int)));
|
||||
if (protocolMarker != ProtocolMarker)
|
||||
{
|
||||
throw new ArgumentException($"Unsupported NMX TransferData protocol marker 0x{protocolMarker:X8}.", nameof(transferBody));
|
||||
}
|
||||
|
||||
return new NmxTransferEnvelope(
|
||||
(NmxTransferMessageKind)BinaryPrimitives.ReadInt32LittleEndian(span.Slice(MessageKindOffset, sizeof(int))),
|
||||
BinaryPrimitives.ReadInt32LittleEndian(span.Slice(SourceGalaxyOffset, sizeof(int))),
|
||||
BinaryPrimitives.ReadInt32LittleEndian(span.Slice(SourcePlatformOffset, sizeof(int))),
|
||||
BinaryPrimitives.ReadInt32LittleEndian(span.Slice(LocalEngineOffset, sizeof(int))),
|
||||
BinaryPrimitives.ReadInt32LittleEndian(span.Slice(TargetGalaxyOffset, sizeof(int))),
|
||||
BinaryPrimitives.ReadInt32LittleEndian(span.Slice(TargetPlatformOffset, sizeof(int))),
|
||||
BinaryPrimitives.ReadInt32LittleEndian(span.Slice(TargetEngineOffset, sizeof(int))),
|
||||
BinaryPrimitives.ReadInt32LittleEndian(span.Slice(TimeoutOffset, sizeof(int))),
|
||||
transferBody[HeaderLength..]);
|
||||
}
|
||||
|
||||
public static byte[] Encode(
|
||||
NmxTransferMessageKind messageKind,
|
||||
int localEngineId,
|
||||
int targetGalaxyId,
|
||||
int targetPlatformId,
|
||||
int targetEngineId,
|
||||
ReadOnlySpan<byte> innerBody,
|
||||
int sourceGalaxyId = 1,
|
||||
int sourcePlatformId = 1,
|
||||
int timeoutMilliseconds = DefaultTimeoutMilliseconds)
|
||||
{
|
||||
byte[] transferBody = new byte[HeaderLength + innerBody.Length];
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(transferBody.AsSpan(0, sizeof(ushort)), Version);
|
||||
BinaryPrimitives.WriteInt32LittleEndian(transferBody.AsSpan(InnerLengthOffset, sizeof(int)), innerBody.Length);
|
||||
BinaryPrimitives.WriteInt32LittleEndian(transferBody.AsSpan(ReservedOffset, sizeof(int)), 0);
|
||||
BinaryPrimitives.WriteInt32LittleEndian(transferBody.AsSpan(MessageKindOffset, sizeof(int)), (int)messageKind);
|
||||
BinaryPrimitives.WriteInt32LittleEndian(transferBody.AsSpan(SourceGalaxyOffset, sizeof(int)), sourceGalaxyId);
|
||||
BinaryPrimitives.WriteInt32LittleEndian(transferBody.AsSpan(SourcePlatformOffset, sizeof(int)), sourcePlatformId);
|
||||
BinaryPrimitives.WriteInt32LittleEndian(transferBody.AsSpan(LocalEngineOffset, sizeof(int)), localEngineId);
|
||||
BinaryPrimitives.WriteInt32LittleEndian(transferBody.AsSpan(TargetGalaxyOffset, sizeof(int)), targetGalaxyId);
|
||||
BinaryPrimitives.WriteInt32LittleEndian(transferBody.AsSpan(TargetPlatformOffset, sizeof(int)), targetPlatformId);
|
||||
BinaryPrimitives.WriteInt32LittleEndian(transferBody.AsSpan(TargetEngineOffset, sizeof(int)), targetEngineId);
|
||||
BinaryPrimitives.WriteInt32LittleEndian(transferBody.AsSpan(ProtocolMarkerOffset, sizeof(int)), ProtocolMarker);
|
||||
BinaryPrimitives.WriteInt32LittleEndian(transferBody.AsSpan(TimeoutOffset, sizeof(int)), timeoutMilliseconds);
|
||||
innerBody.CopyTo(transferBody.AsSpan(HeaderLength));
|
||||
return transferBody;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using System.Buffers.Binary;
|
||||
|
||||
namespace MxNativeCodec;
|
||||
|
||||
public sealed class NmxTransferEnvelopeTemplate
|
||||
{
|
||||
public const int HeaderLength = 46;
|
||||
public const int InnerLengthOffset = 2;
|
||||
|
||||
private readonly byte[] _header;
|
||||
|
||||
private NmxTransferEnvelopeTemplate(byte[] header)
|
||||
{
|
||||
_header = header;
|
||||
}
|
||||
|
||||
public static NmxTransferEnvelopeTemplate FromObserved(ReadOnlySpan<byte> observedTransferBody)
|
||||
{
|
||||
if (observedTransferBody.Length < HeaderLength)
|
||||
{
|
||||
throw new ArgumentException("Observed TransferData body is too short.", nameof(observedTransferBody));
|
||||
}
|
||||
|
||||
int innerLength = BinaryPrimitives.ReadInt32LittleEndian(observedTransferBody.Slice(InnerLengthOffset, sizeof(int)));
|
||||
if (innerLength != observedTransferBody.Length - HeaderLength)
|
||||
{
|
||||
throw new ArgumentException("Observed TransferData body does not contain the expected inner length.", nameof(observedTransferBody));
|
||||
}
|
||||
|
||||
return new NmxTransferEnvelopeTemplate(observedTransferBody[..HeaderLength].ToArray());
|
||||
}
|
||||
|
||||
public byte[] Encode(ReadOnlySpan<byte> innerPutRequestBody)
|
||||
{
|
||||
byte[] body = new byte[HeaderLength + innerPutRequestBody.Length];
|
||||
_header.CopyTo(body, 0);
|
||||
BinaryPrimitives.WriteInt32LittleEndian(body.AsSpan(InnerLengthOffset, sizeof(int)), innerPutRequestBody.Length);
|
||||
innerPutRequestBody.CopyTo(body.AsSpan(HeaderLength));
|
||||
return body;
|
||||
}
|
||||
|
||||
public ReadOnlyMemory<byte> DecodeInner(ReadOnlyMemory<byte> transferBody)
|
||||
{
|
||||
if (transferBody.Length < HeaderLength)
|
||||
{
|
||||
throw new ArgumentException("TransferData body is too short.", nameof(transferBody));
|
||||
}
|
||||
|
||||
int innerLength = BinaryPrimitives.ReadInt32LittleEndian(transferBody.Span.Slice(InnerLengthOffset, sizeof(int)));
|
||||
if (innerLength != transferBody.Length - HeaderLength)
|
||||
{
|
||||
throw new ArgumentException("TransferData body inner length does not match the body size.", nameof(transferBody));
|
||||
}
|
||||
|
||||
return transferBody[HeaderLength..];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace MxNativeCodec;
|
||||
|
||||
public static class NmxWriteMessage
|
||||
{
|
||||
public const byte Command = 0x37;
|
||||
public const ushort Version = 1;
|
||||
public const int HandleProjectionOffset = 3;
|
||||
public const int HandleProjectionLength = 14;
|
||||
public const int KindOffset = 17;
|
||||
|
||||
public static byte[] Encode(
|
||||
MxReferenceHandle referenceHandle,
|
||||
MxValueKind valueKind,
|
||||
object value,
|
||||
int writeIndex = 1,
|
||||
uint clientToken = 0)
|
||||
{
|
||||
byte[] valueBytes = EncodeValue(valueKind, value);
|
||||
byte[] body = valueKind switch
|
||||
{
|
||||
MxValueKind.Boolean => CreateBoolean(referenceHandle, valueKind, valueBytes, writeIndex, clientToken),
|
||||
MxValueKind.Int32 or MxValueKind.Float32 => CreateFixed(referenceHandle, valueKind, valueBytes, writeIndex, clientToken),
|
||||
MxValueKind.Float64 => CreateFixed(referenceHandle, valueKind, valueBytes, writeIndex, clientToken),
|
||||
MxValueKind.String or MxValueKind.DateTime => CreateVariable(referenceHandle, valueKind, valueBytes, writeIndex, clientToken),
|
||||
MxValueKind.BooleanArray or MxValueKind.Int32Array or MxValueKind.Float32Array or MxValueKind.Float64Array => CreateArray(referenceHandle, valueKind, valueBytes, GetArrayCount(value), GetArrayElementWidth(valueKind), writeIndex, clientToken),
|
||||
MxValueKind.StringArray or MxValueKind.DateTimeArray => CreateArray(referenceHandle, valueKind, valueBytes, GetArrayCount(value), elementWidth: 4, writeIndex, clientToken),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(valueKind), valueKind, null),
|
||||
};
|
||||
return body;
|
||||
}
|
||||
|
||||
public static byte[] EncodeTimestamped(
|
||||
MxReferenceHandle referenceHandle,
|
||||
MxValueKind valueKind,
|
||||
object value,
|
||||
DateTime timestamp,
|
||||
int writeIndex = 1,
|
||||
uint clientToken = 0)
|
||||
{
|
||||
byte[] valueBytes = EncodeValue(valueKind, value);
|
||||
byte[] body = valueKind switch
|
||||
{
|
||||
MxValueKind.Boolean => CreateBooleanTimestamped(referenceHandle, value, timestamp, writeIndex, clientToken),
|
||||
MxValueKind.Int32 or MxValueKind.Float32 => CreateFixedTimestamped(referenceHandle, valueKind, valueBytes, timestamp, writeIndex, clientToken),
|
||||
MxValueKind.Float64 => CreateFixedTimestamped(referenceHandle, valueKind, valueBytes, timestamp, writeIndex, clientToken),
|
||||
MxValueKind.String or MxValueKind.DateTime => CreateVariableTimestamped(referenceHandle, valueKind, valueBytes, timestamp, writeIndex, clientToken),
|
||||
MxValueKind.BooleanArray or MxValueKind.Int32Array or MxValueKind.Float32Array or MxValueKind.Float64Array => CreateArrayTimestamped(referenceHandle, valueKind, valueBytes, GetArrayCount(value), GetArrayElementWidth(valueKind), timestamp, writeIndex, clientToken),
|
||||
MxValueKind.StringArray or MxValueKind.DateTimeArray => CreateArrayTimestamped(referenceHandle, valueKind, valueBytes, GetArrayCount(value), elementWidth: 4, timestamp, writeIndex, clientToken),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(valueKind), valueKind, null),
|
||||
};
|
||||
return body;
|
||||
}
|
||||
|
||||
public static MxValueKind GetValueKind(short mxDataType, bool isArray)
|
||||
{
|
||||
if (TryGetValueKind(mxDataType, isArray, out MxValueKind valueKind))
|
||||
{
|
||||
return valueKind;
|
||||
}
|
||||
|
||||
throw new ArgumentOutOfRangeException(nameof(mxDataType), $"Unsupported MX data type {mxDataType} with isArray={isArray}.");
|
||||
}
|
||||
|
||||
public static bool TryGetValueKind(short mxDataType, bool isArray, out MxValueKind valueKind)
|
||||
{
|
||||
return (mxDataType, isArray) switch
|
||||
{
|
||||
(1, false) => Return(MxValueKind.Boolean, out valueKind),
|
||||
(2, false) => Return(MxValueKind.Int32, out valueKind),
|
||||
(3, false) => Return(MxValueKind.Float32, out valueKind),
|
||||
(4, false) => Return(MxValueKind.Float64, out valueKind),
|
||||
(5, false) => Return(MxValueKind.String, out valueKind),
|
||||
(6, false) => Return(MxValueKind.DateTime, out valueKind),
|
||||
(1, true) => Return(MxValueKind.BooleanArray, out valueKind),
|
||||
(2, true) => Return(MxValueKind.Int32Array, out valueKind),
|
||||
(3, true) => Return(MxValueKind.Float32Array, out valueKind),
|
||||
(4, true) => Return(MxValueKind.Float64Array, out valueKind),
|
||||
(5, true) => Return(MxValueKind.StringArray, out valueKind),
|
||||
(6, true) => Return(MxValueKind.DateTimeArray, out valueKind),
|
||||
_ => Return(default, out valueKind, success: false),
|
||||
};
|
||||
}
|
||||
|
||||
private static bool Return(MxValueKind source, out MxValueKind target, bool success = true)
|
||||
{
|
||||
target = source;
|
||||
return success;
|
||||
}
|
||||
|
||||
public static byte GetWireKind(MxValueKind valueKind)
|
||||
{
|
||||
return valueKind switch
|
||||
{
|
||||
MxValueKind.Boolean => 0x01,
|
||||
MxValueKind.Int32 => 0x02,
|
||||
MxValueKind.Float32 => 0x03,
|
||||
MxValueKind.Float64 => 0x04,
|
||||
MxValueKind.String or MxValueKind.DateTime => 0x05,
|
||||
MxValueKind.BooleanArray => 0x41,
|
||||
MxValueKind.Int32Array => 0x42,
|
||||
MxValueKind.Float32Array => 0x43,
|
||||
MxValueKind.Float64Array => 0x44,
|
||||
MxValueKind.StringArray or MxValueKind.DateTimeArray => 0x45,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(valueKind), valueKind, null),
|
||||
};
|
||||
}
|
||||
|
||||
private static byte[] CreateFixed(MxReferenceHandle referenceHandle, MxValueKind valueKind, byte[] valueBytes, int writeIndex, uint clientToken)
|
||||
{
|
||||
byte[] body = new byte[KindOffset + 1 + valueBytes.Length + 14 + sizeof(int)];
|
||||
WriteCommonPrefix(body, referenceHandle, valueKind);
|
||||
valueBytes.CopyTo(body.AsSpan(KindOffset + 1));
|
||||
WriteNormalSuffix(body.AsSpan(KindOffset + 1 + valueBytes.Length), writeIndex, clientToken);
|
||||
return body;
|
||||
}
|
||||
|
||||
private static byte[] CreateBoolean(MxReferenceHandle referenceHandle, MxValueKind valueKind, byte[] valueBytes, int writeIndex, uint clientToken)
|
||||
{
|
||||
byte[] body = new byte[KindOffset + 1 + valueBytes.Length + 11 + sizeof(int)];
|
||||
WriteCommonPrefix(body, referenceHandle, valueKind);
|
||||
valueBytes.CopyTo(body.AsSpan(KindOffset + 1));
|
||||
WriteBooleanSuffix(body.AsSpan(KindOffset + 1 + valueBytes.Length), writeIndex, clientToken);
|
||||
return body;
|
||||
}
|
||||
|
||||
private static byte[] CreateBooleanTimestamped(MxReferenceHandle referenceHandle, object value, DateTime timestamp, int writeIndex, uint clientToken)
|
||||
{
|
||||
byte[] body = new byte[KindOffset + 1 + sizeof(byte) + 14 + sizeof(int)];
|
||||
WriteCommonPrefix(body, referenceHandle, MxValueKind.Boolean);
|
||||
body[KindOffset + 1] = Convert.ToBoolean(value, CultureInfo.InvariantCulture) ? (byte)0xff : (byte)0x00;
|
||||
WriteTimestampedSuffix(body.AsSpan(KindOffset + 2), timestamp, writeIndex, clientToken);
|
||||
return body;
|
||||
}
|
||||
|
||||
private static byte[] CreateFixedTimestamped(MxReferenceHandle referenceHandle, MxValueKind valueKind, byte[] valueBytes, DateTime timestamp, int writeIndex, uint clientToken)
|
||||
{
|
||||
byte[] body = new byte[KindOffset + 1 + valueBytes.Length + 14 + sizeof(int)];
|
||||
WriteCommonPrefix(body, referenceHandle, valueKind);
|
||||
valueBytes.CopyTo(body.AsSpan(KindOffset + 1));
|
||||
WriteTimestampedSuffix(body.AsSpan(KindOffset + 1 + valueBytes.Length), timestamp, writeIndex, clientToken);
|
||||
return body;
|
||||
}
|
||||
|
||||
private static byte[] CreateVariable(MxReferenceHandle referenceHandle, MxValueKind valueKind, byte[] valueBytes, int writeIndex, uint clientToken)
|
||||
{
|
||||
byte[] body = new byte[KindOffset + 1 + sizeof(int) + sizeof(int) + valueBytes.Length + 14 + sizeof(int)];
|
||||
WriteCommonPrefix(body, referenceHandle, valueKind);
|
||||
BinaryPrimitives.WriteInt32LittleEndian(body.AsSpan(18, sizeof(int)), valueBytes.Length + sizeof(int));
|
||||
BinaryPrimitives.WriteInt32LittleEndian(body.AsSpan(22, sizeof(int)), valueBytes.Length);
|
||||
valueBytes.CopyTo(body.AsSpan(26));
|
||||
WriteNormalSuffix(body.AsSpan(26 + valueBytes.Length), writeIndex, clientToken);
|
||||
return body;
|
||||
}
|
||||
|
||||
private static byte[] CreateVariableTimestamped(MxReferenceHandle referenceHandle, MxValueKind valueKind, byte[] valueBytes, DateTime timestamp, int writeIndex, uint clientToken)
|
||||
{
|
||||
byte[] body = new byte[KindOffset + 1 + sizeof(int) + sizeof(int) + valueBytes.Length + 14 + sizeof(int)];
|
||||
WriteCommonPrefix(body, referenceHandle, valueKind);
|
||||
BinaryPrimitives.WriteInt32LittleEndian(body.AsSpan(18, sizeof(int)), valueBytes.Length + sizeof(int));
|
||||
BinaryPrimitives.WriteInt32LittleEndian(body.AsSpan(22, sizeof(int)), valueBytes.Length);
|
||||
valueBytes.CopyTo(body.AsSpan(26));
|
||||
WriteTimestampedSuffix(body.AsSpan(26 + valueBytes.Length), timestamp, writeIndex, clientToken);
|
||||
return body;
|
||||
}
|
||||
|
||||
private static byte[] CreateArray(
|
||||
MxReferenceHandle referenceHandle,
|
||||
MxValueKind valueKind,
|
||||
byte[] valueBytes,
|
||||
int count,
|
||||
ushort elementWidth,
|
||||
int writeIndex,
|
||||
uint clientToken)
|
||||
{
|
||||
byte[] body = new byte[KindOffset + 1 + 10 + valueBytes.Length + 14 + sizeof(int)];
|
||||
WriteCommonPrefix(body, referenceHandle, valueKind);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(22, sizeof(ushort)), checked((ushort)count));
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(24, sizeof(ushort)), elementWidth);
|
||||
valueBytes.CopyTo(body.AsSpan(28));
|
||||
WriteNormalSuffix(body.AsSpan(28 + valueBytes.Length), writeIndex, clientToken);
|
||||
return body;
|
||||
}
|
||||
|
||||
private static byte[] CreateArrayTimestamped(
|
||||
MxReferenceHandle referenceHandle,
|
||||
MxValueKind valueKind,
|
||||
byte[] valueBytes,
|
||||
int count,
|
||||
ushort elementWidth,
|
||||
DateTime timestamp,
|
||||
int writeIndex,
|
||||
uint clientToken)
|
||||
{
|
||||
byte[] body = new byte[KindOffset + 1 + 10 + valueBytes.Length + 14 + sizeof(int)];
|
||||
WriteCommonPrefix(body, referenceHandle, valueKind);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(22, sizeof(ushort)), checked((ushort)count));
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(24, sizeof(ushort)), elementWidth);
|
||||
valueBytes.CopyTo(body.AsSpan(28));
|
||||
WriteTimestampedSuffix(body.AsSpan(28 + valueBytes.Length), timestamp, writeIndex, clientToken);
|
||||
return body;
|
||||
}
|
||||
|
||||
private static void WriteCommonPrefix(byte[] body, MxReferenceHandle referenceHandle, MxValueKind valueKind)
|
||||
{
|
||||
body[0] = Command;
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(1, sizeof(ushort)), Version);
|
||||
referenceHandle.Encode().AsSpan(6, HandleProjectionLength).CopyTo(body.AsSpan(HandleProjectionOffset, HandleProjectionLength));
|
||||
body[KindOffset] = GetWireKind(valueKind);
|
||||
}
|
||||
|
||||
private static void WriteNormalSuffix(Span<byte> suffixAndIndex, int writeIndex, uint clientToken)
|
||||
{
|
||||
if (suffixAndIndex.Length != 18)
|
||||
{
|
||||
throw new ArgumentException("Normal write suffix span must include the 14-byte suffix and 4-byte write index.", nameof(suffixAndIndex));
|
||||
}
|
||||
|
||||
BinaryPrimitives.WriteInt16LittleEndian(suffixAndIndex[..sizeof(short)], -1);
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(suffixAndIndex.Slice(2, sizeof(ulong)), 0);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(suffixAndIndex.Slice(10, sizeof(uint)), clientToken);
|
||||
BinaryPrimitives.WriteInt32LittleEndian(suffixAndIndex.Slice(14, sizeof(int)), writeIndex);
|
||||
}
|
||||
|
||||
private static void WriteBooleanSuffix(Span<byte> suffixAndIndex, int writeIndex, uint clientToken)
|
||||
{
|
||||
if (suffixAndIndex.Length != 15)
|
||||
{
|
||||
throw new ArgumentException("Boolean write suffix span must include the 11-byte suffix and 4-byte write index.", nameof(suffixAndIndex));
|
||||
}
|
||||
|
||||
suffixAndIndex[..7].Clear();
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(suffixAndIndex.Slice(7, sizeof(uint)), clientToken);
|
||||
BinaryPrimitives.WriteInt32LittleEndian(suffixAndIndex.Slice(11, sizeof(int)), writeIndex);
|
||||
}
|
||||
|
||||
private static void WriteTimestampedSuffix(Span<byte> suffixAndIndex, DateTime timestamp, int writeIndex, uint clientToken)
|
||||
{
|
||||
if (suffixAndIndex.Length != 18)
|
||||
{
|
||||
throw new ArgumentException("Timestamped write suffix span must include the 14-byte suffix and 4-byte write index.", nameof(suffixAndIndex));
|
||||
}
|
||||
|
||||
BinaryPrimitives.WriteInt16LittleEndian(suffixAndIndex[..sizeof(short)], 0);
|
||||
BinaryPrimitives.WriteInt64LittleEndian(suffixAndIndex.Slice(2, sizeof(long)), timestamp.ToFileTime());
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(suffixAndIndex.Slice(10, sizeof(uint)), clientToken);
|
||||
BinaryPrimitives.WriteInt32LittleEndian(suffixAndIndex.Slice(14, sizeof(int)), writeIndex);
|
||||
}
|
||||
|
||||
private static byte[] EncodeValue(MxValueKind valueKind, object value)
|
||||
{
|
||||
return valueKind switch
|
||||
{
|
||||
MxValueKind.Boolean => Convert.ToBoolean(value, CultureInfo.InvariantCulture) ? [0xff, 0xff, 0xff, 0x00] : [0x00, 0xff, 0xff, 0x00],
|
||||
MxValueKind.Int32 => EncodeInt32(Convert.ToInt32(value, CultureInfo.InvariantCulture)),
|
||||
MxValueKind.Float32 => EncodeFloat32(Convert.ToSingle(value, CultureInfo.InvariantCulture)),
|
||||
MxValueKind.Float64 => EncodeFloat64(Convert.ToDouble(value, CultureInfo.InvariantCulture)),
|
||||
MxValueKind.String => EncodeUtf16String(Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty),
|
||||
MxValueKind.DateTime => EncodeUtf16String(FormatDateTime((DateTime)value)),
|
||||
MxValueKind.BooleanArray => EncodeBooleanArray((bool[])value),
|
||||
MxValueKind.Int32Array => EncodeInt32Array((int[])value),
|
||||
MxValueKind.Float32Array => EncodeFloat32Array((float[])value),
|
||||
MxValueKind.Float64Array => EncodeFloat64Array((double[])value),
|
||||
MxValueKind.StringArray => EncodeVariableArray(((string[])value).Select(static value => value ?? string.Empty)),
|
||||
MxValueKind.DateTimeArray => EncodeVariableArray(((DateTime[])value).Select(FormatDateTime)),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(valueKind), valueKind, null),
|
||||
};
|
||||
}
|
||||
|
||||
private static byte[] EncodeInt32(int value)
|
||||
{
|
||||
byte[] bytes = new byte[sizeof(int)];
|
||||
BinaryPrimitives.WriteInt32LittleEndian(bytes, value);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private static byte[] EncodeFloat32(float value)
|
||||
{
|
||||
byte[] bytes = new byte[sizeof(float)];
|
||||
BinaryPrimitives.WriteInt32LittleEndian(bytes, BitConverter.SingleToInt32Bits(value));
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private static byte[] EncodeFloat64(double value)
|
||||
{
|
||||
byte[] bytes = new byte[sizeof(double)];
|
||||
BinaryPrimitives.WriteInt64LittleEndian(bytes, BitConverter.DoubleToInt64Bits(value));
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private static byte[] EncodeUtf16String(string value)
|
||||
{
|
||||
byte[] textBytes = Encoding.Unicode.GetBytes(value);
|
||||
byte[] bytes = new byte[textBytes.Length + 2];
|
||||
textBytes.CopyTo(bytes, 0);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private static byte[] EncodeBooleanArray(bool[] values)
|
||||
{
|
||||
byte[] bytes = new byte[values.Length * sizeof(short)];
|
||||
for (int i = 0; i < values.Length; i++)
|
||||
{
|
||||
BinaryPrimitives.WriteInt16LittleEndian(bytes.AsSpan(i * sizeof(short), sizeof(short)), values[i] ? (short)-1 : (short)0);
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private static byte[] EncodeInt32Array(int[] values)
|
||||
{
|
||||
byte[] bytes = new byte[values.Length * sizeof(int)];
|
||||
for (int i = 0; i < values.Length; i++)
|
||||
{
|
||||
BinaryPrimitives.WriteInt32LittleEndian(bytes.AsSpan(i * sizeof(int), sizeof(int)), values[i]);
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private static byte[] EncodeFloat32Array(float[] values)
|
||||
{
|
||||
byte[] bytes = new byte[values.Length * sizeof(float)];
|
||||
for (int i = 0; i < values.Length; i++)
|
||||
{
|
||||
BinaryPrimitives.WriteInt32LittleEndian(bytes.AsSpan(i * sizeof(float), sizeof(float)), BitConverter.SingleToInt32Bits(values[i]));
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private static byte[] EncodeFloat64Array(double[] values)
|
||||
{
|
||||
byte[] bytes = new byte[values.Length * sizeof(double)];
|
||||
for (int i = 0; i < values.Length; i++)
|
||||
{
|
||||
BinaryPrimitives.WriteInt64LittleEndian(bytes.AsSpan(i * sizeof(double), sizeof(double)), BitConverter.DoubleToInt64Bits(values[i]));
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private static byte[] EncodeVariableArray(IEnumerable<string> values)
|
||||
{
|
||||
using var stream = new MemoryStream();
|
||||
foreach (string value in values)
|
||||
{
|
||||
byte[] textBytes = EncodeUtf16String(value);
|
||||
byte[] header = new byte[13];
|
||||
BinaryPrimitives.WriteInt32LittleEndian(header.AsSpan(0, 4), 1 + sizeof(int) + sizeof(int) + textBytes.Length);
|
||||
header[4] = 0x05;
|
||||
BinaryPrimitives.WriteInt32LittleEndian(header.AsSpan(5, 4), textBytes.Length + sizeof(int));
|
||||
BinaryPrimitives.WriteInt32LittleEndian(header.AsSpan(9, 4), textBytes.Length);
|
||||
stream.Write(header);
|
||||
stream.Write(textBytes);
|
||||
}
|
||||
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
private static int GetArrayCount(object value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
bool[] values => values.Length,
|
||||
int[] values => values.Length,
|
||||
float[] values => values.Length,
|
||||
double[] values => values.Length,
|
||||
string[] values => values.Length,
|
||||
DateTime[] values => values.Length,
|
||||
_ => throw new ArgumentException("Value is not a supported MX array.", nameof(value)),
|
||||
};
|
||||
}
|
||||
|
||||
private static ushort GetArrayElementWidth(MxValueKind valueKind)
|
||||
{
|
||||
return valueKind switch
|
||||
{
|
||||
MxValueKind.BooleanArray => sizeof(short),
|
||||
MxValueKind.Int32Array => sizeof(int),
|
||||
MxValueKind.Float32Array => sizeof(float),
|
||||
MxValueKind.Float64Array => sizeof(double),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(valueKind), valueKind, null),
|
||||
};
|
||||
}
|
||||
|
||||
private static string FormatDateTime(DateTime value)
|
||||
{
|
||||
return value.ToString("M/d/yyyy h:mm:ss tt", CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace MxNativeCodec;
|
||||
|
||||
public sealed class ObservedWriteBodyTemplate
|
||||
{
|
||||
public const int FixedValueOffset = 18;
|
||||
public const int VariableValueOffset = 26;
|
||||
public const int ArrayValueOffset = 28;
|
||||
|
||||
private readonly byte[] _prefix;
|
||||
private readonly byte[] _suffixBeforeWriteIndex;
|
||||
private readonly MxValueKind _kind;
|
||||
|
||||
private ObservedWriteBodyTemplate(MxValueKind kind, byte[] prefix, byte[] suffixBeforeWriteIndex)
|
||||
{
|
||||
_kind = kind;
|
||||
_prefix = prefix;
|
||||
_suffixBeforeWriteIndex = suffixBeforeWriteIndex;
|
||||
}
|
||||
|
||||
public MxValueKind Kind => _kind;
|
||||
|
||||
public static ObservedWriteBodyTemplate FromObserved(MxValueKind kind, ReadOnlySpan<byte> observedBody)
|
||||
{
|
||||
if (observedBody.Length < 24)
|
||||
{
|
||||
throw new ArgumentException("Observed body is too short.", nameof(observedBody));
|
||||
}
|
||||
|
||||
return kind switch
|
||||
{
|
||||
MxValueKind.Boolean => CreateFixed(kind, observedBody, valueWidth: 4),
|
||||
MxValueKind.Int32 => CreateFixed(kind, observedBody, valueWidth: 4),
|
||||
MxValueKind.Float32 => CreateFixed(kind, observedBody, valueWidth: 4),
|
||||
MxValueKind.Float64 => CreateFixed(kind, observedBody, valueWidth: 8),
|
||||
MxValueKind.String => CreateVariable(kind, observedBody),
|
||||
MxValueKind.DateTime => CreateVariable(kind, observedBody),
|
||||
MxValueKind.BooleanArray => CreateArray(kind, observedBody),
|
||||
MxValueKind.Int32Array => CreateArray(kind, observedBody),
|
||||
MxValueKind.Float32Array => CreateArray(kind, observedBody),
|
||||
MxValueKind.Float64Array => CreateArray(kind, observedBody),
|
||||
MxValueKind.StringArray => CreateArray(kind, observedBody),
|
||||
MxValueKind.DateTimeArray => CreateArray(kind, observedBody),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null),
|
||||
};
|
||||
}
|
||||
|
||||
public byte[] Encode(object value, int writeIndex)
|
||||
{
|
||||
byte[] valueBytes = EncodeValue(value);
|
||||
byte[] body = new byte[_prefix.Length + valueBytes.Length + _suffixBeforeWriteIndex.Length + sizeof(int)];
|
||||
|
||||
_prefix.CopyTo(body, 0);
|
||||
valueBytes.CopyTo(body, _prefix.Length);
|
||||
_suffixBeforeWriteIndex.CopyTo(body, _prefix.Length + valueBytes.Length);
|
||||
BinaryPrimitives.WriteInt32LittleEndian(body.AsSpan(body.Length - sizeof(int)), writeIndex);
|
||||
PatchVariableLengths(body, valueBytes.Length);
|
||||
PatchArrayDescriptor(body, valueBytes.Length);
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
public object Decode(ReadOnlySpan<byte> body)
|
||||
{
|
||||
return _kind switch
|
||||
{
|
||||
MxValueKind.Boolean => DecodeBoolean(body),
|
||||
MxValueKind.Int32 => BinaryPrimitives.ReadInt32LittleEndian(body.Slice(FixedValueOffset, 4)),
|
||||
MxValueKind.Float32 => BitConverter.Int32BitsToSingle(BinaryPrimitives.ReadInt32LittleEndian(body.Slice(FixedValueOffset, 4))),
|
||||
MxValueKind.Float64 => BitConverter.Int64BitsToDouble(BinaryPrimitives.ReadInt64LittleEndian(body.Slice(FixedValueOffset, 8))),
|
||||
MxValueKind.String => DecodeUtf16String(body),
|
||||
MxValueKind.DateTime => DecodeDateTime(body),
|
||||
MxValueKind.BooleanArray => DecodeBooleanArray(body),
|
||||
MxValueKind.Int32Array => DecodeInt32Array(body),
|
||||
MxValueKind.Float32Array => DecodeFloat32Array(body),
|
||||
MxValueKind.Float64Array => DecodeFloat64Array(body),
|
||||
MxValueKind.StringArray => DecodeVariableArray(body, DecodeVariableArrayString),
|
||||
MxValueKind.DateTimeArray => DecodeVariableArray(body, DecodeVariableArrayDateTime),
|
||||
_ => throw new InvalidOperationException($"Unsupported value kind {_kind}."),
|
||||
};
|
||||
}
|
||||
|
||||
public int DecodeWriteIndex(ReadOnlySpan<byte> body)
|
||||
{
|
||||
if (body.Length < sizeof(int))
|
||||
{
|
||||
throw new ArgumentException("Body is too short.", nameof(body));
|
||||
}
|
||||
|
||||
return BinaryPrimitives.ReadInt32LittleEndian(body[^sizeof(int)..]);
|
||||
}
|
||||
|
||||
private static ObservedWriteBodyTemplate CreateFixed(MxValueKind kind, ReadOnlySpan<byte> body, int valueWidth)
|
||||
{
|
||||
int suffixStart = FixedValueOffset + valueWidth;
|
||||
int suffixLength = body.Length - suffixStart - sizeof(int);
|
||||
if (suffixLength < 0)
|
||||
{
|
||||
throw new ArgumentException("Observed fixed-width body is too short.", nameof(body));
|
||||
}
|
||||
|
||||
return new ObservedWriteBodyTemplate(
|
||||
kind,
|
||||
body[..FixedValueOffset].ToArray(),
|
||||
body.Slice(suffixStart, suffixLength).ToArray());
|
||||
}
|
||||
|
||||
private static ObservedWriteBodyTemplate CreateVariable(MxValueKind kind, ReadOnlySpan<byte> body)
|
||||
{
|
||||
if (body.Length < VariableValueOffset + sizeof(int))
|
||||
{
|
||||
throw new ArgumentException("Observed variable-width body is too short.", nameof(body));
|
||||
}
|
||||
|
||||
int valueByteLength = BinaryPrimitives.ReadInt32LittleEndian(body.Slice(22, 4));
|
||||
int suffixStart = VariableValueOffset + valueByteLength;
|
||||
int suffixLength = body.Length - suffixStart - sizeof(int);
|
||||
if (valueByteLength < 2 || suffixLength < 0)
|
||||
{
|
||||
throw new ArgumentException("Observed variable-width body has invalid lengths.", nameof(body));
|
||||
}
|
||||
|
||||
return new ObservedWriteBodyTemplate(
|
||||
kind,
|
||||
body[..VariableValueOffset].ToArray(),
|
||||
body.Slice(suffixStart, suffixLength).ToArray());
|
||||
}
|
||||
|
||||
private static ObservedWriteBodyTemplate CreateArray(MxValueKind kind, ReadOnlySpan<byte> body)
|
||||
{
|
||||
if (body.Length < ArrayValueOffset + 18)
|
||||
{
|
||||
throw new ArgumentException("Observed array body is too short.", nameof(body));
|
||||
}
|
||||
|
||||
int suffixStart = body.Length - 18;
|
||||
return new ObservedWriteBodyTemplate(
|
||||
kind,
|
||||
body[..ArrayValueOffset].ToArray(),
|
||||
body.Slice(suffixStart, 14).ToArray());
|
||||
}
|
||||
|
||||
private byte[] EncodeValue(object value)
|
||||
{
|
||||
return _kind switch
|
||||
{
|
||||
MxValueKind.Boolean => EncodeBoolean(Convert.ToBoolean(value, CultureInfo.InvariantCulture)),
|
||||
MxValueKind.Int32 => EncodeInt32(Convert.ToInt32(value, CultureInfo.InvariantCulture)),
|
||||
MxValueKind.Float32 => EncodeFloat32(Convert.ToSingle(value, CultureInfo.InvariantCulture)),
|
||||
MxValueKind.Float64 => EncodeFloat64(Convert.ToDouble(value, CultureInfo.InvariantCulture)),
|
||||
MxValueKind.String => EncodeUtf16String(Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty),
|
||||
MxValueKind.DateTime => EncodeUtf16String(FormatObservedDateTime((DateTime)value)),
|
||||
MxValueKind.BooleanArray => EncodeBooleanArray((bool[])value),
|
||||
MxValueKind.Int32Array => EncodeInt32Array((int[])value),
|
||||
MxValueKind.Float32Array => EncodeFloat32Array((float[])value),
|
||||
MxValueKind.Float64Array => EncodeFloat64Array((double[])value),
|
||||
MxValueKind.StringArray => EncodeVariableArray(((string[])value).Select(static s => s ?? string.Empty)),
|
||||
MxValueKind.DateTimeArray => EncodeVariableArray(((DateTime[])value).Select(FormatObservedDateTime)),
|
||||
_ => throw new InvalidOperationException($"Unsupported value kind {_kind}."),
|
||||
};
|
||||
}
|
||||
|
||||
private static byte[] EncodeBoolean(bool value)
|
||||
{
|
||||
return value
|
||||
? [0xff, 0xff, 0xff, 0x00]
|
||||
: [0x00, 0xff, 0xff, 0x00];
|
||||
}
|
||||
|
||||
private static bool DecodeBoolean(ReadOnlySpan<byte> body)
|
||||
{
|
||||
return body[FixedValueOffset] == 0xff && body[FixedValueOffset + 1] == 0xff;
|
||||
}
|
||||
|
||||
private static byte[] EncodeInt32(int value)
|
||||
{
|
||||
byte[] bytes = new byte[sizeof(int)];
|
||||
BinaryPrimitives.WriteInt32LittleEndian(bytes, value);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private static byte[] EncodeBooleanArray(bool[] values)
|
||||
{
|
||||
byte[] bytes = new byte[values.Length * sizeof(short)];
|
||||
for (int i = 0; i < values.Length; i++)
|
||||
{
|
||||
BinaryPrimitives.WriteInt16LittleEndian(bytes.AsSpan(i * sizeof(short), sizeof(short)), values[i] ? (short)-1 : (short)0);
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private static bool[] DecodeBooleanArray(ReadOnlySpan<byte> body)
|
||||
{
|
||||
int count = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(22, 2));
|
||||
bool[] values = new bool[count];
|
||||
for (int i = 0; i < values.Length; i++)
|
||||
{
|
||||
values[i] = BinaryPrimitives.ReadInt16LittleEndian(body.Slice(ArrayValueOffset + i * sizeof(short), sizeof(short))) != 0;
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
private static byte[] EncodeInt32Array(int[] values)
|
||||
{
|
||||
byte[] bytes = new byte[values.Length * sizeof(int)];
|
||||
for (int i = 0; i < values.Length; i++)
|
||||
{
|
||||
BinaryPrimitives.WriteInt32LittleEndian(bytes.AsSpan(i * sizeof(int), sizeof(int)), values[i]);
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private static int[] DecodeInt32Array(ReadOnlySpan<byte> body)
|
||||
{
|
||||
int count = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(22, 2));
|
||||
int[] values = new int[count];
|
||||
for (int i = 0; i < values.Length; i++)
|
||||
{
|
||||
values[i] = BinaryPrimitives.ReadInt32LittleEndian(body.Slice(ArrayValueOffset + i * sizeof(int), sizeof(int)));
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
private static byte[] EncodeFloat32(float value)
|
||||
{
|
||||
byte[] bytes = new byte[sizeof(float)];
|
||||
BinaryPrimitives.WriteInt32LittleEndian(bytes, BitConverter.SingleToInt32Bits(value));
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private static byte[] EncodeFloat32Array(float[] values)
|
||||
{
|
||||
byte[] bytes = new byte[values.Length * sizeof(float)];
|
||||
for (int i = 0; i < values.Length; i++)
|
||||
{
|
||||
BinaryPrimitives.WriteInt32LittleEndian(bytes.AsSpan(i * sizeof(float), sizeof(float)), BitConverter.SingleToInt32Bits(values[i]));
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private static float[] DecodeFloat32Array(ReadOnlySpan<byte> body)
|
||||
{
|
||||
int count = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(22, 2));
|
||||
float[] values = new float[count];
|
||||
for (int i = 0; i < values.Length; i++)
|
||||
{
|
||||
values[i] = BitConverter.Int32BitsToSingle(BinaryPrimitives.ReadInt32LittleEndian(body.Slice(ArrayValueOffset + i * sizeof(float), sizeof(float))));
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
private static byte[] EncodeFloat64(double value)
|
||||
{
|
||||
byte[] bytes = new byte[sizeof(double)];
|
||||
BinaryPrimitives.WriteInt64LittleEndian(bytes, BitConverter.DoubleToInt64Bits(value));
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private static byte[] EncodeFloat64Array(double[] values)
|
||||
{
|
||||
byte[] bytes = new byte[values.Length * sizeof(double)];
|
||||
for (int i = 0; i < values.Length; i++)
|
||||
{
|
||||
BinaryPrimitives.WriteInt64LittleEndian(bytes.AsSpan(i * sizeof(double), sizeof(double)), BitConverter.DoubleToInt64Bits(values[i]));
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private static double[] DecodeFloat64Array(ReadOnlySpan<byte> body)
|
||||
{
|
||||
int count = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(22, 2));
|
||||
double[] values = new double[count];
|
||||
for (int i = 0; i < values.Length; i++)
|
||||
{
|
||||
values[i] = BitConverter.Int64BitsToDouble(BinaryPrimitives.ReadInt64LittleEndian(body.Slice(ArrayValueOffset + i * sizeof(double), sizeof(double))));
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
private static byte[] EncodeUtf16String(string value)
|
||||
{
|
||||
byte[] textBytes = Encoding.Unicode.GetBytes(value);
|
||||
byte[] bytes = new byte[textBytes.Length + 2];
|
||||
textBytes.CopyTo(bytes, 0);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private static string DecodeUtf16String(ReadOnlySpan<byte> body)
|
||||
{
|
||||
int valueByteLength = BinaryPrimitives.ReadInt32LittleEndian(body.Slice(22, 4));
|
||||
ReadOnlySpan<byte> raw = body.Slice(VariableValueOffset, valueByteLength);
|
||||
if (raw.Length >= 2 && raw[^1] == 0 && raw[^2] == 0)
|
||||
{
|
||||
raw = raw[..^2];
|
||||
}
|
||||
|
||||
return Encoding.Unicode.GetString(raw);
|
||||
}
|
||||
|
||||
private static DateTime DecodeDateTime(ReadOnlySpan<byte> body)
|
||||
{
|
||||
string text = DecodeUtf16String(body);
|
||||
return DateTime.ParseExact(text, "M/d/yyyy h:mm:ss tt", CultureInfo.InvariantCulture, DateTimeStyles.None);
|
||||
}
|
||||
|
||||
private static byte[] EncodeVariableArray(IEnumerable<string> values)
|
||||
{
|
||||
using var stream = new MemoryStream();
|
||||
foreach (var value in values)
|
||||
{
|
||||
byte[] textBytes = EncodeUtf16String(value);
|
||||
byte[] header = new byte[13];
|
||||
BinaryPrimitives.WriteInt32LittleEndian(header.AsSpan(0, 4), 1 + sizeof(int) + sizeof(int) + textBytes.Length);
|
||||
header[4] = 0x05;
|
||||
BinaryPrimitives.WriteInt32LittleEndian(header.AsSpan(5, 4), textBytes.Length + sizeof(int));
|
||||
BinaryPrimitives.WriteInt32LittleEndian(header.AsSpan(9, 4), textBytes.Length);
|
||||
stream.Write(header);
|
||||
stream.Write(textBytes, 0, textBytes.Length);
|
||||
}
|
||||
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
private static T[] DecodeVariableArray<T>(ReadOnlySpan<byte> body, Func<ReadOnlySpan<byte>, T> decode)
|
||||
{
|
||||
int count = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(22, 2));
|
||||
T[] values = new T[count];
|
||||
int offset = ArrayValueOffset;
|
||||
for (int i = 0; i < values.Length; i++)
|
||||
{
|
||||
int recordLength = BinaryPrimitives.ReadInt32LittleEndian(body.Slice(offset, 4));
|
||||
ReadOnlySpan<byte> record = body.Slice(offset + 4, recordLength);
|
||||
values[i] = decode(record);
|
||||
offset += sizeof(int) + recordLength;
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
private static string DecodeVariableArrayString(ReadOnlySpan<byte> record)
|
||||
{
|
||||
if (record[0] != 0x05)
|
||||
{
|
||||
throw new ArgumentException("Unexpected variable array element type.", nameof(record));
|
||||
}
|
||||
|
||||
int valueByteLength = BinaryPrimitives.ReadInt32LittleEndian(record.Slice(5, 4));
|
||||
ReadOnlySpan<byte> raw = record.Slice(9, valueByteLength);
|
||||
if (raw.Length >= 2 && raw[^1] == 0 && raw[^2] == 0)
|
||||
{
|
||||
raw = raw[..^2];
|
||||
}
|
||||
|
||||
return Encoding.Unicode.GetString(raw);
|
||||
}
|
||||
|
||||
private static DateTime DecodeVariableArrayDateTime(ReadOnlySpan<byte> record)
|
||||
{
|
||||
return DateTime.ParseExact(DecodeVariableArrayString(record), "M/d/yyyy h:mm:ss tt", CultureInfo.InvariantCulture, DateTimeStyles.None);
|
||||
}
|
||||
|
||||
private static string FormatObservedDateTime(DateTime value)
|
||||
{
|
||||
return value.ToString("M/d/yyyy h:mm:ss tt", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private void PatchVariableLengths(byte[] body, int valueByteLength)
|
||||
{
|
||||
if (_kind is not (MxValueKind.String or MxValueKind.DateTime))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
BinaryPrimitives.WriteInt32LittleEndian(body.AsSpan(18, 4), valueByteLength + 4);
|
||||
BinaryPrimitives.WriteInt32LittleEndian(body.AsSpan(22, 4), valueByteLength);
|
||||
return;
|
||||
}
|
||||
|
||||
private void PatchArrayDescriptor(byte[] body, int valueByteLength)
|
||||
{
|
||||
if (_kind is not (MxValueKind.BooleanArray or MxValueKind.Int32Array or MxValueKind.Float32Array or MxValueKind.Float64Array or MxValueKind.StringArray or MxValueKind.DateTimeArray))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_kind is MxValueKind.StringArray or MxValueKind.DateTimeArray)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
int count = _kind switch
|
||||
{
|
||||
MxValueKind.BooleanArray => valueByteLength / sizeof(short),
|
||||
MxValueKind.Int32Array => valueByteLength / sizeof(int),
|
||||
MxValueKind.Float32Array => valueByteLength / sizeof(float),
|
||||
MxValueKind.Float64Array => valueByteLength / sizeof(double),
|
||||
_ => 0,
|
||||
};
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(22, 2), checked((ushort)count));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net481</TargetFramework>
|
||||
<PlatformTarget>x86</PlatformTarget>
|
||||
<Prefer32Bit>true</Prefer32Bit>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>disable</ImplicitUsings>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="ArchestrA.MxAccess">
|
||||
<HintPath>C:\Program Files (x86)\ArchestrA\Framework\Bin\ArchestrA.MXAccess.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net481</TargetFramework>
|
||||
<PlatformTarget>x86</PlatformTarget>
|
||||
<Prefer32Bit>true</Prefer32Bit>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>disable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,364 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using ComTypes = System.Runtime.InteropServices.ComTypes;
|
||||
|
||||
namespace NmxComHarness;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
[STAThread]
|
||||
private static int Main(string[] args)
|
||||
{
|
||||
var engineId = GetInt(args, "--engine-id") ?? 0x7100;
|
||||
var engineName = GetString(args, "--engine-name") ?? $"NmxComHarness.{Process.GetCurrentProcess().Id}";
|
||||
var version = GetInt(args, "--version") ?? 6;
|
||||
var holdSeconds = GetInt(args, "--hold-seconds") ?? 1;
|
||||
var useNullCallback = HasFlag(args, "--null-callback");
|
||||
var dumpCallbackObjRef = HasFlag(args, "--dump-callback-objref");
|
||||
var selfTransfer = HasFlag(args, "--self-transfer");
|
||||
var allowEmptyTransfer = HasFlag(args, "--allow-empty-transfer");
|
||||
|
||||
Console.WriteLine($"process=x64:{Environment.Is64BitProcess}");
|
||||
Console.WriteLine($"engine_id={engineId}");
|
||||
Console.WriteLine($"engine_name={engineName}");
|
||||
Console.WriteLine($"version={version}");
|
||||
Console.WriteLine($"null_callback={useNullCallback}");
|
||||
|
||||
object? instance = null;
|
||||
try
|
||||
{
|
||||
var type = Type.GetTypeFromProgID("NmxSvc.NmxService", throwOnError: true)
|
||||
?? throw new InvalidOperationException("ProgID NmxSvc.NmxService was not resolved.");
|
||||
instance = Activator.CreateInstance(type)
|
||||
?? throw new InvalidOperationException("ProgID NmxSvc.NmxService activation returned null.");
|
||||
|
||||
var service = (INmxService2)instance;
|
||||
INmxSvcCallback? callback = useNullCallback ? null : new CallbackSink();
|
||||
if (callback is not null && dumpCallbackObjRef)
|
||||
{
|
||||
byte[] objref = ComObjRefProvider.MarshalInterfaceObjRef(
|
||||
callback,
|
||||
new Guid("B49F92F7-C748-4169-8ECA-A0670B012746"),
|
||||
ComObjRefProvider.MarshalContextDifferentMachine);
|
||||
Console.WriteLine($"callback_objref_size={objref.Length}");
|
||||
Console.WriteLine($"callback_objref_hex={BitConverter.ToString(objref).Replace("-", string.Empty)}");
|
||||
}
|
||||
|
||||
Console.WriteLine("register.begin");
|
||||
service.RegisterEngine2(engineId, engineName, version, callback);
|
||||
Console.WriteLine("register.ok");
|
||||
|
||||
if (selfTransfer)
|
||||
{
|
||||
if (HasFlag(args, "--connect-self"))
|
||||
{
|
||||
service.Connect(engineId, 1, 1, engineId);
|
||||
Console.WriteLine("connect.self.ok");
|
||||
}
|
||||
|
||||
if (HasFlag(args, "--add-self-subscriber"))
|
||||
{
|
||||
service.AddSubscriberEngine(engineId, 1, 1, engineId);
|
||||
Console.WriteLine("add-subscriber.self.ok");
|
||||
}
|
||||
|
||||
string bodyHex = GetString(args, "--transfer-body-hex")
|
||||
?? throw new InvalidOperationException(
|
||||
"--self-transfer requires --transfer-body-hex with a complete 46-byte NMX envelope plus inner body. " +
|
||||
"Use --allow-empty-transfer only for controlled size-zero probes.");
|
||||
byte[] body = ParseHex(bodyHex);
|
||||
ValidateTransferDataBody(body, allowEmptyTransfer);
|
||||
if (body.Length == 0)
|
||||
{
|
||||
byte empty = 0;
|
||||
service.TransferData(1, 1, engineId, 0, ref empty);
|
||||
}
|
||||
else
|
||||
{
|
||||
service.TransferData(1, 1, engineId, body.Length, ref body[0]);
|
||||
}
|
||||
Console.WriteLine("transfer.self.ok");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
service.GetPartnerVersion(1, 1, 0x7ffd, out var partnerVersion);
|
||||
Console.WriteLine($"partner_version={partnerVersion}");
|
||||
}
|
||||
catch (COMException ex)
|
||||
{
|
||||
Console.WriteLine($"partner_version_error=0x{ex.HResult:X8} {ex.Message}");
|
||||
}
|
||||
|
||||
Thread.Sleep(TimeSpan.FromSeconds(Math.Max(0, holdSeconds)));
|
||||
|
||||
Console.WriteLine("unregister.begin");
|
||||
service.UnRegisterEngine(engineId);
|
||||
Console.WriteLine("unregister.ok");
|
||||
return 0;
|
||||
}
|
||||
catch (COMException ex)
|
||||
{
|
||||
Console.Error.WriteLine($"com_error=0x{ex.HResult:X8} {ex.Message}");
|
||||
return 2;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"{ex.GetType().FullName}: {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (instance is not null && Marshal.IsComObject(instance))
|
||||
{
|
||||
while (Marshal.ReleaseComObject(instance) > 0)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string? GetString(string[] args, string name)
|
||||
{
|
||||
var prefix = name + "=";
|
||||
var match = args.FirstOrDefault(arg => arg.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
|
||||
return match is null ? null : match.Substring(prefix.Length);
|
||||
}
|
||||
|
||||
private static int? GetInt(string[] args, string name)
|
||||
{
|
||||
var raw = GetString(args, name);
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return raw!.StartsWith("0x", StringComparison.OrdinalIgnoreCase)
|
||||
? int.Parse(raw.Substring(2), NumberStyles.HexNumber, CultureInfo.InvariantCulture)
|
||||
: int.Parse(raw, CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static bool HasFlag(string[] args, string name)
|
||||
{
|
||||
return args.Any(arg => arg.Equals(name, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static void ValidateTransferDataBody(byte[] body, bool allowEmptyTransfer)
|
||||
{
|
||||
const int headerLength = 46;
|
||||
const int innerLengthOffset = 2;
|
||||
|
||||
if (body.Length == 0)
|
||||
{
|
||||
if (allowEmptyTransfer)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
throw new ArgumentException("TransferData body cannot be empty without --allow-empty-transfer.");
|
||||
}
|
||||
|
||||
if (body.Length < headerLength)
|
||||
{
|
||||
throw new ArgumentException($"TransferData body must be at least {headerLength} bytes.");
|
||||
}
|
||||
|
||||
int innerLength = BitConverter.ToInt32(body, innerLengthOffset);
|
||||
int expectedInnerLength = body.Length - headerLength;
|
||||
if (innerLength != expectedInnerLength)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"TransferData envelope declares inner length {innerLength}, but body length {body.Length} requires {expectedInnerLength}.");
|
||||
}
|
||||
|
||||
if (innerLength == 0 && !allowEmptyTransfer)
|
||||
{
|
||||
throw new ArgumentException("TransferData body must include an inner message after the 46-byte envelope.");
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] ParseHex(string hex)
|
||||
{
|
||||
string compact = new string(hex.Where(static ch => !char.IsWhiteSpace(ch)).ToArray());
|
||||
if ((compact.Length % 2) != 0)
|
||||
{
|
||||
throw new FormatException("Hex string must contain an even number of digits.");
|
||||
}
|
||||
|
||||
byte[] bytes = new byte[compact.Length / 2];
|
||||
for (int i = 0; i < bytes.Length; i++)
|
||||
{
|
||||
bytes[i] = byte.Parse(compact.Substring(i * 2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
|
||||
internal static class ComObjRefProvider
|
||||
{
|
||||
public const uint MarshalContextDifferentMachine = 2;
|
||||
|
||||
public static byte[] MarshalInterfaceObjRef(object comObject, Guid iid, uint destinationContext)
|
||||
{
|
||||
IntPtr unknown = IntPtr.Zero;
|
||||
ComTypes.IStream? stream = null;
|
||||
|
||||
try
|
||||
{
|
||||
unknown = Marshal.GetIUnknownForObject(comObject);
|
||||
Marshal.ThrowExceptionForHR(CreateStreamOnHGlobal(IntPtr.Zero, true, out stream));
|
||||
Marshal.ThrowExceptionForHR(CoMarshalInterface(stream, ref iid, unknown, destinationContext, IntPtr.Zero, 0));
|
||||
Marshal.ThrowExceptionForHR(GetHGlobalFromStream(stream, out var hglobal));
|
||||
|
||||
uint size = GlobalSize(hglobal);
|
||||
IntPtr pointer = GlobalLock(hglobal);
|
||||
if (pointer == IntPtr.Zero)
|
||||
{
|
||||
throw new InvalidOperationException("GlobalLock failed.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
byte[] buffer = new byte[(int)size];
|
||||
Marshal.Copy(pointer, buffer, 0, buffer.Length);
|
||||
return buffer;
|
||||
}
|
||||
finally
|
||||
{
|
||||
GlobalUnlock(hglobal);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (unknown != IntPtr.Zero)
|
||||
{
|
||||
Marshal.Release(unknown);
|
||||
}
|
||||
|
||||
if (stream is not null)
|
||||
{
|
||||
Marshal.ReleaseComObject(stream);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[DllImport("ole32.dll")]
|
||||
private static extern int CreateStreamOnHGlobal(IntPtr hGlobal, [MarshalAs(UnmanagedType.Bool)] bool deleteOnRelease, out ComTypes.IStream stream);
|
||||
|
||||
[DllImport("ole32.dll")]
|
||||
private static extern int CoMarshalInterface(ComTypes.IStream stream, ref Guid iid, IntPtr unknown, uint destinationContext, IntPtr destinationContextPointer, uint marshalFlags);
|
||||
|
||||
[DllImport("ole32.dll")]
|
||||
private static extern int GetHGlobalFromStream(ComTypes.IStream stream, out IntPtr hGlobal);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern IntPtr GlobalLock(IntPtr hMem);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern bool GlobalUnlock(IntPtr hMem);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern uint GlobalSize(IntPtr hMem);
|
||||
}
|
||||
|
||||
[ComImport]
|
||||
[Guid("AE24BD51-2E80-44CC-905B-E5446C942BEB")]
|
||||
[ClassInterface(ClassInterfaceType.None)]
|
||||
internal sealed class NmxServiceClass
|
||||
{
|
||||
}
|
||||
|
||||
[ComImport]
|
||||
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
[Guid("575008DB-845D-46C6-A906-F6F8CA86F315")]
|
||||
internal interface INmxService
|
||||
{
|
||||
[MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)]
|
||||
void RegisterEngine(int engineId, [MarshalAs(UnmanagedType.BStr)] string engineName, [MarshalAs(UnmanagedType.Interface)] INmxSvcCallback? callback);
|
||||
|
||||
[MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)]
|
||||
void UnRegisterEngine(int engineId);
|
||||
|
||||
[MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)]
|
||||
void Connect(int localEngineId, int remoteGalaxyId, int remotePlatformId, int remoteEngineId);
|
||||
|
||||
[MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)]
|
||||
void TransferData(int remoteGalaxyId, int remotePlatformId, int remoteEngineId, int size, ref byte messageBody);
|
||||
|
||||
[MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)]
|
||||
void AddSubscriberEngine(int localEngineId, int subscriberGalaxyId, int subscriberPlatformId, int subscriberEngineId);
|
||||
|
||||
[MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)]
|
||||
void RemoveSubscriberEngine(int localEngineId, int subscriberGalaxyId, int subscriberPlatformId, int subscriberEngineId);
|
||||
|
||||
[MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)]
|
||||
void SetHeartbeatSendInterval(int ticksPerBeat, int maxMissedTicks);
|
||||
}
|
||||
|
||||
[ComImport]
|
||||
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
[Guid("2630A513-A974-4B1A-8025-457A9A7C56B8")]
|
||||
internal interface INmxService2 : INmxService
|
||||
{
|
||||
[MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)]
|
||||
new void RegisterEngine(int engineId, [MarshalAs(UnmanagedType.BStr)] string engineName, [MarshalAs(UnmanagedType.Interface)] INmxSvcCallback? callback);
|
||||
|
||||
[MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)]
|
||||
new void UnRegisterEngine(int engineId);
|
||||
|
||||
[MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)]
|
||||
new void Connect(int localEngineId, int remoteGalaxyId, int remotePlatformId, int remoteEngineId);
|
||||
|
||||
[MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)]
|
||||
new void TransferData(int remoteGalaxyId, int remotePlatformId, int remoteEngineId, int size, ref byte messageBody);
|
||||
|
||||
[MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)]
|
||||
new void AddSubscriberEngine(int localEngineId, int subscriberGalaxyId, int subscriberPlatformId, int subscriberEngineId);
|
||||
|
||||
[MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)]
|
||||
new void RemoveSubscriberEngine(int localEngineId, int subscriberGalaxyId, int subscriberPlatformId, int subscriberEngineId);
|
||||
|
||||
[MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)]
|
||||
new void SetHeartbeatSendInterval(int ticksPerBeat, int maxMissedTicks);
|
||||
|
||||
[MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)]
|
||||
void RegisterEngine2(int engineId, [MarshalAs(UnmanagedType.BStr)] string engineName, int version, [MarshalAs(UnmanagedType.Interface)] INmxSvcCallback? callback);
|
||||
|
||||
[MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)]
|
||||
void GetPartnerVersion(int galaxyId, int platformId, int engineId, out int version);
|
||||
}
|
||||
|
||||
[ComVisible(true)]
|
||||
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
[Guid("B49F92F7-C748-4169-8ECA-A0670B012746")]
|
||||
public interface INmxSvcCallback
|
||||
{
|
||||
[PreserveSig]
|
||||
int DataReceived(int bufferSize, ref sbyte dataBuffer);
|
||||
|
||||
[PreserveSig]
|
||||
int StatusReceived(int bufferSize, ref sbyte statusBuffer);
|
||||
}
|
||||
|
||||
[ComVisible(true)]
|
||||
[ClassInterface(ClassInterfaceType.None)]
|
||||
public sealed class CallbackSink : INmxSvcCallback
|
||||
{
|
||||
public int DataReceived(int bufferSize, ref sbyte dataBuffer)
|
||||
{
|
||||
Console.WriteLine($"callback.data size={bufferSize}");
|
||||
return 0;
|
||||
}
|
||||
|
||||
public int StatusReceived(int bufferSize, ref sbyte statusBuffer)
|
||||
{
|
||||
Console.WriteLine($"callback.status size={bufferSize}");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user