fix(galaxy): AdviseSupervisory before raw Write so writes commit
v2-ci / build (push) Failing after 45s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped

A plain MXAccess Write runs with no user login (WriteUserId is typically 0),
and MXAccess only COMMITS such a write when the item is advised in supervisory
mode. Without it the gateway's Write call doesn't throw (the reply looks OK) but
the value never reaches the galaxy. GatewayGalaxyDataWriter now issues
AdviseSupervisory (once per item handle) before each raw Write; SecuredWrite/
VerifiedWrite tags keep their own user-identity path. Live-verified end-to-end:
an authorized write to a Galaxy equipment tag commits and PERSISTS across a
fresh re-subscribe; an anonymous write is denied.

(The sister ScadaBridge driver commits writes the other way — a configured
non-zero WriteUserId + regular Advise; we have no galaxy login, so we use the
supervisory context.)
This commit is contained in:
Joseph Doherty
2026-06-13 22:53:59 -04:00
parent 8d8c05f595
commit f05b5d79c4
@@ -27,6 +27,9 @@ public sealed class GatewayGalaxyDataWriter : IGalaxyDataWriter
private readonly ILogger _logger;
private readonly ConcurrentDictionary<string, int> _itemHandles =
new(StringComparer.OrdinalIgnoreCase);
// Item handles we've already AdviseSupervisory'd this session — supervisory advise is
// idempotent but the round-trip isn't free, so do it once per handle (see EnsureSupervisoryAdvisedAsync).
private readonly ConcurrentDictionary<int, byte> _supervisedHandles = new();
/// <summary>Initializes a new Galaxy data writer.</summary>
/// <param name="session">The MXAccess gateway session.</param>
@@ -77,9 +80,24 @@ public sealed class GatewayGalaxyDataWriter : IGalaxyDataWriter
.ConfigureAwait(false);
var mxValue = MxValueEncoder.Encode(request.Value);
var reply = NeedsSecuredWrite(classification)
? await InvokeWriteSecuredAsync(session, serverHandle, itemHandle, mxValue, ct).ConfigureAwait(false)
: await session.WriteRawAsync(serverHandle, itemHandle, mxValue, _writeUserId, ct).ConfigureAwait(false);
MxCommandReply reply;
if (NeedsSecuredWrite(classification))
{
// SecuredWrite/VerifiedWrite tags carry their own ArchestrA user identity
// (current/verifier user), so they don't use the supervisory path.
reply = await InvokeWriteSecuredAsync(session, serverHandle, itemHandle, mxValue, ct)
.ConfigureAwait(false);
}
else
{
// A raw Write runs with NO user login (WriteUserId is typically 0), so MXAccess
// only COMMITS the value when the item is advised in SUPERVISORY mode. Without it
// the gateway's Write call doesn't throw (reply looks OK) but the value never
// reaches the galaxy. AdviseSupervisory once per handle, then Write.
await EnsureSupervisoryAdvisedAsync(session, serverHandle, itemHandle, ct).ConfigureAwait(false);
reply = await session.WriteRawAsync(serverHandle, itemHandle, mxValue, _writeUserId, ct)
.ConfigureAwait(false);
}
return TranslateReply(reply, request.FullReference);
}
@@ -110,6 +128,48 @@ public sealed class GatewayGalaxyDataWriter : IGalaxyDataWriter
return handle;
}
/// <summary>
/// Advise an item in MXAccess <c>AdviseSupervisory</c> mode. A plain <c>Write</c> runs
/// under no user login (<see cref="_writeUserId"/> is typically 0); MXAccess only
/// COMMITS such a write when the item is supervisory-advised. (A logged-in identity —
/// i.e. a non-zero WriteUserId, as the ScadaBridge sister driver uses — would commit via
/// a regular Advise instead; we don't log in, so we use the supervisory context.)
/// The gateway client doesn't expose a typed method, so we build the <see cref="MxCommand"/>
/// and route through <c>InvokeAsync</c> (same pattern as <see cref="InvokeWriteSecuredAsync"/>).
/// Idempotent per handle for the session lifetime.
/// </summary>
private async Task EnsureSupervisoryAdvisedAsync(
MxGatewaySession session, int serverHandle, int itemHandle, CancellationToken ct)
{
if (!_supervisedHandles.TryAdd(itemHandle, 0)) return;
var request = new MxCommandRequest
{
SessionId = session.SessionId,
ClientCorrelationId = Guid.NewGuid().ToString("N"),
Command = new MxCommand
{
Kind = MxCommandKind.AdviseSupervisory,
AdviseSupervisory = new AdviseSupervisoryCommand
{
ServerHandle = serverHandle,
ItemHandle = itemHandle,
},
},
};
var reply = await session.InvokeAsync(request, ct).ConfigureAwait(false);
if (reply.ProtocolStatus is { } proto && proto.Code != ProtocolStatusCode.Ok)
{
// Supervisory advise failed — forget it so the next write retries, and let the
// write proceed (it surfaces its own status via TranslateReply).
_supervisedHandles.TryRemove(itemHandle, out _);
_logger.LogWarning(
"GalaxyDriver supervisory advise failed for item {ItemHandle}: {Code} {Message}",
itemHandle, proto.Code, proto.Message);
}
}
/// <summary>
/// Issue a WriteSecured command. The high-level session client doesn't expose
/// <c>WriteSecuredAsync</c> as a typed method — we build the <see cref="MxCommand"/>