ExperionCrawler First Commit
This commit is contained in:
392
src/Infrastructure/OpcUa/ExperionOpcClient.cs
Normal file
392
src/Infrastructure/OpcUa/ExperionOpcClient.cs
Normal file
@@ -0,0 +1,392 @@
|
||||
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 ISession = Opc.Ua.Client.ISession;
|
||||
using StatusCodes = Opc.Ua.StatusCodes;
|
||||
|
||||
namespace ExperionCrawler.Infrastructure.OpcUa;
|
||||
|
||||
/// <summary>
|
||||
/// OPC UA 서버 접속·읽기·탐색.
|
||||
/// ApplicationConfiguration 은 원본 Program.cs 의 SecurityConfiguration 구조를 그대로 준수.
|
||||
/// </summary>
|
||||
public class ExperionOpcClient : IExperionOpcClient
|
||||
{
|
||||
private readonly ILogger<ExperionOpcClient> _logger;
|
||||
|
||||
public ExperionOpcClient(ILogger<ExperionOpcClient> logger) => _logger = logger;
|
||||
|
||||
// ── 공통 설정 빌더 ────────────────────────────────────────────────────────
|
||||
private static async Task<ApplicationConfiguration> BuildConfigAsync(ExperionServerConfig cfg)
|
||||
{
|
||||
var clientCert = ExperionCertificateService.TryLoadCertificate(cfg.ClientHostName);
|
||||
|
||||
var config = new ApplicationConfiguration
|
||||
{
|
||||
ApplicationName = "ExperionCrawlerClient",
|
||||
ApplicationType = ApplicationType.Client,
|
||||
ApplicationUri = cfg.ApplicationUri,
|
||||
SecurityConfiguration = new SecurityConfiguration
|
||||
{
|
||||
// 원본: ApplicationCertificate = new CertificateIdentifier { Certificate = clientCert }
|
||||
ApplicationCertificate = clientCert != null
|
||||
? new CertificateIdentifier { Certificate = clientCert }
|
||||
: new CertificateIdentifier(),
|
||||
|
||||
// 원본 주석: "⚠️ 에러 발생했던 지점: 아래 3개 경로가 모두 명시되어야 함"
|
||||
TrustedPeerCertificates = new CertificateTrustList
|
||||
{
|
||||
StoreType = "Directory",
|
||||
StorePath = Path.GetFullPath("pki/trusted")
|
||||
},
|
||||
TrustedIssuerCertificates = new CertificateTrustList
|
||||
{
|
||||
StoreType = "Directory",
|
||||
StorePath = Path.GetFullPath("pki/issuers")
|
||||
},
|
||||
RejectedCertificateStore = new CertificateTrustList
|
||||
{
|
||||
StoreType = "Directory",
|
||||
StorePath = Path.GetFullPath("pki/rejected")
|
||||
},
|
||||
AutoAcceptUntrustedCertificates = true,
|
||||
AddAppCertToTrustedStore = true
|
||||
},
|
||||
TransportQuotas = new TransportQuotas { OperationTimeout = 15_000 },
|
||||
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 }
|
||||
};
|
||||
|
||||
await config.ValidateAsync(ApplicationType.Client);
|
||||
|
||||
// 원본: config.CertificateValidator.CertificateValidation += (v, e) => { if (...) e.Accept = true; };
|
||||
config.CertificateValidator.CertificateValidation += (_, e) =>
|
||||
{
|
||||
if (e.Error.StatusCode != StatusCodes.Good) e.Accept = true;
|
||||
};
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
// ── 엔드포인트 선택 (원본 로직 동일) ────────────────────────────────────
|
||||
private static async Task<ConfiguredEndpoint> SelectEndpointAsync(
|
||||
ApplicationConfiguration appConfig, string endpointUrl)
|
||||
{
|
||||
var endpointConfig = EndpointConfiguration.Create(appConfig);
|
||||
using var discovery = await DiscoveryClient.CreateAsync(
|
||||
appConfig, new Uri(endpointUrl), DiagnosticsMasks.All, CancellationToken.None);
|
||||
|
||||
var endpoints = await discovery.GetEndpointsAsync(null);
|
||||
|
||||
// 원본: OrderByDescending SecurityLevel, prefer Basic256Sha256
|
||||
var selected = endpoints
|
||||
.OrderByDescending(e => e.SecurityLevel)
|
||||
.FirstOrDefault(e => e.SecurityPolicyUri.Contains("Basic256Sha256"))
|
||||
?? endpoints[0];
|
||||
|
||||
return new ConfiguredEndpoint(null, selected, endpointConfig);
|
||||
}
|
||||
|
||||
// ── 세션 생성 ─────────────────────────────────────────────────────────────
|
||||
private static async Task<ISession> CreateSessionAsync(
|
||||
ApplicationConfiguration appConfig,
|
||||
ConfiguredEndpoint endpoint,
|
||||
ExperionServerConfig cfg,
|
||||
string sessionName)
|
||||
{
|
||||
// 원본: new UserIdentity(userName, Encoding.UTF8.GetBytes(password))
|
||||
var identity = new UserIdentity(cfg.UserName, Encoding.UTF8.GetBytes(cfg.Password));
|
||||
|
||||
return await Session.Create(appConfig, endpoint, false, sessionName, 60_000, identity, null);
|
||||
}
|
||||
|
||||
// ── 접속 테스트 ───────────────────────────────────────────────────────────
|
||||
public async Task<ExperionConnectResult> TestConnectionAsync(ExperionServerConfig cfg)
|
||||
{
|
||||
ISession? session = null;
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("[ExperionOpc] TestConnection → {Url}", cfg.EndpointUrl);
|
||||
var appConfig = await BuildConfigAsync(cfg);
|
||||
var endpoint = await SelectEndpointAsync(appConfig, cfg.EndpointUrl);
|
||||
|
||||
_logger.LogInformation("정책 선택됨: {Policy}", endpoint.Description.SecurityPolicyUri);
|
||||
|
||||
session = await CreateSessionAsync(appConfig, endpoint, cfg, "ExperionCrawlerSession");
|
||||
var sessionId = session.SessionId?.ToString() ?? "N/A";
|
||||
|
||||
return new ExperionConnectResult(true, "연결 성공", sessionId, endpoint.Description.SecurityPolicyUri);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[ExperionOpc] 연결 실패");
|
||||
return new ExperionConnectResult(false, ex.Message);
|
||||
}
|
||||
finally { await DisposeSessionAsync(session); }
|
||||
}
|
||||
|
||||
// ── 단일 태그 읽기 ────────────────────────────────────────────────────────
|
||||
public async Task<ExperionReadResult> ReadTagAsync(ExperionServerConfig cfg, string nodeId)
|
||||
{
|
||||
var results = await ReadTagsAsync(cfg, new[] { nodeId });
|
||||
return results.FirstOrDefault()
|
||||
?? new ExperionReadResult(false, nodeId, null, "Error", "결과 없음");
|
||||
}
|
||||
|
||||
// ── 복수 태그 읽기 ────────────────────────────────────────────────────────
|
||||
public async Task<IEnumerable<ExperionReadResult>> ReadTagsAsync(
|
||||
ExperionServerConfig cfg, IEnumerable<string> nodeIds)
|
||||
{
|
||||
ISession? session = null;
|
||||
var nodeList = nodeIds.ToList();
|
||||
var results = new List<ExperionReadResult>();
|
||||
|
||||
try
|
||||
{
|
||||
var appConfig = await BuildConfigAsync(cfg);
|
||||
var endpoint = await SelectEndpointAsync(appConfig, cfg.EndpointUrl);
|
||||
session = await CreateSessionAsync(appConfig, endpoint, cfg, "ExperionCrawlerReadSession");
|
||||
|
||||
foreach (var nodeId in nodeList)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 원본: session.ReadValueAsync(nodeId)
|
||||
var nodeToRead = new ReadValueId
|
||||
{
|
||||
NodeId = new NodeId(nodeId),
|
||||
AttributeId = Attributes.Value
|
||||
};
|
||||
var readResponse = await session.ReadAsync(null, 0, TimestampsToReturn.Both, new ReadValueIdCollection { nodeToRead }, CancellationToken.None);
|
||||
var dv = readResponse.Results[0];
|
||||
|
||||
var statusStr = StatusCode.IsGood(dv.StatusCode)
|
||||
? "Good"
|
||||
: $"0x{(uint)dv.StatusCode:X8}";
|
||||
|
||||
results.Add(new ExperionReadResult(
|
||||
true, nodeId, dv.Value, statusStr, null, dv.SourceTimestamp));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
results.Add(new ExperionReadResult(false, nodeId, null, "Error", ex.Message));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[ExperionOpc] ReadTags 실패");
|
||||
foreach (var n in nodeList.Where(n => results.All(r => r.NodeId != n)))
|
||||
results.Add(new ExperionReadResult(false, n, null, "Error", ex.Message));
|
||||
}
|
||||
finally { await DisposeSessionAsync(session); }
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ── 전체 노드맵 탐색 (재귀) ───────────────────────────────────────────────
|
||||
public async Task<ExperionNodeMapResult> BrowseAllNodesAsync(
|
||||
ExperionServerConfig cfg, int maxDepth = 10, CancellationToken ct = default)
|
||||
{
|
||||
ISession? session = null;
|
||||
var results = new List<ExperionNodeMapEntry>();
|
||||
var visited = new HashSet<string>();
|
||||
|
||||
try
|
||||
{
|
||||
var appConfig = await BuildConfigAsync(cfg);
|
||||
var endpoint = await SelectEndpointAsync(appConfig, cfg.EndpointUrl);
|
||||
session = await CreateSessionAsync(appConfig, endpoint, cfg, "ExperionCrawlerNodeMapSession");
|
||||
|
||||
_logger.LogInformation("[ExperionOpc] 전체 노드맵 탐색 시작 (maxDepth={MaxDepth})", maxDepth);
|
||||
|
||||
await BrowseRecursiveAsync(session, ObjectIds.ObjectsFolder, 0, maxDepth, results, visited, ct);
|
||||
|
||||
_logger.LogInformation("[ExperionOpc] 전체 노드맵 탐색 완료: {Count}개", results.Count);
|
||||
return new ExperionNodeMapResult(true, results, results.Count);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[ExperionOpc] BrowseAllNodes 실패");
|
||||
return new ExperionNodeMapResult(false, results, results.Count, ex.Message);
|
||||
}
|
||||
finally { await DisposeSessionAsync(session); }
|
||||
}
|
||||
|
||||
private async Task BrowseRecursiveAsync(
|
||||
ISession session,
|
||||
NodeId startNode,
|
||||
int currentLevel,
|
||||
int maxLevel,
|
||||
List<ExperionNodeMapEntry> results,
|
||||
HashSet<string> visited,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (currentLevel > maxLevel || ct.IsCancellationRequested) return;
|
||||
|
||||
ReferenceDescriptionCollection refs;
|
||||
try
|
||||
{
|
||||
var browser = new Browser(session)
|
||||
{
|
||||
BrowseDirection = BrowseDirection.Forward,
|
||||
ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
|
||||
IncludeSubtypes = true,
|
||||
NodeClassMask = (int)(NodeClass.Object | NodeClass.Variable),
|
||||
ResultMask = (uint)BrowseResultMask.All
|
||||
};
|
||||
refs = await browser.BrowseAsync(startNode);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning("[ExperionOpc] Browse 실패 NodeId={NodeId}: {Msg}", startNode, ex.Message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (refs.Count == 0) return;
|
||||
|
||||
// Variable 노드들 DataType 배치 읽기
|
||||
var variableIds = refs
|
||||
.Where(r => r.NodeClass == NodeClass.Variable)
|
||||
.Select(r => (NodeId)r.NodeId)
|
||||
.ToList();
|
||||
|
||||
var dataTypeMap = await BatchReadDataTypesAsync(session, variableIds);
|
||||
|
||||
foreach (var r in refs)
|
||||
{
|
||||
if (ct.IsCancellationRequested) return;
|
||||
|
||||
var nodeIdStr = r.NodeId.ToString();
|
||||
if (!visited.Add(nodeIdStr)) continue; // 순환 참조 방지
|
||||
|
||||
var dataType = r.NodeClass == NodeClass.Variable
|
||||
? (dataTypeMap.TryGetValue(nodeIdStr, out var dt) ? dt : "Unknown")
|
||||
: "N/A";
|
||||
|
||||
results.Add(new ExperionNodeMapEntry(
|
||||
currentLevel,
|
||||
r.NodeClass.ToString(),
|
||||
r.DisplayName.Text,
|
||||
nodeIdStr,
|
||||
dataType));
|
||||
|
||||
if (results.Count % 1000 == 0)
|
||||
_logger.LogInformation("[ExperionOpc] 탐색 중... {Count}개 수집", results.Count);
|
||||
|
||||
if (r.NodeClass == NodeClass.Object)
|
||||
await BrowseRecursiveAsync(session, (NodeId)r.NodeId, currentLevel + 1, maxLevel, results, visited, ct);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Dictionary<string, string>> BatchReadDataTypesAsync(
|
||||
ISession session, List<NodeId> variableNodeIds)
|
||||
{
|
||||
var result = new Dictionary<string, string>(variableNodeIds.Count);
|
||||
if (variableNodeIds.Count == 0) return result;
|
||||
|
||||
const int chunkSize = 500;
|
||||
for (int i = 0; i < variableNodeIds.Count; i += chunkSize)
|
||||
{
|
||||
var chunk = variableNodeIds.Skip(i).Take(chunkSize).ToList();
|
||||
try
|
||||
{
|
||||
var readValues = new ReadValueIdCollection(
|
||||
chunk.Select(n => new ReadValueId { NodeId = n, AttributeId = Attributes.DataType }));
|
||||
|
||||
var response = await session.ReadAsync(
|
||||
null, 0, TimestampsToReturn.Neither, readValues, CancellationToken.None);
|
||||
|
||||
for (int j = 0; j < chunk.Count; j++)
|
||||
result[chunk[j].ToString()] = ResolveDataTypeName(response.Results[j]);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning("[ExperionOpc] DataType 배치 읽기 실패: {Msg}", ex.Message);
|
||||
foreach (var n in chunk)
|
||||
result[n.ToString()] = "Unknown";
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static readonly Dictionary<uint, string> _builtinTypes = new()
|
||||
{
|
||||
{ 1, "Boolean" }, { 2, "SByte" }, { 3, "Byte" }, { 4, "Int16" },
|
||||
{ 5, "UInt16" }, { 6, "Int32" }, { 7, "UInt32" }, { 8, "Int64" },
|
||||
{ 9, "UInt64" }, { 10, "Float" }, { 11, "Double" }, { 12, "String" },
|
||||
{ 13, "DateTime" }, { 15, "ByteString"}, { 17, "StatusCode" }
|
||||
};
|
||||
|
||||
private static string ResolveDataTypeName(DataValue dv)
|
||||
{
|
||||
if (!StatusCode.IsGood(dv.StatusCode)) return "Unknown";
|
||||
|
||||
NodeId? nodeId = dv.Value switch
|
||||
{
|
||||
NodeId nid => nid,
|
||||
ExpandedNodeId eid => (NodeId)eid,
|
||||
_ => null
|
||||
};
|
||||
if (nodeId is null) return "Unknown";
|
||||
|
||||
if (nodeId.NamespaceIndex == 0 && nodeId.IdType == IdType.Numeric)
|
||||
{
|
||||
var id = Convert.ToUInt32(nodeId.Identifier);
|
||||
if (_builtinTypes.TryGetValue(id, out var name)) return name;
|
||||
}
|
||||
return nodeId.ToString() ?? "Unknown";
|
||||
}
|
||||
|
||||
// ── 노드 탐색 ─────────────────────────────────────────────────────────────
|
||||
public async Task<ExperionBrowseResult> BrowseNodesAsync(ExperionServerConfig cfg, string? startNodeId = null)
|
||||
{
|
||||
ISession? session = null;
|
||||
try
|
||||
{
|
||||
var appConfig = await BuildConfigAsync(cfg);
|
||||
var endpoint = await SelectEndpointAsync(appConfig, cfg.EndpointUrl);
|
||||
session = await CreateSessionAsync(appConfig, endpoint, cfg, "ExperionCrawlerBrowseSession");
|
||||
|
||||
var startNode = startNodeId != null
|
||||
? new NodeId(startNodeId)
|
||||
: ObjectIds.ObjectsFolder;
|
||||
|
||||
var browser = new Browser(session)
|
||||
{
|
||||
BrowseDirection = BrowseDirection.Forward,
|
||||
ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
|
||||
IncludeSubtypes = true,
|
||||
NodeClassMask = (int)(NodeClass.Object | NodeClass.Variable),
|
||||
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();
|
||||
|
||||
return new ExperionBrowseResult(true, nodes);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[ExperionOpc] BrowseNodes 실패");
|
||||
return new ExperionBrowseResult(false, Enumerable.Empty<ExperionNodeInfo>(), ex.Message);
|
||||
}
|
||||
finally { await DisposeSessionAsync(session); }
|
||||
}
|
||||
|
||||
// ── 세션 정리 ─────────────────────────────────────────────────────────────
|
||||
private static async Task DisposeSessionAsync(ISession? session)
|
||||
{
|
||||
if (session == null) return;
|
||||
try { await session.CloseAsync(); } catch { /* ignore */ }
|
||||
session.Dispose();
|
||||
}
|
||||
}
|
||||
50
src/Infrastructure/OpcUa/ExperionStatusCodeService.cs
Normal file
50
src/Infrastructure/OpcUa/ExperionStatusCodeService.cs
Normal file
@@ -0,0 +1,50 @@
|
||||
using System.Text.Json;
|
||||
using ExperionCrawler.Core.Application.Interfaces;
|
||||
using ExperionCrawler.Core.Domain.Entities;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ExperionCrawler.Infrastructure.OpcUa;
|
||||
|
||||
/// <summary>
|
||||
/// 원본 Program.cs 의 LoadStatusCodes() / _statusCodeMap 을 서비스로 분리.
|
||||
/// statuscode.json 에서 로드하며 Hex 키(대소문자 무시)로 조회한다.
|
||||
/// </summary>
|
||||
public class ExperionStatusCodeService : IExperionStatusCodeService
|
||||
{
|
||||
private readonly Dictionary<string, ExperionStatusCodeInfo> _map
|
||||
= new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ILogger<ExperionStatusCodeService> _logger;
|
||||
|
||||
public int LoadedCount => _map.Count;
|
||||
|
||||
public ExperionStatusCodeService(ILogger<ExperionStatusCodeService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
Load();
|
||||
}
|
||||
|
||||
private void Load()
|
||||
{
|
||||
var path = Path.Combine(Directory.GetCurrentDirectory(), "statuscode.json");
|
||||
if (!File.Exists(path)) return;
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(path);
|
||||
var opts = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
||||
var list = JsonSerializer.Deserialize<List<ExperionStatusCodeInfo>>(json, opts);
|
||||
if (list == null) return;
|
||||
foreach (var item in list) _map[item.Hex] = item;
|
||||
_logger.LogInformation("✅ {Count}개의 에러 코드 정의 로드 완료.", _map.Count);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "statuscode.json 로드 실패");
|
||||
}
|
||||
}
|
||||
|
||||
public ExperionStatusCodeInfo? GetByHex(string hexCode)
|
||||
=> _map.TryGetValue(hexCode, out var info) ? info : null;
|
||||
|
||||
public ExperionStatusCodeInfo? GetByUint(uint statusCode)
|
||||
=> GetByHex($"0x{statusCode:X8}");
|
||||
}
|
||||
Reference in New Issue
Block a user