Phase 2 — port GalaxyRepository to Galaxy.Host + DbBackedGalaxyBackend, smoke-tested against live ZB. Real Galaxy gobject hierarchy + dynamic attributes now flow through the IPC contract end-to-end without any MXAccess code involvement, so the OPC UA address-space build (Stream C.4 acceptance) becomes parity-testable today even before the COM client port lands. Backend/Galaxy/GalaxyRepository.cs is a byte-for-byte port of v1 GalaxyRepositoryService's HierarchySql + AttributesSql (the two SQL bodies, both ~50 lines of recursive CTE template-chain + deployed_package_chain logic, are identical to v1 so the row set is verifiably the same — extended-attributes + scope-filter queries from v1 are intentionally not ported yet, they're refinements not on the Phase 2 critical path); plus TestConnectionAsync (SELECT 1) and GetLastDeployTimeAsync (SELECT time_of_last_deploy FROM galaxy) for the ChangeDetection deploy-watermark path. Backend/Galaxy/GalaxyRepositoryOptions defaults to localhost ZB Integrated Security; runtime override comes from DriverConfig.Database section per plan.md §"Galaxy DriverConfig". Backend/Galaxy/GalaxyHierarchyRow + GalaxyAttributeRow are the row-shape DTOs (no required modifier — net48 lacks RequiredMemberAttribute and we'd need a polyfill shim like the existing IsExternalInit one; default-string init is simpler). System.Data.SqlClient 4.9.0 added (the same package the v1 Host uses; net48-compatible). Backend/DbBackedGalaxyBackend wraps the repository: DiscoverAsync builds a real DiscoverHierarchyResponse (groups attributes by gobject, resolves parent-by-tagname, maps category_id → human-readable template-category name mirroring v1 AlarmObjectFilter); ReadValuesAsync/WriteValuesAsync/HistoryReadAsync still surface "MXAccess code lift pending (Phase 2 Task B.1)" because runtime data values genuinely need the COM client; OpenSession/CloseSession/Subscribe/Unsubscribe/AlarmSubscribe/AlarmAck/Recycle return success without backend work (subscription ID is a synthetic counter for now). Live smoke tests (GalaxyRepositoryLiveSmokeTests) skip when localhost ZB is unreachable; when present they verify (1) TestConnection returns true, (2) GetHierarchy returns at least one deployed gobject with a non-empty TagName, (3) GetAttributes returns rows with FullTagReference matching the "tag.attribute" shape, (4) GetLastDeployTime returns a value, (5) DbBackedBackend.DiscoverAsync returns at least one gobject with attributes and a populated TemplateCategory. All 5 pass against the local Galaxy. Full solution 957 pass / 1 pre-existing Phase 0 baseline; the 494 v1 IntegrationTests + 6 v1 IntegrationTests-net48 tests still pass — legacy OtOpcUa.Host untouched. Remaining for the Phase 2 exit gate is the MXAccess COM client port itself (the v1 MxAccessClient partials + IMxProxy abstraction + StaPump-based Connect/Subscribe/Read/Write semantics) — Discover is now solved in DB-backed form, so the lift can focus exclusively on the runtime data-plane.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend;
|
||||
|
||||
/// <summary>
|
||||
/// Galaxy backend that uses the live <c>ZB</c> repository for <see cref="DiscoverAsync"/> —
|
||||
/// real gobject hierarchy + attributes flow through to the Proxy without needing the MXAccess
|
||||
/// COM client. Runtime data-plane calls (Read/Write/Subscribe/Alarm/History) still surface
|
||||
/// as "MXAccess code lift pending" until the COM client port lands. This is the highest-value
|
||||
/// intermediate state because Discover is what powers the OPC UA address-space build, so
|
||||
/// downstream Proxy + parity tests can exercise the complete tree shape today.
|
||||
/// </summary>
|
||||
public sealed class DbBackedGalaxyBackend(GalaxyRepository repository) : IGalaxyBackend
|
||||
{
|
||||
private long _nextSessionId;
|
||||
private long _nextSubscriptionId;
|
||||
|
||||
public Task<OpenSessionResponse> OpenSessionAsync(OpenSessionRequest req, CancellationToken ct)
|
||||
{
|
||||
var id = Interlocked.Increment(ref _nextSessionId);
|
||||
return Task.FromResult(new OpenSessionResponse { Success = true, SessionId = id });
|
||||
}
|
||||
|
||||
public Task CloseSessionAsync(CloseSessionRequest req, CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
public async Task<DiscoverHierarchyResponse> DiscoverAsync(DiscoverHierarchyRequest req, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var hierarchy = await repository.GetHierarchyAsync(ct).ConfigureAwait(false);
|
||||
var attributes = await repository.GetAttributesAsync(ct).ConfigureAwait(false);
|
||||
|
||||
// Group attributes by their owning gobject for the IPC payload.
|
||||
var attrsByGobject = attributes
|
||||
.GroupBy(a => a.GobjectId)
|
||||
.ToDictionary(g => g.Key, g => g.Select(MapAttribute).ToArray());
|
||||
|
||||
var parentByChild = hierarchy
|
||||
.ToDictionary(o => o.GobjectId, o => o.ParentGobjectId);
|
||||
var nameByGobject = hierarchy
|
||||
.ToDictionary(o => o.GobjectId, o => o.TagName);
|
||||
|
||||
var objects = hierarchy.Select(o => new GalaxyObjectInfo
|
||||
{
|
||||
ContainedName = string.IsNullOrEmpty(o.ContainedName) ? o.TagName : o.ContainedName,
|
||||
TagName = o.TagName,
|
||||
ParentContainedName = parentByChild.TryGetValue(o.GobjectId, out var p)
|
||||
&& p != 0
|
||||
&& nameByGobject.TryGetValue(p, out var pName)
|
||||
? pName
|
||||
: null,
|
||||
TemplateCategory = MapCategory(o.CategoryId),
|
||||
Attributes = attrsByGobject.TryGetValue(o.GobjectId, out var a) ? a : System.Array.Empty<GalaxyAttributeInfo>(),
|
||||
}).ToArray();
|
||||
|
||||
return new DiscoverHierarchyResponse { Success = true, Objects = objects };
|
||||
}
|
||||
catch (Exception ex) when (ex is System.Data.SqlClient.SqlException
|
||||
or InvalidOperationException
|
||||
or TimeoutException)
|
||||
{
|
||||
return new DiscoverHierarchyResponse
|
||||
{
|
||||
Success = false,
|
||||
Error = $"Galaxy ZB repository error: {ex.Message}",
|
||||
Objects = System.Array.Empty<GalaxyObjectInfo>(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public Task<ReadValuesResponse> ReadValuesAsync(ReadValuesRequest req, CancellationToken ct)
|
||||
=> Task.FromResult(new ReadValuesResponse
|
||||
{
|
||||
Success = false,
|
||||
Error = "MXAccess code lift pending (Phase 2 Task B.1) — DB-backed backend covers Discover only",
|
||||
Values = System.Array.Empty<GalaxyDataValue>(),
|
||||
});
|
||||
|
||||
public Task<WriteValuesResponse> WriteValuesAsync(WriteValuesRequest req, CancellationToken ct)
|
||||
{
|
||||
var results = new WriteValueResult[req.Writes.Length];
|
||||
for (var i = 0; i < req.Writes.Length; i++)
|
||||
{
|
||||
results[i] = new WriteValueResult
|
||||
{
|
||||
TagReference = req.Writes[i].TagReference,
|
||||
StatusCode = 0x80020000u,
|
||||
Error = "MXAccess code lift pending (Phase 2 Task B.1)",
|
||||
};
|
||||
}
|
||||
return Task.FromResult(new WriteValuesResponse { Results = results });
|
||||
}
|
||||
|
||||
public Task<SubscribeResponse> SubscribeAsync(SubscribeRequest req, CancellationToken ct)
|
||||
{
|
||||
var sid = Interlocked.Increment(ref _nextSubscriptionId);
|
||||
return Task.FromResult(new SubscribeResponse
|
||||
{
|
||||
Success = true,
|
||||
SubscriptionId = sid,
|
||||
ActualIntervalMs = req.RequestedIntervalMs,
|
||||
});
|
||||
}
|
||||
|
||||
public Task UnsubscribeAsync(UnsubscribeRequest req, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task SubscribeAlarmsAsync(AlarmSubscribeRequest req, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task AcknowledgeAlarmAsync(AlarmAckRequest req, CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
public Task<HistoryReadResponse> HistoryReadAsync(HistoryReadRequest req, CancellationToken ct)
|
||||
=> Task.FromResult(new HistoryReadResponse
|
||||
{
|
||||
Success = false,
|
||||
Error = "MXAccess + Historian code lift pending (Phase 2 Task B.1)",
|
||||
Tags = System.Array.Empty<HistoryTagValues>(),
|
||||
});
|
||||
|
||||
public Task<RecycleStatusResponse> RecycleAsync(RecycleHostRequest req, CancellationToken ct)
|
||||
=> Task.FromResult(new RecycleStatusResponse { Accepted = true, GraceSeconds = 15 });
|
||||
|
||||
private static GalaxyAttributeInfo MapAttribute(GalaxyAttributeRow row) => new()
|
||||
{
|
||||
AttributeName = row.AttributeName,
|
||||
MxDataType = row.MxDataType,
|
||||
IsArray = row.IsArray,
|
||||
ArrayDim = row.ArrayDimension is int d and > 0 ? (uint)d : null,
|
||||
SecurityClassification = row.SecurityClassification,
|
||||
IsHistorized = row.IsHistorized,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Galaxy <c>template_definition.category_id</c> → human-readable name.
|
||||
/// Mirrors v1 Host's <c>AlarmObjectFilter</c> mapping.
|
||||
/// </summary>
|
||||
private static string MapCategory(int categoryId) => categoryId switch
|
||||
{
|
||||
1 => "$WinPlatform",
|
||||
3 => "$AppEngine",
|
||||
4 => "$Area",
|
||||
10 => "$UserDefined",
|
||||
11 => "$ApplicationObject",
|
||||
13 => "$Area",
|
||||
17 => "$DeviceIntegration",
|
||||
24 => "$ViewEngine",
|
||||
26 => "$ViewApp",
|
||||
_ => $"category-{categoryId}",
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user