ExperionCrawler First Commit

This commit is contained in:
windpacer
2026-04-14 04:02:43 +00:00
commit 323aec34af
158 changed files with 539535 additions and 0 deletions

View 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();
}
}

View 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}");
}