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,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")]
|
||||
Reference in New Issue
Block a user