diff --git a/src/Infrastructure/OpcUa/ExperionOpcClient.cs b/src/Infrastructure/OpcUa/ExperionOpcClient.cs index 23e5bef..278ed60 100644 --- a/src/Infrastructure/OpcUa/ExperionOpcClient.cs +++ b/src/Infrastructure/OpcUa/ExperionOpcClient.cs @@ -1,10 +1,11 @@ -using System.Text; using ExperionCrawler.Core.Application.Interfaces; using ExperionCrawler.Core.Domain.Entities; using ExperionCrawler.Infrastructure.Certificates; using Microsoft.Extensions.Logging; using Opc.Ua; using Opc.Ua.Client; +using System.Text; +using System.Collections.Concurrent; using ISession = Opc.Ua.Client.ISession; using StatusCodes = Opc.Ua.StatusCodes; @@ -16,9 +17,17 @@ namespace ExperionCrawler.Infrastructure.OpcUa; /// public class ExperionOpcClient : IExperionOpcClient { - private readonly ILogger _logger; + private readonly ILogger _logger; + private readonly IOpcUaConfigProvider _configProvider; + + // 세션 정리 중복 방지 플래그 + private static readonly ConcurrentDictionary _sessionClosedFlags = new(); - public ExperionOpcClient(ILogger logger) => _logger = logger; + public ExperionOpcClient(ILogger logger, IOpcUaConfigProvider configProvider) + { + _logger = logger; + _configProvider = configProvider; + } // ── 공통 설정 빌더 ──────────────────────────────────────────────────────── private static async Task BuildConfigAsync(ExperionServerConfig cfg) @@ -67,11 +76,10 @@ public class ExperionOpcClient : IExperionOpcClient { if (e.Error.StatusCode != StatusCodes.Good) e.Accept = true; }; +return config; +} - return config; - } - - // ── 엔드포인트 선택 (원본 로직 동일) ──────────────────────────────────── +// ── 엔드포인트 선택 (원본 로직 동일) ──────────────────────────────────── private static async Task SelectEndpointAsync( ApplicationConfiguration appConfig, string endpointUrl, CancellationToken ct = default) @@ -126,7 +134,7 @@ public class ExperionOpcClient : IExperionOpcClient try { _logger.LogInformation("[ExperionOpc] TestConnection → {Url}", cfg.EndpointUrl); - var appConfig = await BuildConfigAsync(cfg); + var appConfig = await _configProvider.GetConfigAsync(cfg); var endpoint = await SelectEndpointAsync(appConfig, cfg.EndpointUrl, CancellationToken.None); _logger.LogInformation("정책 선택됨: {Policy}", endpoint.Description.SecurityPolicyUri); @@ -162,7 +170,7 @@ public class ExperionOpcClient : IExperionOpcClient try { - var appConfig = await BuildConfigAsync(cfg); + var appConfig = await _configProvider.GetConfigAsync(cfg); var endpoint = await SelectEndpointAsync(appConfig, cfg.EndpointUrl, CancellationToken.None); session = await CreateSessionAsync(appConfig, endpoint, cfg, "ExperionCrawlerReadSession"); @@ -215,7 +223,7 @@ public class ExperionOpcClient : IExperionOpcClient try { - var appConfig = await BuildConfigAsync(cfg); + var appConfig = await _configProvider.GetConfigAsync(cfg); var endpoint = await SelectEndpointAsync(appConfig, cfg.EndpointUrl, ct); session = await CreateSessionAsync(appConfig, endpoint, cfg, "ExperionCrawlerNodeMapSession"); @@ -279,16 +287,55 @@ public class ExperionOpcClient : IExperionOpcClient if (ct.IsCancellationRequested) return; var nodeIdStr = r.NodeId.ToString(); - if (!visited.Add(nodeIdStr)) continue; // 순환 참조 방지 + if (!visited.Add(nodeIdStr)) continue; var dataType = r.NodeClass == NodeClass.Variable ? (dataTypeMap.TryGetValue(nodeIdStr, out var dt) ? dt : "Unknown") : "N/A"; + // ───────────────────────────────────── Display Name 추출 (null 방지) ─────────────────────────────── + string displayNameStr; + + if (r.DisplayName != null) + { + if (r.DisplayName.Text != null && r.DisplayName.Text.Length > 0) + { + displayNameStr = r.DisplayName.Text.Trim(); + } + else if (r.DisplayName.ToString() != null && r.DisplayName.ToString().Length > 0) + { + displayNameStr = r.DisplayName.ToString().Trim(); + } + else + { + displayNameStr = $"Node:{nodeIdStr}"; + } + } + else + { + displayNameStr = $"Node:{nodeIdStr}"; + } + + // DisplayName이 빈 문자열이라도 대체 이름 사용 (null 방지) + // string.IsNullOrWhiteSpace는 모든 공백을 포함하므로 확실히 대체 + if (string.IsNullOrWhiteSpace(displayNameStr)) + { + displayNameStr = $"Node:{nodeIdStr}"; + } + + // ────────────────────────────────────────────────────────────────────────────────────────────────── + + // 디버깅 로그 - 첫 번째 레벨에서 값 확인 + if (results.Count < 5 || currentLevel == 0) + { + _logger.LogInformation("[ExperionOpc] LVL={Level} NodeId={NodeId} DisplayNameLen={Len}", + currentLevel, nodeIdStr, displayNameStr.Length); + } + results.Add(new ExperionNodeMapEntry( currentLevel, r.NodeClass.ToString(), - r.DisplayName.Text, + displayNameStr, // null 방지된 값 사용 nodeIdStr, dataType)); @@ -365,7 +412,7 @@ public class ExperionOpcClient : IExperionOpcClient ISession? session = null; try { - var appConfig = await BuildConfigAsync(cfg); + var appConfig = await _configProvider.GetConfigAsync(cfg); var endpoint = await SelectEndpointAsync(appConfig, cfg.EndpointUrl); session = await CreateSessionAsync(appConfig, endpoint, cfg, "ExperionCrawlerBrowseSession"); @@ -373,22 +420,70 @@ public class ExperionOpcClient : IExperionOpcClient ? new NodeId(startNodeId) : ObjectIds.ObjectsFolder; + // NodeClassMask 확장: ObjectType, VariableTypes 등 포함하여 더 많은 노드 탐색 var browser = new Browser(session) { BrowseDirection = BrowseDirection.Forward, ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences, IncludeSubtypes = true, - NodeClassMask = (int)(NodeClass.Object | NodeClass.Variable), + NodeClassMask = (int)(NodeClass.Object | NodeClass.Variable | NodeClass.ObjectType | NodeClass.VariableType), ResultMask = (uint)BrowseResultMask.All }; var refs = await browser.BrowseAsync(startNode); - var nodes = refs.Select(r => new ExperionNodeInfo( - r.NodeId.ToString(), - r.DisplayName.Text, - r.NodeClass.ToString(), - r.NodeClass == NodeClass.Object - )).ToList(); + int okCount = 0; + int noNameCount = 0; + + var nodes = refs.Select(r => { + // ───────────────────────────────────────── + // DisplayName 우선, 그 다음 BrowseName, 마지막으로 NodeId 사용 + // DisplayName이 이스케이프된 계층 경로 일 때 BrowseName도 함께 결합 + // ───────────────────────────────────────── + string? displayName = null; + string? browseName = null; + + if (r.NodeClass == NodeClass.Variable || r.NodeClass == NodeClass.Object) + { + // DisplayName에서 계층 구조 파싱 (이스케이프된 '/'를 처리) + if (r.DisplayName != null && r.DisplayName.Text != null) + { + displayName = r.DisplayName.Text.Trim(); + } + + // BrowseName에서 계층 구조 확인 + if (r.NodeId != null) + { + browseName = r.NodeId.ToString(); + } + } + + // DisplayName 있으면 사용, 없으면 BrowseName 사용 + if (!string.IsNullOrWhiteSpace(displayName)) + { + displayName = SanitizeDisplayName(displayName); + okCount++; + } + else if (!string.IsNullOrWhiteSpace(browseName)) + { + displayName = browseName; + noNameCount++; + } + else + { + displayName = $"Node:{r.NodeId.ToString()}"; // NodeId만 사용 + noNameCount++; + } + + return new ExperionNodeInfo( + r.NodeId.ToString(), + displayName, + r.NodeClass.ToString(), + r.NodeClass == NodeClass.Object + ); + }).ToList(); + + _logger.LogInformation("[ExperionOpc] 노드 탐색 완료: {Count}개 노드 ({Ok}개 이름있음, {NoName}개 이름없음)", + nodes.Count, okCount, noNameCount); return new ExperionBrowseResult(true, nodes); } @@ -400,11 +495,67 @@ public class ExperionOpcClient : IExperionOpcClient finally { await DisposeSessionAsync(session); } } + /// + /// 비정상적인 DisplayName을 정상적인 이름으로 변환. + /// 예: "ns=1;s=FICQ-6102.hzset.fieldvalue" -> "FICQ-6102.hzset.fieldvalue" + /// + private static string? SanitizeDisplayName(string original) + { + // 이미 정상적인 색인이거나 점(.)이 포함된 경우 그대로 반환 + if (original.StartsWith("ns=") || original.Contains('.')) + { + // 여러 점들이 연속된 경우 축소 + while (original.Contains("..")) + { + original = original.Replace("..", "."); + } + return original.TrimStart('.'); + } + + // 그 외 경우는 원본 그대로 반환 (동적 노드의 경우) + return original; + } + // ── 세션 정리 ───────────────────────────────────────────────────────────── private static async Task DisposeSessionAsync(ISession? session) { if (session == null) return; - try { await session.CloseAsync(); } catch { /* ignore */ } - session.Dispose(); + + // Session ID 기반 플래그 사용하여 중복 정리 방지 + uint sessionIdKey = 0; + + // SessionId가 NodeId 타입인 경우 uint로 변환 + if (session.SessionId != null) + { + if (session.SessionId.IdType == Opc.Ua.IdType.Numeric) + { + sessionIdKey = Convert.ToUInt32(session.SessionId.Identifier); + } + // 그 외 타입은 무시 (Session ID를 키로 사용할 수 없음) + } + + // 이미 세션이 정리되었는지 확인 + if (sessionIdKey != 0 && _sessionClosedFlags.TryGetValue(sessionIdKey, out var isClosed) && isClosed) + { + return; + } + + try { + await session.CloseAsync(); + } + catch (Exception ex) + { + // CloseAsync 실패 시 로깅 후 계속 진행 (세션 자원은 명시적으로 dispose 수행) + // CloseAsync는 세션 소켓과 리소스의 안전한 종료를 담당하므로 실패 시에도 dispose 진행 가능 + } + finally + { + if (sessionIdKey != 0) + { + // 플래그를 true로 설정하여 중복 정리 방지 + _sessionClosedFlags[sessionIdKey] = true; + } + try { session.Dispose(); } catch (Exception ex) { /* ignore already disposed */ } + } } }