From 549cd366624a1d28e42d5c38b34146288df2e37c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 17 Apr 2026 23:14:09 -0400 Subject: [PATCH] =?UTF-8?q?Phase=202=20=E2=80=94=20port=20GalaxyRepository?= =?UTF-8?q?=20to=20Galaxy.Host=20+=20DbBackedGalaxyBackend,=20smoke-tested?= =?UTF-8?q?=20against=20live=20ZB.=20Real=20Galaxy=20gobject=20hierarchy?= =?UTF-8?q?=20+=20dynamic=20attributes=20now=20flow=20through=20the=20IPC?= =?UTF-8?q?=20contract=20end-to-end=20without=20any=20MXAccess=20code=20in?= =?UTF-8?q?volvement,=20so=20the=20OPC=20UA=20address-space=20build=20(Str?= =?UTF-8?q?eam=20C.4=20acceptance)=20becomes=20parity-testable=20today=20e?= =?UTF-8?q?ven=20before=20the=20COM=20client=20port=20lands.=20Backend/Gal?= =?UTF-8?q?axy/GalaxyRepository.cs=20is=20a=20byte-for-byte=20port=20of=20?= =?UTF-8?q?v1=20GalaxyRepositoryService's=20HierarchySql=20+=20AttributesS?= =?UTF-8?q?ql=20(the=20two=20SQL=20bodies,=20both=20~50=20lines=20of=20rec?= =?UTF-8?q?ursive=20CTE=20template-chain=20+=20deployed=5Fpackage=5Fchain?= =?UTF-8?q?=20logic,=20are=20identical=20to=20v1=20so=20the=20row=20set=20?= =?UTF-8?q?is=20verifiably=20the=20same=20=E2=80=94=20extended-attributes?= =?UTF-8?q?=20+=20scope-filter=20queries=20from=20v1=20are=20intentionally?= =?UTF-8?q?=20not=20ported=20yet,=20they're=20refinements=20not=20on=20the?= =?UTF-8?q?=20Phase=202=20critical=20path);=20plus=20TestConnectionAsync?= =?UTF-8?q?=20(SELECT=201)=20and=20GetLastDeployTimeAsync=20(SELECT=20time?= =?UTF-8?q?=5Fof=5Flast=5Fdeploy=20FROM=20galaxy)=20for=20the=20ChangeDete?= =?UTF-8?q?ction=20deploy-watermark=20path.=20Backend/Galaxy/GalaxyReposit?= =?UTF-8?q?oryOptions=20defaults=20to=20localhost=20ZB=20Integrated=20Secu?= =?UTF-8?q?rity;=20runtime=20override=20comes=20from=20DriverConfig.Databa?= =?UTF-8?q?se=20section=20per=20plan.md=20=C2=A7"Galaxy=20DriverConfig".?= =?UTF-8?q?=20Backend/Galaxy/GalaxyHierarchyRow=20+=20GalaxyAttributeRow?= =?UTF-8?q?=20are=20the=20row-shape=20DTOs=20(no=20`required`=20modifier?= =?UTF-8?q?=20=E2=80=94=20net48=20lacks=20RequiredMemberAttribute=20and=20?= =?UTF-8?q?we'd=20need=20a=20polyfill=20shim=20like=20the=20existing=20IsE?= =?UTF-8?q?xternalInit=20one;=20default-string=20init=20is=20simpler).=20S?= =?UTF-8?q?ystem.Data.SqlClient=204.9.0=20added=20(the=20same=20package=20?= =?UTF-8?q?the=20v1=20Host=20uses;=20net48-compatible).=20Backend/DbBacked?= =?UTF-8?q?GalaxyBackend=20wraps=20the=20repository:=20DiscoverAsync=20bui?= =?UTF-8?q?lds=20a=20real=20DiscoverHierarchyResponse=20(groups=20attribut?= =?UTF-8?q?es=20by=20gobject,=20resolves=20parent-by-tagname,=20maps=20cat?= =?UTF-8?q?egory=5Fid=20=E2=86=92=20human-readable=20template-category=20n?= =?UTF-8?q?ame=20mirroring=20v1=20AlarmObjectFilter);=20ReadValuesAsync/Wr?= =?UTF-8?q?iteValuesAsync/HistoryReadAsync=20still=20surface=20"MXAccess?= =?UTF-8?q?=20code=20lift=20pending=20(Phase=202=20Task=20B.1)"=20because?= =?UTF-8?q?=20runtime=20data=20values=20genuinely=20need=20the=20COM=20cli?= =?UTF-8?q?ent;=20OpenSession/CloseSession/Subscribe/Unsubscribe/AlarmSubs?= =?UTF-8?q?cribe/AlarmAck/Recycle=20return=20success=20without=20backend?= =?UTF-8?q?=20work=20(subscription=20ID=20is=20a=20synthetic=20counter=20f?= =?UTF-8?q?or=20now).=20Live=20smoke=20tests=20(GalaxyRepositoryLiveSmokeT?= =?UTF-8?q?ests)=20skip=20when=20localhost=20ZB=20is=20unreachable;=20when?= =?UTF-8?q?=20present=20they=20verify=20(1)=20TestConnection=20returns=20t?= =?UTF-8?q?rue,=20(2)=20GetHierarchy=20returns=20at=20least=20one=20deploy?= =?UTF-8?q?ed=20gobject=20with=20a=20non-empty=20TagName,=20(3)=20GetAttri?= =?UTF-8?q?butes=20returns=20rows=20with=20FullTagReference=20matching=20t?= =?UTF-8?q?he=20"tag.attribute"=20shape,=20(4)=20GetLastDeployTime=20retur?= =?UTF-8?q?ns=20a=20value,=20(5)=20DbBackedBackend.DiscoverAsync=20returns?= =?UTF-8?q?=20at=20least=20one=20gobject=20with=20attributes=20and=20a=20p?= =?UTF-8?q?opulated=20TemplateCategory.=20All=205=20pass=20against=20the?= =?UTF-8?q?=20local=20Galaxy.=20Full=20solution=20957=20pass=20/=201=20pre?= =?UTF-8?q?-existing=20Phase=200=20baseline;=20the=20494=20v1=20Integratio?= =?UTF-8?q?nTests=20+=206=20v1=20IntegrationTests-net48=20tests=20still=20?= =?UTF-8?q?pass=20=E2=80=94=20legacy=20OtOpcUa.Host=20untouched.=20Remaini?= =?UTF-8?q?ng=20for=20the=20Phase=202=20exit=20gate=20is=20the=20MXAccess?= =?UTF-8?q?=20COM=20client=20port=20itself=20(the=20v1=20MxAccessClient=20?= =?UTF-8?q?partials=20+=20IMxProxy=20abstraction=20+=20StaPump-based=20Con?= =?UTF-8?q?nect/Subscribe/Read/Write=20semantics)=20=E2=80=94=20Discover?= =?UTF-8?q?=20is=20now=20solved=20in=20DB-backed=20form,=20so=20the=20lift?= =?UTF-8?q?=20can=20focus=20exclusively=20on=20the=20runtime=20data-plane.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Backend/DbBackedGalaxyBackend.cs | 153 ++++++++++++ .../Backend/Galaxy/GalaxyHierarchyRow.cs | 35 +++ .../Backend/Galaxy/GalaxyRepository.cs | 224 ++++++++++++++++++ .../Backend/Galaxy/GalaxyRepositoryOptions.cs | 13 + ...B.MOM.WW.OtOpcUa.Driver.Galaxy.Host.csproj | 1 + .../GalaxyRepositoryLiveSmokeTests.cs | 100 ++++++++ 6 files changed, 526 insertions(+) create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/DbBackedGalaxyBackend.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Galaxy/GalaxyHierarchyRow.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Galaxy/GalaxyRepository.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Galaxy/GalaxyRepositoryOptions.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/GalaxyRepositoryLiveSmokeTests.cs diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/DbBackedGalaxyBackend.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/DbBackedGalaxyBackend.cs new file mode 100644 index 0000000..88e64c3 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/DbBackedGalaxyBackend.cs @@ -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; + +/// +/// Galaxy backend that uses the live ZB repository for — +/// 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. +/// +public sealed class DbBackedGalaxyBackend(GalaxyRepository repository) : IGalaxyBackend +{ + private long _nextSessionId; + private long _nextSubscriptionId; + + public Task 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 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(), + }).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(), + }; + } + } + + public Task 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(), + }); + + public Task 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 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 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(), + }); + + public Task 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, + }; + + /// + /// Galaxy template_definition.category_id → human-readable name. + /// Mirrors v1 Host's AlarmObjectFilter mapping. + /// + 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}", + }; +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Galaxy/GalaxyHierarchyRow.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Galaxy/GalaxyHierarchyRow.cs new file mode 100644 index 0000000..8f0ede4 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Galaxy/GalaxyHierarchyRow.cs @@ -0,0 +1,35 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy; + +/// +/// One row from the v1 HierarchySql. Galaxy gobject deployed instance with its +/// hierarchy parent + template-chain context. +/// +public sealed class GalaxyHierarchyRow +{ + public int GobjectId { get; init; } + public string TagName { get; init; } = string.Empty; + public string ContainedName { get; init; } = string.Empty; + public string BrowseName { get; init; } = string.Empty; + public int ParentGobjectId { get; init; } + public bool IsArea { get; init; } + public int CategoryId { get; init; } + public int HostedByGobjectId { get; init; } + public System.Collections.Generic.IReadOnlyList TemplateChain { get; init; } = System.Array.Empty(); +} + +/// One row from the v1 AttributesSql. +public sealed class GalaxyAttributeRow +{ + public int GobjectId { get; init; } + public string TagName { get; init; } = string.Empty; + public string AttributeName { get; init; } = string.Empty; + public string FullTagReference { get; init; } = string.Empty; + public int MxDataType { get; init; } + public string? DataTypeName { get; init; } + public bool IsArray { get; init; } + public int? ArrayDimension { get; init; } + public int MxAttributeCategory { get; init; } + public int SecurityClassification { get; init; } + public bool IsHistorized { get; init; } + public bool IsAlarm { get; init; } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Galaxy/GalaxyRepository.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Galaxy/GalaxyRepository.cs new file mode 100644 index 0000000..2d511be --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Galaxy/GalaxyRepository.cs @@ -0,0 +1,224 @@ +using System; +using System.Collections.Generic; +using System.Data.SqlClient; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy; + +/// +/// SQL access to the Galaxy ZB repository — port of v1 GalaxyRepositoryService. +/// The two SQL bodies (Hierarchy + Attributes) are byte-for-byte identical to v1 so the +/// queries surface the same row set at parity time. Extended-attributes and scope-filter +/// queries from v1 are intentionally not ported yet — they're refinements that aren't on +/// the Phase 2 critical path. +/// +public sealed class GalaxyRepository(GalaxyRepositoryOptions options) +{ + public async Task TestConnectionAsync(CancellationToken ct = default) + { + try + { + using var conn = new SqlConnection(options.ConnectionString); + await conn.OpenAsync(ct).ConfigureAwait(false); + using var cmd = new SqlCommand("SELECT 1", conn) { CommandTimeout = options.CommandTimeoutSeconds }; + var result = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false); + return result is int i && i == 1; + } + catch (SqlException) { return false; } + catch (InvalidOperationException) { return false; } + } + + public async Task GetLastDeployTimeAsync(CancellationToken ct = default) + { + using var conn = new SqlConnection(options.ConnectionString); + await conn.OpenAsync(ct).ConfigureAwait(false); + using var cmd = new SqlCommand("SELECT time_of_last_deploy FROM galaxy", conn) + { CommandTimeout = options.CommandTimeoutSeconds }; + var result = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false); + return result is DateTime dt ? dt : null; + } + + public async Task> GetHierarchyAsync(CancellationToken ct = default) + { + var rows = new List(); + + using var conn = new SqlConnection(options.ConnectionString); + await conn.OpenAsync(ct).ConfigureAwait(false); + + using var cmd = new SqlCommand(HierarchySql, conn) { CommandTimeout = options.CommandTimeoutSeconds }; + using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false); + + while (await reader.ReadAsync(ct).ConfigureAwait(false)) + { + var templateChainRaw = reader.IsDBNull(8) ? string.Empty : reader.GetString(8); + var templateChain = templateChainRaw.Length == 0 + ? Array.Empty() + : templateChainRaw.Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries) + .Select(s => s.Trim()) + .Where(s => s.Length > 0) + .ToArray(); + + rows.Add(new GalaxyHierarchyRow + { + GobjectId = Convert.ToInt32(reader.GetValue(0)), + TagName = reader.GetString(1), + ContainedName = reader.IsDBNull(2) ? string.Empty : reader.GetString(2), + BrowseName = reader.GetString(3), + ParentGobjectId = Convert.ToInt32(reader.GetValue(4)), + IsArea = Convert.ToInt32(reader.GetValue(5)) == 1, + CategoryId = Convert.ToInt32(reader.GetValue(6)), + HostedByGobjectId = Convert.ToInt32(reader.GetValue(7)), + TemplateChain = templateChain, + }); + } + return rows; + } + + public async Task> GetAttributesAsync(CancellationToken ct = default) + { + var rows = new List(); + + using var conn = new SqlConnection(options.ConnectionString); + await conn.OpenAsync(ct).ConfigureAwait(false); + + using var cmd = new SqlCommand(AttributesSql, conn) { CommandTimeout = options.CommandTimeoutSeconds }; + using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false); + + while (await reader.ReadAsync(ct).ConfigureAwait(false)) + { + rows.Add(new GalaxyAttributeRow + { + GobjectId = Convert.ToInt32(reader.GetValue(0)), + TagName = reader.GetString(1), + AttributeName = reader.GetString(2), + FullTagReference = reader.GetString(3), + MxDataType = Convert.ToInt32(reader.GetValue(4)), + DataTypeName = reader.IsDBNull(5) ? null : reader.GetString(5), + IsArray = Convert.ToInt32(reader.GetValue(6)) == 1, + ArrayDimension = reader.IsDBNull(7) ? (int?)null : Convert.ToInt32(reader.GetValue(7)), + MxAttributeCategory = Convert.ToInt32(reader.GetValue(8)), + SecurityClassification = Convert.ToInt32(reader.GetValue(9)), + IsHistorized = Convert.ToInt32(reader.GetValue(10)) == 1, + IsAlarm = Convert.ToInt32(reader.GetValue(11)) == 1, + }); + } + return rows; + } + + private const string HierarchySql = @" +;WITH template_chain AS ( + SELECT g.gobject_id AS instance_gobject_id, t.gobject_id AS template_gobject_id, + t.tag_name AS template_tag_name, t.derived_from_gobject_id, 0 AS depth + FROM gobject g + INNER JOIN gobject t ON t.gobject_id = g.derived_from_gobject_id + WHERE g.is_template = 0 AND g.deployed_package_id <> 0 AND g.derived_from_gobject_id <> 0 + UNION ALL + SELECT tc.instance_gobject_id, t.gobject_id, t.tag_name, t.derived_from_gobject_id, tc.depth + 1 + FROM template_chain tc + INNER JOIN gobject t ON t.gobject_id = tc.derived_from_gobject_id + WHERE tc.derived_from_gobject_id <> 0 AND tc.depth < 10 +) +SELECT DISTINCT + g.gobject_id, + g.tag_name, + g.contained_name, + CASE WHEN g.contained_name IS NULL OR g.contained_name = '' + THEN g.tag_name + ELSE g.contained_name + END AS browse_name, + CASE WHEN g.contained_by_gobject_id = 0 + THEN g.area_gobject_id + ELSE g.contained_by_gobject_id + END AS parent_gobject_id, + CASE WHEN td.category_id = 13 + THEN 1 + ELSE 0 + END AS is_area, + td.category_id AS category_id, + g.hosted_by_gobject_id AS hosted_by_gobject_id, + ISNULL( + STUFF(( + SELECT '|' + tc.template_tag_name + FROM template_chain tc + WHERE tc.instance_gobject_id = g.gobject_id + ORDER BY tc.depth + FOR XML PATH('') + ), 1, 1, ''), + '' + ) AS template_chain +FROM gobject g +INNER JOIN template_definition td + ON g.template_definition_id = td.template_definition_id +WHERE td.category_id IN (1, 3, 4, 10, 11, 13, 17, 24, 26) + AND g.is_template = 0 + AND g.deployed_package_id <> 0 +ORDER BY parent_gobject_id, g.tag_name"; + + private const string AttributesSql = @" +;WITH deployed_package_chain AS ( + SELECT g.gobject_id, p.package_id, p.derived_from_package_id, 0 AS depth + FROM gobject g + INNER JOIN package p ON p.package_id = g.deployed_package_id + WHERE g.is_template = 0 AND g.deployed_package_id <> 0 + UNION ALL + SELECT dpc.gobject_id, p.package_id, p.derived_from_package_id, dpc.depth + 1 + FROM deployed_package_chain dpc + INNER JOIN package p ON p.package_id = dpc.derived_from_package_id + WHERE dpc.derived_from_package_id <> 0 AND dpc.depth < 10 +) +SELECT gobject_id, tag_name, attribute_name, full_tag_reference, + mx_data_type, data_type_name, is_array, array_dimension, + mx_attribute_category, security_classification, is_historized, is_alarm +FROM ( + SELECT + dpc.gobject_id, + g.tag_name, + da.attribute_name, + g.tag_name + '.' + da.attribute_name + + CASE WHEN da.is_array = 1 THEN '[]' ELSE '' END + AS full_tag_reference, + da.mx_data_type, + dt.description AS data_type_name, + da.is_array, + CASE WHEN da.is_array = 1 + THEN CONVERT(int, CONVERT(varbinary(2), + SUBSTRING(da.mx_value, 15, 2) + SUBSTRING(da.mx_value, 13, 2), 2)) + ELSE NULL + END AS array_dimension, + da.mx_attribute_category, + da.security_classification, + CASE WHEN EXISTS ( + SELECT 1 FROM deployed_package_chain dpc2 + INNER JOIN primitive_instance pi ON pi.package_id = dpc2.package_id AND pi.primitive_name = da.attribute_name + INNER JOIN primitive_definition pd ON pd.primitive_definition_id = pi.primitive_definition_id AND pd.primitive_name = 'HistoryExtension' + WHERE dpc2.gobject_id = dpc.gobject_id + ) THEN 1 ELSE 0 END AS is_historized, + CASE WHEN EXISTS ( + SELECT 1 FROM deployed_package_chain dpc2 + INNER JOIN primitive_instance pi ON pi.package_id = dpc2.package_id AND pi.primitive_name = da.attribute_name + INNER JOIN primitive_definition pd ON pd.primitive_definition_id = pi.primitive_definition_id AND pd.primitive_name = 'AlarmExtension' + WHERE dpc2.gobject_id = dpc.gobject_id + ) THEN 1 ELSE 0 END AS is_alarm, + ROW_NUMBER() OVER ( + PARTITION BY dpc.gobject_id, da.attribute_name + ORDER BY dpc.depth + ) AS rn + FROM deployed_package_chain dpc + INNER JOIN dynamic_attribute da + ON da.package_id = dpc.package_id + INNER JOIN gobject g + ON g.gobject_id = dpc.gobject_id + INNER JOIN template_definition td + ON td.template_definition_id = g.template_definition_id + LEFT JOIN data_type dt + ON dt.mx_data_type = da.mx_data_type + WHERE td.category_id IN (1, 3, 4, 10, 11, 13, 17, 24, 26) + AND da.attribute_name NOT LIKE '[_]%' + AND da.attribute_name NOT LIKE '%.Description' + AND da.mx_attribute_category IN (2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 24) +) ranked +WHERE rn = 1 +ORDER BY tag_name, attribute_name"; +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Galaxy/GalaxyRepositoryOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Galaxy/GalaxyRepositoryOptions.cs new file mode 100644 index 0000000..b72a759 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Galaxy/GalaxyRepositoryOptions.cs @@ -0,0 +1,13 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy; + +/// +/// Connection settings for the Galaxy ZB repository database. Set from the +/// DriverConfig JSON section Database per plan.md §"Galaxy DriverConfig". +/// +public sealed class GalaxyRepositoryOptions +{ + public string ConnectionString { get; init; } = + "Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;"; + + public int CommandTimeoutSeconds { get; init; } = 60; +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.csproj b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.csproj index 565eb6c..0c713f8 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.csproj +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.csproj @@ -20,6 +20,7 @@ + diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/GalaxyRepositoryLiveSmokeTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/GalaxyRepositoryLiveSmokeTests.cs new file mode 100644 index 0000000..fe5f741 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/GalaxyRepositoryLiveSmokeTests.cs @@ -0,0 +1,100 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend; +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.Tests +{ + /// + /// Live smoke against the Galaxy ZB repository. Skipped when ZB is unreachable so + /// CI / dev boxes without an AVEVA install still pass. Exercises the ported + /// + against the same + /// SQL the v1 Host uses, proving the lift is byte-for-byte equivalent at the + /// DiscoverHierarchyResponse shape. + /// + [Trait("Category", "LiveGalaxy")] + public sealed class GalaxyRepositoryLiveSmokeTests + { + private static GalaxyRepositoryOptions DevZbOptions() => new() + { + ConnectionString = + "Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;Connect Timeout=2;", + CommandTimeoutSeconds = 10, + }; + + private static async Task ZbReachableAsync() + { + try + { + var repo = new GalaxyRepository(DevZbOptions()); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3)); + return await repo.TestConnectionAsync(cts.Token); + } + catch { return false; } + } + + [Fact] + public async Task TestConnection_returns_true_against_live_ZB() + { + if (!await ZbReachableAsync()) return; + + var repo = new GalaxyRepository(DevZbOptions()); + (await repo.TestConnectionAsync()).ShouldBeTrue(); + } + + [Fact] + public async Task GetHierarchy_returns_at_least_one_deployed_gobject() + { + if (!await ZbReachableAsync()) return; + + var repo = new GalaxyRepository(DevZbOptions()); + var rows = await repo.GetHierarchyAsync(); + + rows.Count.ShouldBeGreaterThan(0, + "the dev Galaxy has at least the WinPlatform + AppEngine deployed"); + rows.ShouldAllBe(r => !string.IsNullOrEmpty(r.TagName)); + } + + [Fact] + public async Task GetAttributes_returns_attributes_for_deployed_objects() + { + if (!await ZbReachableAsync()) return; + + var repo = new GalaxyRepository(DevZbOptions()); + var attrs = await repo.GetAttributesAsync(); + + attrs.Count.ShouldBeGreaterThan(0); + attrs.ShouldAllBe(a => !string.IsNullOrEmpty(a.FullTagReference) && a.FullTagReference.Contains(".")); + } + + [Fact] + public async Task GetLastDeployTime_returns_a_value() + { + if (!await ZbReachableAsync()) return; + + var repo = new GalaxyRepository(DevZbOptions()); + var ts = await repo.GetLastDeployTimeAsync(); + ts.ShouldNotBeNull(); + } + + [Fact] + public async Task DbBackedBackend_DiscoverAsync_returns_objects_with_attributes_and_categories() + { + if (!await ZbReachableAsync()) return; + + var backend = new DbBackedGalaxyBackend(new GalaxyRepository(DevZbOptions())); + var resp = await backend.DiscoverAsync(new DiscoverHierarchyRequest { SessionId = 1 }, CancellationToken.None); + + resp.Success.ShouldBeTrue(resp.Error); + resp.Objects.Length.ShouldBeGreaterThan(0); + + var firstWithAttrs = System.Linq.Enumerable.FirstOrDefault(resp.Objects, o => o.Attributes.Length > 0); + firstWithAttrs.ShouldNotBeNull("at least one gobject in the dev Galaxy carries dynamic attributes"); + firstWithAttrs!.TemplateCategory.ShouldNotBeNullOrEmpty(); + } + } +}