Initial project state: .NET reference, design, Rust port (M0+M1), evidence
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:
Joseph Doherty
2026-05-05 06:21:00 -04:00
parent 43733699b0
commit fe2a6db786
3849 changed files with 352975 additions and 0 deletions
+54
View File
@@ -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;
}
+116
View File
@@ -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}";
}
}
+20
View File
@@ -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
+75
View File
@@ -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);
}
}
}
}
+23
View File
@@ -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();
}
}
+147
View File
@@ -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;
}
}
+44
View File
@@ -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;
}
+67
View File
@@ -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";
}
+273
View File
@@ -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;
}
}
+49
View File
@@ -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;
}
}
+29
View File
@@ -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; }
}
+167
View File
@@ -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;
}
}
+50
View File
@@ -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);
+8
View File
@@ -0,0 +1,8 @@
namespace MxAsbClient;
public sealed record AsbWriteOptions
{
public uint WriteHandle { get; init; }
public string? Comment { get; init; }
}
+17
View File
@@ -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>
+295
View File
@@ -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; }
}
}
+828
View File
@@ -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")]