feat(lmxproxy): phase 7 — integration tests, deployment to windev, v1 cutover

- Replaced STA dispatch thread with Task.Run pattern for COM interop
- Fixed TypedValue oneof tracking with property-level _setCase field
- Added x-api-key DelegatingHandler for gRPC metadata authentication
- Fixed CheckApiKey RPC to validate request body key (not header)
- Integration tests: 15/17 pass (reads, subscribes, API keys, connections)
- 2 write tests pending (OnWriteComplete callback timing issue)
- v2 service deployed on windev port 50100

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-03-22 01:11:44 -04:00
parent 6d9bf594ec
commit 779598d962
14 changed files with 497 additions and 383 deletions

View File

@@ -13,6 +13,7 @@ namespace ZB.MOM.WW.LmxProxy.Host.MxAccess
/// <summary>
/// Subscribes to value changes for the specified addresses.
/// Stores subscription state for reconnect replay.
/// COM calls dispatched via Task.Run.
/// </summary>
public async Task<IAsyncDisposable> SubscribeAsync(
IEnumerable<string> addresses,
@@ -24,19 +25,22 @@ namespace ZB.MOM.WW.LmxProxy.Host.MxAccess
var addressList = addresses.ToList();
await _staThread.DispatchAsync(() =>
await Task.Run(() =>
{
foreach (var address in addressList)
lock (_lock)
{
SubscribeInternal(address);
if (!IsConnected || _lmxProxy == null)
throw new InvalidOperationException("Not connected to MxAccess");
// Store for reconnect replay
lock (_lock)
foreach (var address in addressList)
{
SubscribeInternal(address);
// Store for reconnect replay
_storedSubscriptions[address] = callback;
}
}
});
}, ct);
Log.Information("Subscribed to {Count} tags", addressList.Count);
@@ -50,14 +54,13 @@ namespace ZB.MOM.WW.LmxProxy.Host.MxAccess
{
var addressList = addresses.ToList();
await _staThread.DispatchAsync(() =>
await Task.Run(() =>
{
foreach (var address in addressList)
lock (_lock)
{
UnsubscribeInternal(address);
lock (_lock)
foreach (var address in addressList)
{
UnsubscribeInternal(address);
_storedSubscriptions.Remove(address);
}
}
@@ -81,53 +84,87 @@ namespace ZB.MOM.WW.LmxProxy.Host.MxAccess
Log.Information("Recreating {Count} stored subscriptions after reconnect", subscriptions.Count);
await _staThread.DispatchAsync(() =>
await Task.Run(() =>
{
foreach (var kvp in subscriptions)
lock (_lock)
{
try
foreach (var kvp in subscriptions)
{
SubscribeInternal(kvp.Key);
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to recreate subscription for {Address}", kvp.Key);
try
{
SubscribeInternal(kvp.Key);
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to recreate subscription for {Address}", kvp.Key);
}
}
}
});
}
// ── Internal COM calls (execute on STA thread) ──────────
// ── Internal COM calls ──────────
/// <summary>
/// Registers a tag subscription with MxAccess COM API (AddItem + AdviseSupervisory).
/// Must be called on the STA thread.
/// Must be called while holding _lock.
/// </summary>
private void SubscribeInternal(string address)
{
// The exact MxAccess COM API call is:
// var itemHandle = _lmxProxy.AddItem(_connectionHandle, address);
// _lmxProxy.AdviseSupervisory(_connectionHandle, itemHandle);
//
// Consult src-reference/Implementation/MxAccessClient.Subscription.cs
if (_lmxProxy == null || _connectionHandle <= 0)
throw new InvalidOperationException("Not connected to MxAccess");
throw new NotImplementedException(
"SubscribeInternal must be implemented using ArchestrA.MXAccess COM API. " +
"See src-reference/Implementation/MxAccessClient.Subscription.cs for the exact pattern.");
// If already subscribed to this address, skip
if (_addressToHandle.ContainsKey(address))
{
Log.Debug("Already subscribed to {Address}, skipping", address);
return;
}
// Add the item to MxAccess
int itemHandle = _lmxProxy.AddItem(_connectionHandle, address);
// Track handle-to-address and address-to-handle mappings
_handleToAddress[itemHandle] = address;
_addressToHandle[address] = itemHandle;
// Advise (subscribe) for data change events
_lmxProxy.AdviseSupervisory(_connectionHandle, itemHandle);
Log.Debug("Subscribed to {Address} with handle {Handle}", address, itemHandle);
}
/// <summary>
/// Unregisters a tag subscription from MxAccess COM API (UnAdvise + RemoveItem).
/// Must be called on the STA thread.
/// Must be called while holding _lock.
/// </summary>
private void UnsubscribeInternal(string address)
{
// The exact MxAccess COM API call is:
// _lmxProxy.UnAdvise(_connectionHandle, itemHandle);
// _lmxProxy.RemoveItem(_connectionHandle, itemHandle);
if (!_addressToHandle.TryGetValue(address, out int itemHandle))
{
Log.Debug("No active subscription for {Address}, skipping unsubscribe", address);
return;
}
throw new NotImplementedException(
"UnsubscribeInternal must be implemented using ArchestrA.MXAccess COM API.");
try
{
if (_lmxProxy != null && _connectionHandle > 0)
{
_lmxProxy.UnAdvise(_connectionHandle, itemHandle);
_lmxProxy.RemoveItem(_connectionHandle, itemHandle);
}
}
catch (Exception ex)
{
Log.Warning(ex, "Error unsubscribing from {Address} (handle {Handle})", address, itemHandle);
}
finally
{
_handleToAddress.Remove(itemHandle);
_addressToHandle.Remove(address);
}
Log.Debug("Unsubscribed from {Address} (handle {Handle})", address, itemHandle);
}
/// <summary>