using System.Collections.Concurrent; using System.Globalization; using Grpc.Core; using Microsoft.Extensions.Logging; using ZB.MOM.WW.MxGateway.Client; using ZB.MOM.WW.MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol; namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters; /// /// Production implementation over the /// ZB.MOM.WW.MxGateway.Client NuGet package. This is the only type in the /// Data Connection Layer that references the generated gRPC/protobuf contracts; /// the adapter and its tests run entirely against the neutral seam. /// public sealed class RealMxGatewayClient : IMxGatewayClient { private readonly ILogger _logger; private readonly ILoggerFactory? _loggerFactory; private MxGatewayClient? _client; private GalaxyRepositoryClient? _galaxy; private MxGatewaySession? _session; private int _serverHandle; private int _writeUserId; private int _readTimeoutMs; private int _supervisoryAdviseParallelism = 16; private ulong _lastSeq; // tag ↔ MXAccess item handle, maintained across subscribe/write. private readonly ConcurrentDictionary _tagToHandle = new(); private readonly ConcurrentDictionary _handleToTag = new(); // Item handles already advised in SUPERVISORY mode (advise-once tracking). // MXAccess only accepts a write on an item that holds a supervisory advise; a // plain Advise is not sufficient and the COM Write blocks until the gateway // command timeout. When the connection has no write-user context // (WriteUserId == 0) we therefore advise supervisory by DEFAULT — for both the // monitoring subscription and any write — so the one connection can read and // write. A supervisory advise still delivers OnDataChange (monitoring is // unaffected), and the worker's UnAdvise tears down either advice kind. The // Lazy per handle guarantees the advise runs at most once even under a // concurrent first-time subscribe+write on the same new handle: every racing // caller awaits the same Task, so none reaches WriteBulk before the advise has // completed. A faulted advise is evicted so the next write retries. private readonly ConcurrentDictionary> _supervisoryAdvised = new(); // With no configured MXAccess write-user this connection behaves as a // supervisory client: every advise defaults to AdviseSupervisory so writes are // accepted. A configured write-user keeps the prior plain-advise behaviour. private bool UseSupervisoryAdvise => _writeUserId == 0; /// Initializes a new instance of . /// Logger factory shared with the gateway client. public RealMxGatewayClient(ILoggerFactory? loggerFactory) { _loggerFactory = loggerFactory; _logger = (loggerFactory ?? Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance) .CreateLogger(); } /// public async Task ConnectAsync(MxGatewayConnectionOptions options, CancellationToken ct = default) { _writeUserId = options.WriteUserId; _readTimeoutMs = options.ReadTimeoutMs; _supervisoryAdviseParallelism = Math.Max(1, options.SupervisoryAdviseParallelism); var clientOptions = new MxGatewayClientOptions { Endpoint = new Uri(options.Endpoint), ApiKey = options.ApiKey, UseTls = options.UseTls, CaCertificatePath = options.CaFile, ServerNameOverride = options.ServerName, LoggerFactory = _loggerFactory, }; _client = MxGatewayClient.Create(clientOptions); _galaxy = GalaxyRepositoryClient.Create(clientOptions); _session = await _client.OpenSessionAsync(cancellationToken: ct).ConfigureAwait(false); _serverHandle = await _session.RegisterAsync(options.ClientName, ct).ConfigureAwait(false); } /// public async Task DisconnectAsync(CancellationToken ct = default) { if (_session is not null) await _session.CloseAsync(ct).ConfigureAwait(false); } /// public async Task SubscribeAsync(string tagPath, CancellationToken ct = default) { var handle = await GetOrAddItemHandleAsync(tagPath, ct).ConfigureAwait(false); // With no write-user context, advise supervisory by default so the same // subscription also makes the item writable; otherwise plain monitor-advise. if (UseSupervisoryAdvise) await EnsureSupervisoryAdvisedAsync(handle, ct).ConfigureAwait(false); else await _session!.AdviseAsync(_serverHandle, handle, ct).ConfigureAwait(false); return handle.ToString(CultureInfo.InvariantCulture); } /// public async Task> SubscribeBulkAsync( IReadOnlyList tagPaths, CancellationToken ct = default) { if (tagPaths.Count == 0) return []; // Plain-advise mode (a configured write user): the worker's SubscribeBulk does // AddItem + Advise for every tag in ONE gateway command, collapsing the historical // 2 RPCs per tag to 1 RPC per chunk. if (!UseSupervisoryAdvise) { var replies = await _session!.SubscribeBulkAsync(_serverHandle, tagPaths, ct).ConfigureAwait(false); return MapSubscribeResults(tagPaths, replies); } // Supervisory mode (WriteUserId == 0): the worker has no BULK supervisory advise // (SubscribeBulk issues plain Advise), so bulk-add the items in one command and // pipeline the per-item AdviseSupervisory commands with a bounded window instead // of issuing them serially. Cross-repo follow-up: an additive `supervisory` flag on // SubscribeBulkCommand in mxaccessgw would collapse this to one RPC as well. var added = await _session!.AddItemBulkAsync(_serverHandle, tagPaths, ct).ConfigureAwait(false); var outcomes = MapSubscribeResults(tagPaths, added); var advisable = outcomes .Select((o, i) => (Outcome: o, Index: i)) .Where(x => x.Outcome.Success) .ToList(); var advised = await BulkPipeline.RunAsync( advisable, _supervisoryAdviseParallelism, async (x, token) => { var handle = int.Parse(x.Outcome.SubscriptionId!, NumberStyles.Integer, CultureInfo.InvariantCulture); await EnsureSupervisoryAdvisedAsync(handle, token).ConfigureAwait(false); return x.Outcome; }, (x, ex) => x.Outcome with { Success = false, SubscriptionId = null, Error = ex.Message }, ct).ConfigureAwait(false); var result = outcomes.ToArray(); for (var i = 0; i < advisable.Count; i++) result[advisable[i].Index] = advised[i]; // A failed advise leaves an added-but-unadvised item; drop our handle mapping so a // later retry re-adds cleanly rather than reusing a half-subscribed handle. foreach (var failed in result.Where(r => !r.Success)) { if (_tagToHandle.TryRemove(failed.TagPath, out var staleHandle)) _handleToTag.TryRemove(staleHandle, out _); } return result; } /// /// Maps the gateway's per-item bulk results back onto the requested tag paths. Results /// are returned in request order; the tag address on the reply is preferred when the /// gateway echoes it. Successful rows populate the tag ↔ item-handle maps that the /// event loop and the write path key off. /// private MxSubscribeOutcome[] MapSubscribeResults( IReadOnlyList tagPaths, IReadOnlyList replies) { var outcomes = new MxSubscribeOutcome[tagPaths.Count]; for (var i = 0; i < tagPaths.Count; i++) { var tag = tagPaths[i]; if (i >= replies.Count) { outcomes[i] = new MxSubscribeOutcome(tag, false, null, "Gateway returned no result for this tag."); continue; } var reply = replies[i]; if (!reply.WasSuccessful) { outcomes[i] = new MxSubscribeOutcome(tag, false, null, string.IsNullOrEmpty(reply.ErrorMessage) ? "Bulk subscribe failed." : reply.ErrorMessage); continue; } _tagToHandle[tag] = reply.ItemHandle; _handleToTag[reply.ItemHandle] = tag; outcomes[i] = new MxSubscribeOutcome( tag, true, reply.ItemHandle.ToString(CultureInfo.InvariantCulture), null); } return outcomes; } /// public async Task UnsubscribeBulkAsync(IReadOnlyList subscriptionIds, CancellationToken ct = default) { var handles = new List(subscriptionIds.Count); foreach (var id in subscriptionIds) { if (int.TryParse(id, NumberStyles.Integer, CultureInfo.InvariantCulture, out var handle)) handles.Add(handle); } if (handles.Count == 0) return; await _session!.UnsubscribeBulkAsync(_serverHandle, handles, ct).ConfigureAwait(false); foreach (var handle in handles) { // UnsubscribeBulk unadvises AND removes the item, releasing every advice kind. _supervisoryAdvised.TryRemove(handle, out _); if (_handleToTag.TryRemove(handle, out var tag)) ((ICollection>)_tagToHandle).Remove(new(tag, handle)); } } /// public async Task UnsubscribeAsync(string subscriptionId, CancellationToken ct = default) { if (!int.TryParse(subscriptionId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var handle)) return; await _session!.UnAdviseAsync(_serverHandle, handle, ct).ConfigureAwait(false); await _session.RemoveItemAsync(_serverHandle, handle, ct).ConfigureAwait(false); // RemoveItem releases the item and all its advices (plain + supervisory), // so forget any supervisory-advise state for this handle. _supervisoryAdvised.TryRemove(handle, out _); if (_handleToTag.TryRemove(handle, out var tag)) ((ICollection>)_tagToHandle).Remove(new(tag, handle)); } /// public async Task> ReadAsync(IReadOnlyList tagPaths, CancellationToken ct = default) { var results = await _session! .ReadBulkAsync(_serverHandle, tagPaths, TimeSpan.FromMilliseconds(_readTimeoutMs), ct) .ConfigureAwait(false); return results.Select(r => new MxReadOutcome( r.TagAddress, r.WasSuccessful, r.WasSuccessful ? r.Value?.ToClrValue() : null, MapQuality(r.Quality, r.Statuses), r.SourceTimestamp?.ToDateTimeOffset() ?? DateTimeOffset.UtcNow, r.WasSuccessful ? null : r.ErrorMessage)).ToList(); } /// public async Task> WriteAsync(IReadOnlyList<(string TagPath, object? Value)> writes, CancellationToken ct = default) { // Build entries in request order; remember the tag for each handle so the // per-handle BulkWriteResult can be mapped back to its tag. var entries = new List(writes.Count); var orderedTags = new List(writes.Count); foreach (var (tag, value) in writes) { // MXAccess addresses a WHOLE array attribute with a trailing "[]" on the // item reference. A write to the bare reference is silently dropped — the // COM Write returns success but the value never commits (reads work // either way). This mirrors the AVEVA MES Camstar API, which registers // array tags as ".[]". Scalars keep the bare reference. var writeRef = IsArrayValue(value) ? tag + "[]" : tag; var handle = await GetOrAddItemHandleAsync(writeRef, ct).ConfigureAwait(false); // MXAccess requires a supervisory advise on the item before it will // accept a write; without it the worker's synchronous COM Write blocks. // With no write-user context we advise supervisory by default (a // supervisory subscription may already cover this handle — advise-once). if (UseSupervisoryAdvise) await EnsureSupervisoryAdvisedAsync(handle, ct).ConfigureAwait(false); // MXAccess SAFEARRAY writes must supply the attribute's FULL declared // length; a short array (only the N elements the caller provided) is // rejected and the COM write blocks. Pad list values out to the live // array's slot count before encoding (callers signal the valid count // via a separate scalar, e.g. MoveInNumberWorkOrders). var toWrite = await PadArrayToDeclaredSizeAsync(writeRef, value, ct).ConfigureAwait(false); entries.Add(new WriteBulkEntry { ItemHandle = handle, Value = ToMxValue(toWrite), UserId = _writeUserId, }); // Map the per-handle result back to the ORIGINAL caller tag (not writeRef). orderedTags.Add(tag); } var results = await _session!.WriteBulkAsync(_serverHandle, entries, ct).ConfigureAwait(false); // Results are returned in request order; pair by index back to the tags. return results.Select((r, i) => new MxWriteOutcome( i < orderedTags.Count ? orderedTags[i] : (_handleToTag.TryGetValue(r.ItemHandle, out var t) ? t : ""), r.WasSuccessful, r.WasSuccessful ? null : r.ErrorMessage)).ToList(); } /// public async Task<(IReadOnlyList Children, bool Truncated)> BrowseChildrenAsync(string? parentNodeId, CancellationToken ct = default) { // Navigation browse returns child OBJECTS only (IncludeAttributes = false). // An object's own attributes are loaded lazily — via DiscoverHierarchy below, // scoped to that single object — only when the object is expanded. This keeps // each browse level's payload to a few KB. Inlining every child's full // attribute set (the previous behaviour) produced replies that exceeded // Akka's remote frame size (e.g. ~152 KB for one attribute-heavy area), and // remoting silently discarded the oversized reply, hanging the picker. var request = new BrowseChildrenRequest { IncludeAttributes = false }; // Object NodeIds are the Galaxy gobject id (encoded as a string); attribute // NodeIds are FullTagReference leaves and never arrive here as a parent. var parentGobjectId = 0; var haveParentObject = !string.IsNullOrEmpty(parentNodeId) && int.TryParse(parentNodeId, NumberStyles.Integer, CultureInfo.InvariantCulture, out parentGobjectId); if (haveParentObject) { request.ParentGobjectId = parentGobjectId; } BrowseChildrenReply reply; GalaxyObject? parentObject = null; try { reply = await _galaxy!.BrowseChildrenRawAsync(request, ct).ConfigureAwait(false); // When expanding a concrete object, fetch just that object (MaxDepth = 0) // with its attributes so they can be listed as selectable leaves. The root // level has no parent object, hence no attributes of its own. if (haveParentObject) { var attrRequest = new DiscoverHierarchyRequest { RootGobjectId = parentGobjectId, MaxDepth = 0, IncludeAttributes = true, }; var attrReply = await _galaxy!.DiscoverHierarchyRawAsync(attrRequest, ct).ConfigureAwait(false); parentObject = attrReply.Objects.Count > 0 ? attrReply.Objects[0] : null; } } catch (RpcException ex) when (ex.StatusCode == StatusCode.Unavailable) { throw new ConnectionNotConnectedException($"MxGateway repository unavailable: {ex.Status.Detail}"); } catch (RpcException ex) when (ex.StatusCode == StatusCode.Unimplemented) { // The data pipe (read/subscribe/write) works against every gateway // build, but Galaxy hierarchy browsing (the BrowseChildren RPC) was // added later. An older gateway answers Unimplemented. Surface a // clear, actionable reason instead of a raw gRPC fault — the actor // maps NotSupportedException to BrowseFailureKind.NotBrowsable and // carries this message through to the picker. throw new NotSupportedException( "The connected MxGateway build does not support hierarchy browsing. " + "Update the gateway to a build that implements BrowseChildren, " + "or enter the tag reference manually."); } var children = new List(); // Navigable child objects, keyed by gobject id. Always marked expandable — // every Galaxy object carries attributes (and may host sub-objects), both // resolved on demand when the node is expanded. foreach (var obj in reply.Children) { children.Add(new MxBrowseChild( obj.GobjectId.ToString(CultureInfo.InvariantCulture), string.IsNullOrEmpty(obj.TagName) ? obj.ContainedName : obj.TagName, BrowseNodeClass.Object, true)); } // The expanded object's own attributes, as selectable leaves keyed by their // full tag reference. if (parentObject is not null) { foreach (var attr in parentObject.Attributes) { children.Add(new MxBrowseChild( attr.FullTagReference, attr.AttributeName, BrowseNodeClass.Variable, false, string.IsNullOrEmpty(attr.DataTypeName) ? null : attr.DataTypeName)); } } return (children, !string.IsNullOrEmpty(reply.NextPageToken)); } /// public async Task RunEventLoopAsync(Action onUpdate, CancellationToken ct = default) { await foreach (var ev in _session!.StreamEventsAsync(_lastSeq, ct).ConfigureAwait(false)) { _lastSeq = ev.WorkerSequence; if (ev.Family != MxEventFamily.OnDataChange) continue; if (!_handleToTag.TryGetValue(ev.ItemHandle, out var tag)) continue; onUpdate(new MxValueUpdate( tag, ev.Value?.ToClrValue(), MapQuality(ev.Quality, ev.Statuses), ev.SourceTimestamp?.ToDateTimeOffset() ?? DateTimeOffset.UtcNow)); } } /// public async Task RunAlarmStreamAsync( string? alarmFilterPrefix, Action onTransition, CancellationToken ct = default) { var reconnectDelay = TimeSpan.FromSeconds(5); while (!ct.IsCancellationRequested) { try { var request = new StreamAlarmsRequest { ClientCorrelationId = Guid.NewGuid().ToString("N"), AlarmFilterPrefix = alarmFilterPrefix ?? string.Empty, }; // Every transport reopen produces a fresh authoritative frame, // including an empty snapshot after the previous stream faulted. onTransition(MxGatewayAlarmMapper.SnapshotComplete() with { Kind = Commons.Types.Enums.AlarmTransitionKind.SnapshotStart }); await foreach (var message in _client!.StreamAlarmsAsync(request, ct) .WithCancellation(ct).ConfigureAwait(false)) { if (ct.IsCancellationRequested) break; switch (message.PayloadCase) { case AlarmFeedMessage.PayloadOneofCase.ActiveAlarm: onTransition(MxGatewayAlarmMapper.MapSnapshot(message.ActiveAlarm)); break; case AlarmFeedMessage.PayloadOneofCase.Transition: onTransition(MxGatewayAlarmMapper.MapTransition(message.Transition)); break; case AlarmFeedMessage.PayloadOneofCase.SnapshotComplete: onTransition(MxGatewayAlarmMapper.SnapshotComplete()); break; } } } catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; // clean shutdown } catch (Exception ex) { _logger.LogWarning(ex, "MxGateway alarm stream faulted; reopening in {DelaySeconds}s", reconnectDelay.TotalSeconds); } try { await Task.Delay(reconnectDelay, ct).ConfigureAwait(false); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; } } } /// /// Asynchronously disposes the active MxAccess session, client, and galaxy resources. /// /// A task that represents the asynchronous operation. public async ValueTask DisposeAsync() { if (_session is not null) await _session.DisposeAsync().ConfigureAwait(false); if (_client is not null) await _client.DisposeAsync().ConfigureAwait(false); if (_galaxy is not null) await _galaxy.DisposeAsync().ConfigureAwait(false); } private async Task GetOrAddItemHandleAsync(string tagPath, CancellationToken ct) { if (_tagToHandle.TryGetValue(tagPath, out var existing)) return existing; var handle = await _session!.AddItemAsync(_serverHandle, tagPath, ct).ConfigureAwait(false); _tagToHandle[tagPath] = handle; _handleToTag[handle] = tagPath; return handle; } /// /// MXAccess fixed-size SAFEARRAY attributes only accept a write that supplies /// the whole array; a short list (just the caller's N elements) is rejected and /// the COM write blocks. For a list value, read the tag's current array to learn /// its slot count and pad the value to that length with element-type defaults /// (empty string / 0 / false / default) — the caller's values fill slots 0..N-1, /// the remainder are cleared. Scalars (and a non-array / unreadable tag) pass /// through unchanged. /// private async Task PadArrayToDeclaredSizeAsync(string tag, object? value, CancellationToken ct) { switch (value) { case IReadOnlyList v: return await PadAsync(tag, v, string.Empty, ct).ConfigureAwait(false); case IReadOnlyList v: return await PadAsync(tag, v, 0, ct).ConfigureAwait(false); case IReadOnlyList v: return await PadAsync(tag, v, 0L, ct).ConfigureAwait(false); case IReadOnlyList v: return await PadAsync(tag, v, 0f, ct).ConfigureAwait(false); case IReadOnlyList v: return await PadAsync(tag, v, 0d, ct).ConfigureAwait(false); case IReadOnlyList v: return await PadAsync(tag, v, false, ct).ConfigureAwait(false); case IReadOnlyList v: return await PadAsync(tag, v, default(DateTimeOffset), ct).ConfigureAwait(false); case IReadOnlyList v: return await PadAsync(tag, v, default(DateTime), ct).ConfigureAwait(false); default: return value; // scalar or unsupported — leave as-is } } /// /// Whether a write value is a multi-element (array) attribute value — i.e. one /// encodes as an MXAccess array. Such attributes must be /// addressed with a trailing "[]" on the item reference (see WriteAsync). /// The set matches the array cases in / /// ; scalars (string, int, bool, …) are false. /// private static bool IsArrayValue(object? value) => value switch { IReadOnlyList => true, IReadOnlyList => true, IReadOnlyList => true, IReadOnlyList => true, IReadOnlyList => true, IReadOnlyList => true, IReadOnlyList => true, IReadOnlyList => true, _ => false, }; private async Task> PadAsync(string tag, IReadOnlyList values, T pad, CancellationToken ct) { var size = await GetArraySlotCountAsync(tag, ct).ConfigureAwait(false); if (size <= 0) { // Couldn't determine the declared size (read failed or tag is not an // array). Write what we have rather than blocking the caller; if the // node really is a short SAFEARRAY the gateway will surface the error. _logger.LogWarning("Could not determine array size for '{Tag}'; writing {Count} element(s) unpadded", tag, values.Count); return values; } if (values.Count > size) _logger.LogWarning("Array write for '{Tag}' has {Count} elements but the node holds {Size}; truncating", tag, values.Count, size); var result = new List(size); for (var i = 0; i < size; i++) result.Add(i < values.Count ? values[i] : pad); return result; } /// Reads the tag's current array element count (its declared SAFEARRAY length), or 0 if not an array / unreadable. private async Task GetArraySlotCountAsync(string tag, CancellationToken ct) { var reads = await _session! .ReadBulkAsync(_serverHandle, new[] { tag }, TimeSpan.FromMilliseconds(_readTimeoutMs), ct) .ConfigureAwait(false); var v = reads.Count > 0 ? reads[0].Value : null; if (v is null || v.KindCase != MxValue.KindOneofCase.ArrayValue) return 0; var a = v.ArrayValue; return a.ValuesCase switch { MxArray.ValuesOneofCase.StringValues => a.StringValues.Values.Count, MxArray.ValuesOneofCase.Int32Values => a.Int32Values.Values.Count, MxArray.ValuesOneofCase.Int64Values => a.Int64Values.Values.Count, MxArray.ValuesOneofCase.FloatValues => a.FloatValues.Values.Count, MxArray.ValuesOneofCase.DoubleValues => a.DoubleValues.Values.Count, MxArray.ValuesOneofCase.BoolValues => a.BoolValues.Values.Count, MxArray.ValuesOneofCase.TimestampValues => a.TimestampValues.Values.Count, _ => 0, }; } /// /// Ensures the item is advised in MXAccess SUPERVISORY mode (advise-once). A /// supervisory advise is what lets the gateway accept a write and it still /// delivers OnDataChange, so it doubles as the monitoring subscription when this /// connection has no write-user context. The client package exposes only a plain /// AdviseAsync, so the supervisory advise is issued as a raw /// command (mirroring how the session /// builds every other command). The result is remembered per handle; a failure is /// not cached so the next attempt retries rather than wedging the item permanently. /// private async Task EnsureSupervisoryAdvisedAsync(int handle, CancellationToken ct) { // GetOrAdd + Lazy runs the advise at most once per handle; concurrent // callers await the same Task and so never reach WriteBulk before it // completes (default Lazy is thread-safe, factory runs once). var advise = _supervisoryAdvised.GetOrAdd( handle, h => new Lazy(() => AdviseSupervisoryCoreAsync(h, ct))); try { await advise.Value.ConfigureAwait(false); } catch { // Evict the faulted entry (only if it's still the one we awaited) so the // next write retries rather than caching a transient advise failure. _supervisoryAdvised.TryRemove(new KeyValuePair>(handle, advise)); throw; } } private async Task AdviseSupervisoryCoreAsync(int handle, CancellationToken ct) { var reply = await _session!.InvokeAsync( new MxCommandRequest { SessionId = _session.SessionId, ClientCorrelationId = Guid.NewGuid().ToString("N"), Command = new MxCommand { Kind = MxCommandKind.AdviseSupervisory, AdviseSupervisory = new AdviseSupervisoryCommand { ServerHandle = _serverHandle, ItemHandle = handle, }, }, }, ct).ConfigureAwait(false); reply.EnsureProtocolSuccess().EnsureMxAccessSuccess(); } /// /// Maps MXAccess quality. A failing status proxy is authoritative bad; otherwise /// the OPC-style quality byte: ≥192 Good, ≥64 Uncertain, else Bad. /// private static QualityCode MapQuality(int quality, IEnumerable statuses) { if (statuses.Any(s => !s.IsSuccess())) return QualityCode.Bad; return quality switch { >= 192 => QualityCode.Good, >= 64 => QualityCode.Uncertain, _ => QualityCode.Bad, }; } private static MxValue ToMxValue(object? value) => value switch { null => new MxValue { IsNull = true }, bool b => b.ToMxValue(), int i => i.ToMxValue(), long l => l.ToMxValue(), float f => f.ToMxValue(), double d => d.ToMxValue(), string s => s.ToMxValue(), DateTimeOffset dto => dto.ToMxValue(), DateTime dt => dt.ToMxValue(), // List/array attribute values. The InstanceActor decodes a canonical JSON // List attribute into a typed List (AttributeValueCodec produces // List) before the write so the DCL // can push a real MXAccess array. Map each to the client's typed array // encoder. Without these a List would fall through to Convert.ToString // and write a garbage scalar string to an array node — which the gateway's // COM write then rejects/blocks (the array-write hang). IReadOnlyList lb => lb.ToMxValue(), IReadOnlyList li => li.ToMxValue(), IReadOnlyList ll => ll.ToMxValue(), IReadOnlyList lf => lf.ToMxValue(), IReadOnlyList ld => ld.ToMxValue(), IReadOnlyList ls => ls.ToMxValue(), IReadOnlyList ldto => ldto.ToMxValue(), IReadOnlyList ldt => ldt.Select(d => (DateTimeOffset)d).ToList().ToMxValue(), // Fall back to invariant string for any other CLR type. _ => Convert.ToString(value, CultureInfo.InvariantCulture)!.ToMxValue(), }; } /// Builds instances. public sealed class RealMxGatewayClientFactory : IMxGatewayClientFactory { private readonly ILoggerFactory? _loggerFactory; /// Initializes a new factory. /// Logger factory passed to each created client. public RealMxGatewayClientFactory(ILoggerFactory? loggerFactory) => _loggerFactory = loggerFactory; /// public IMxGatewayClient Create() => new RealMxGatewayClient(_loggerFactory); }