삽질하다 도저히 문제 파악이 안돼서 opcUaManager로 분리 테스트 중
This commit is contained in:
158
opcUaManager/Services/CertService.cs
Normal file
158
opcUaManager/Services/CertService.cs
Normal file
@@ -0,0 +1,158 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using OpcUaManager.Models;
|
||||
|
||||
namespace OpcUaManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// OPC UA 클라이언트용 X.509 인증서를 생성하고 PFX로 저장합니다.
|
||||
/// 원본 Program.cs 의 인증서 체계(pki/ 폴더 구조, X509KeyStorageFlags)를 유지합니다.
|
||||
/// </summary>
|
||||
public class CertService
|
||||
{
|
||||
private readonly ILogger<CertService> _logger;
|
||||
private readonly string _pkiRoot;
|
||||
|
||||
public CertService(ILogger<CertService> logger, IWebHostEnvironment env)
|
||||
{
|
||||
_logger = logger;
|
||||
_pkiRoot = Path.Combine(env.ContentRootPath, "pki");
|
||||
}
|
||||
|
||||
private void EnsurePkiDirectories()
|
||||
{
|
||||
Directory.CreateDirectory(Path.Combine(_pkiRoot, "own", "certs"));
|
||||
Directory.CreateDirectory(Path.Combine(_pkiRoot, "trusted", "certs"));
|
||||
Directory.CreateDirectory(Path.Combine(_pkiRoot, "issuers", "certs"));
|
||||
Directory.CreateDirectory(Path.Combine(_pkiRoot, "rejected", "certs"));
|
||||
}
|
||||
|
||||
public Task<CertCreateResult> GenerateAsync(CertCreateRequest req)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsurePkiDirectories();
|
||||
|
||||
string appUri = $"urn:{req.ClientHostName}:{req.ApplicationName}";
|
||||
|
||||
// ── 1. RSA 키 생성 ────────────────────────────────────────
|
||||
using var rsa = RSA.Create(2048);
|
||||
|
||||
// ── 2. Distinguished Name ──────────────────────────────────
|
||||
var dn = new X500DistinguishedName(
|
||||
$"CN={req.ApplicationName}, O={req.ClientHostName}, C=KR");
|
||||
|
||||
// ── 3. Certificate Request ─────────────────────────────────
|
||||
var certReq = new CertificateRequest(
|
||||
dn, rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
|
||||
// ── 4. Extensions ──────────────────────────────────────────
|
||||
certReq.CertificateExtensions.Add(
|
||||
new X509BasicConstraintsExtension(false, false, 0, true));
|
||||
|
||||
certReq.CertificateExtensions.Add(
|
||||
new X509KeyUsageExtension(
|
||||
X509KeyUsageFlags.DigitalSignature |
|
||||
X509KeyUsageFlags.NonRepudiation |
|
||||
X509KeyUsageFlags.KeyEncipherment |
|
||||
X509KeyUsageFlags.DataEncipherment,
|
||||
critical: true));
|
||||
|
||||
certReq.CertificateExtensions.Add(
|
||||
new X509EnhancedKeyUsageExtension(
|
||||
new OidCollection
|
||||
{
|
||||
new Oid("1.3.6.1.5.5.7.3.2"), // clientAuth
|
||||
new Oid("1.3.6.1.5.5.7.3.1") // serverAuth
|
||||
}, false));
|
||||
|
||||
certReq.CertificateExtensions.Add(
|
||||
new X509SubjectKeyIdentifierExtension(certReq.PublicKey, false));
|
||||
|
||||
// ── 5. Subject Alternative Names ──────────────────────────
|
||||
var sanBuilder = new SubjectAlternativeNameBuilder();
|
||||
sanBuilder.AddUri(new Uri(appUri));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(req.ServerIp) &&
|
||||
System.Net.IPAddress.TryParse(req.ServerIp, out var ip))
|
||||
sanBuilder.AddIpAddress(ip);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(req.ServerHostName))
|
||||
sanBuilder.AddDnsName(req.ServerHostName);
|
||||
|
||||
sanBuilder.AddDnsName(req.ClientHostName);
|
||||
sanBuilder.AddDnsName("localhost");
|
||||
|
||||
certReq.CertificateExtensions.Add(sanBuilder.Build());
|
||||
|
||||
// ── 6. 자체 서명 인증서 생성 ──────────────────────────────
|
||||
var notBefore = DateTimeOffset.UtcNow.AddMinutes(-5);
|
||||
var notAfter = notBefore.AddDays(req.ValidDays);
|
||||
|
||||
// FIX: CreateSelfSigned 는 이미 RSA 키가 연결된 X509Certificate2 를 반환함
|
||||
// → CopyWithPrivateKey() 를 추가로 호출하면
|
||||
// "The certificate already has an associated private key" 에러 발생
|
||||
// → CreateSelfSigned 결과를 직접 Export(Pfx) 하면 개인키 포함됨
|
||||
using var certWithKey = certReq.CreateSelfSigned(notBefore, notAfter);
|
||||
|
||||
// ── 7. PFX 내보내기 ───────────────────────────────────────
|
||||
string pfxPassword = string.IsNullOrEmpty(req.PfxPassword) ? "" : req.PfxPassword;
|
||||
|
||||
// .NET 9 이상: X509CertificateLoader 권장이지만 net8 에서는 Export 사용
|
||||
byte[] pfxBytes = certWithKey.Export(X509ContentType.Pfx, pfxPassword);
|
||||
|
||||
string pfxFileName = $"{req.ApplicationName}.pfx";
|
||||
string pfxPath = Path.Combine(_pkiRoot, "own", "certs", pfxFileName);
|
||||
File.WriteAllBytes(pfxPath, pfxBytes);
|
||||
|
||||
// ── 8. Trusted 폴더에 DER(공개키) 복사 ───────────────────
|
||||
byte[] derBytes = certWithKey.Export(X509ContentType.Cert);
|
||||
string derPath = Path.Combine(_pkiRoot, "trusted", "certs",
|
||||
$"{req.ApplicationName}[{certWithKey.Thumbprint}].der");
|
||||
File.WriteAllBytes(derPath, derBytes);
|
||||
|
||||
_logger.LogInformation(
|
||||
"인증서 생성 완료 → {PfxPath} | Thumbprint: {Thumbprint}",
|
||||
pfxPath, certWithKey.Thumbprint);
|
||||
|
||||
return Task.FromResult(new CertCreateResult
|
||||
{
|
||||
Success = true,
|
||||
Message = $"인증서 생성 완료: {pfxFileName}",
|
||||
PfxPath = pfxPath,
|
||||
ApplicationUri = appUri,
|
||||
Thumbprint = certWithKey.Thumbprint ?? string.Empty,
|
||||
SerialNumber = certWithKey.SerialNumber ?? string.Empty,
|
||||
NotBefore = notBefore.ToString("yyyy-MM-dd HH:mm:ss UTC"),
|
||||
NotAfter = notAfter.ToString("yyyy-MM-dd HH:mm:ss UTC"),
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "인증서 생성 실패");
|
||||
return Task.FromResult(new CertCreateResult
|
||||
{
|
||||
Success = false,
|
||||
Message = $"인증서 생성 실패: {ex.Message}"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 기존 PFX를 로드합니다 (원본 코드와 동일한 플래그).
|
||||
/// </summary>
|
||||
public X509Certificate2 LoadPfx(string pfxPath, string pfxPassword)
|
||||
{
|
||||
return new X509Certificate2(
|
||||
pfxPath,
|
||||
pfxPassword,
|
||||
X509KeyStorageFlags.Exportable | X509KeyStorageFlags.MachineKeySet);
|
||||
}
|
||||
|
||||
public IEnumerable<string> ListCertificates()
|
||||
{
|
||||
string dir = Path.Combine(_pkiRoot, "own", "certs");
|
||||
if (!Directory.Exists(dir)) return [];
|
||||
return Directory.GetFiles(dir, "*.pfx").Select(Path.GetFileName)!;
|
||||
}
|
||||
}
|
||||
223
opcUaManager/Services/DatabaseService.cs
Normal file
223
opcUaManager/Services/DatabaseService.cs
Normal file
@@ -0,0 +1,223 @@
|
||||
using Npgsql;
|
||||
using OpcUaManager.Models;
|
||||
|
||||
namespace OpcUaManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL opc_history 테이블에 대한 읽기/쓰기를 담당합니다.
|
||||
/// 원본 SaveToDatabase 함수의 INSERT 패턴을 그대로 유지하며,
|
||||
/// OPC 태그 읽기(OpcSessionService)와 DB 저장을 조율합니다.
|
||||
/// </summary>
|
||||
public class DatabaseService
|
||||
{
|
||||
private readonly ILogger<DatabaseService> _logger;
|
||||
private readonly OpcSessionService _sessionSvc;
|
||||
|
||||
public DatabaseService(ILogger<DatabaseService> logger, OpcSessionService sessionSvc)
|
||||
{
|
||||
_logger = logger;
|
||||
_sessionSvc = sessionSvc;
|
||||
}
|
||||
|
||||
// ── 연결 문자열 빌더 ──────────────────────────────────────────────
|
||||
|
||||
private static string BuildConnString(DbWriteRequest req)
|
||||
=> $"Host={req.DbHost};Username={req.DbUser};Password={req.DbPassword};Database={req.DbName}";
|
||||
|
||||
// ── DB/테이블 초기화 (최초 한 번) ────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// opc_history 테이블이 없으면 자동 생성합니다.
|
||||
/// 원본 코드에는 없었지만, 프로젝트화 시 처음 실행 환경을 위해 추가합니다.
|
||||
/// </summary>
|
||||
public async Task<(bool Ok, string Msg)> EnsureTableAsync(DbWriteRequest req)
|
||||
{
|
||||
const string ddl = """
|
||||
CREATE TABLE IF NOT EXISTS opc_history (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tag_name TEXT NOT NULL,
|
||||
tag_value DOUBLE PRECISION NOT NULL,
|
||||
status_code TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
""";
|
||||
try
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(BuildConnString(req));
|
||||
await conn.OpenAsync();
|
||||
await using var cmd = new NpgsqlCommand(ddl, conn);
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
return (true, "테이블 확인/생성 완료");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "테이블 초기화 실패");
|
||||
return (false, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── OPC 읽기 + DB 저장 루프 ──────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// OPC 노드를 req.Count 회 읽고 각 결과를 DB에 저장합니다.
|
||||
/// 원본 for 루프 + SaveToDatabase 패턴을 그대로 유지합니다.
|
||||
/// </summary>
|
||||
public async Task<DbWriteResult> WriteLoopAsync(DbWriteRequest req)
|
||||
{
|
||||
if (!_sessionSvc.IsConnected)
|
||||
return new DbWriteResult { Success = false, Message = "OPC 세션이 연결되어 있지 않습니다." };
|
||||
|
||||
var (tableOk, tableMsg) = await EnsureTableAsync(req);
|
||||
if (!tableOk)
|
||||
return new DbWriteResult { Success = false, Message = $"테이블 오류: {tableMsg}" };
|
||||
|
||||
var records = new List<DbWriteRecord>();
|
||||
int saved = 0;
|
||||
|
||||
for (int i = 1; i <= req.Count; i++)
|
||||
{
|
||||
// ── 1. OPC 태그 읽기 ────────────────────────────────────
|
||||
double val = 0;
|
||||
string status = "Unknown";
|
||||
bool dbSaved = false;
|
||||
|
||||
try
|
||||
{
|
||||
var (rawVal, sc) = await _sessionSvc.ReadValueAsync(req.TagNodeId);
|
||||
val = Convert.ToDouble(rawVal);
|
||||
status = sc;
|
||||
_logger.LogInformation("[{I}/{N}] {Tag} = {Val} ({Status})",
|
||||
i, req.Count, req.TagName, val, status);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
status = $"ReadError: {ex.Message}";
|
||||
_logger.LogWarning("[{I}/{N}] OPC 읽기 실패: {Msg}", i, req.Count, ex.Message);
|
||||
}
|
||||
|
||||
// ── 2. DB 저장 (원본 SaveToDatabase 로직) ───────────────
|
||||
try
|
||||
{
|
||||
await SaveToDatabaseAsync(req, val, status);
|
||||
dbSaved = true;
|
||||
saved++;
|
||||
_logger.LogInformation("[{I}/{N}] DB 저장 완료.", i, req.Count);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[{I}/{N}] DB 저장 실패", i, req.Count);
|
||||
}
|
||||
|
||||
records.Add(new DbWriteRecord
|
||||
{
|
||||
Seq = i,
|
||||
Timestamp = DateTime.UtcNow,
|
||||
TagName = req.TagName,
|
||||
Value = val,
|
||||
StatusCode = status,
|
||||
DbSaved = dbSaved
|
||||
});
|
||||
|
||||
// ── 3. 인터벌 대기 (마지막 회차는 생략) ─────────────────
|
||||
if (i < req.Count && req.IntervalMs > 0)
|
||||
await Task.Delay(req.IntervalMs);
|
||||
}
|
||||
|
||||
return new DbWriteResult
|
||||
{
|
||||
Success = saved > 0,
|
||||
Message = $"{saved}/{req.Count}회 저장 완료",
|
||||
SavedCount = saved,
|
||||
Records = records
|
||||
};
|
||||
}
|
||||
|
||||
// ── 원본 SaveToDatabase (INSERT 패턴 동일) ────────────────────────
|
||||
|
||||
private async Task SaveToDatabaseAsync(DbWriteRequest req, double val, string status)
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(BuildConnString(req));
|
||||
await conn.OpenAsync();
|
||||
|
||||
// 원본 동일 INSERT
|
||||
const string sql =
|
||||
"INSERT INTO opc_history (tag_name, tag_value, status_code) " +
|
||||
"VALUES (@tag, @val, @status)";
|
||||
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("tag", req.TagName);
|
||||
cmd.Parameters.AddWithValue("val", val);
|
||||
cmd.Parameters.AddWithValue("status", status);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
// ── DB 조회 ───────────────────────────────────────────────────────
|
||||
|
||||
public async Task<DbQueryResult> QueryRecentAsync(DbWriteRequest req, int limit = 100)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(BuildConnString(req));
|
||||
await conn.OpenAsync();
|
||||
|
||||
string sql = $"""
|
||||
SELECT id, tag_name, tag_value, status_code, created_at
|
||||
FROM opc_history
|
||||
ORDER BY id DESC
|
||||
LIMIT {limit}
|
||||
""";
|
||||
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
await using var reader = await cmd.ExecuteReaderAsync();
|
||||
|
||||
var rows = new List<OpcHistoryRow>();
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
rows.Add(new OpcHistoryRow
|
||||
{
|
||||
Id = reader.GetInt64(0),
|
||||
TagName = reader.GetString(1),
|
||||
TagValue = reader.GetDouble(2),
|
||||
StatusCode = reader.GetString(3),
|
||||
CreatedAt = reader.GetDateTime(4)
|
||||
});
|
||||
}
|
||||
|
||||
// 전체 카운트
|
||||
await reader.CloseAsync();
|
||||
await using var cntCmd = new NpgsqlCommand("SELECT COUNT(*) FROM opc_history", conn);
|
||||
long total = (long)(await cntCmd.ExecuteScalarAsync() ?? 0L);
|
||||
|
||||
return new DbQueryResult
|
||||
{
|
||||
Success = true,
|
||||
TotalCount = (int)total,
|
||||
Rows = rows
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "DB 조회 실패");
|
||||
return new DbQueryResult { Success = false, Message = ex.Message };
|
||||
}
|
||||
}
|
||||
|
||||
// ── 연결 테스트 ───────────────────────────────────────────────────
|
||||
|
||||
public async Task<(bool Ok, string Msg)> TestConnectionAsync(DbWriteRequest req)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(BuildConnString(req));
|
||||
await conn.OpenAsync();
|
||||
await using var cmd = new NpgsqlCommand("SELECT version()", conn);
|
||||
var ver = await cmd.ExecuteScalarAsync();
|
||||
return (true, $"연결 성공: {ver}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return (false, ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
161
opcUaManager/Services/OpcCrawlerService.cs
Normal file
161
opcUaManager/Services/OpcCrawlerService.cs
Normal file
@@ -0,0 +1,161 @@
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
using OpcUaManager.Models;
|
||||
|
||||
namespace OpcUaManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 원본 HoneywellCrawler 를 Service 로 분리.
|
||||
/// v1.5.374.70: BrowseNextAsync 가 튜플 반환이 아닌 BrowseNextResponse 객체 반환으로 변경됨.
|
||||
/// </summary>
|
||||
public class OpcCrawlerService
|
||||
{
|
||||
private readonly ILogger<OpcCrawlerService> _logger;
|
||||
private readonly OpcSessionService _sessionSvc;
|
||||
|
||||
public OpcCrawlerService(ILogger<OpcCrawlerService> logger, OpcSessionService sessionSvc)
|
||||
{
|
||||
_logger = logger;
|
||||
_sessionSvc = sessionSvc;
|
||||
}
|
||||
|
||||
public async Task<CrawlResult> CrawlAsync(CrawlRequest req)
|
||||
{
|
||||
if (!_sessionSvc.IsConnected)
|
||||
return new CrawlResult { Success = false, Message = "세션이 연결되어 있지 않습니다." };
|
||||
|
||||
var session = _sessionSvc.GetRawSession();
|
||||
if (session == null)
|
||||
return new CrawlResult { Success = false, Message = "Raw 세션을 가져올 수 없습니다." };
|
||||
|
||||
var tags = new List<TagMaster>();
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("저인망 탐사 시작: {NodeId}", req.StartNodeId);
|
||||
NodeId rootNode = NodeId.Parse(req.StartNodeId);
|
||||
await BrowseRecursiveAsync(session, rootNode, 0, req.MaxDepth, tags);
|
||||
|
||||
string csvPath = await SaveToCsvAsync(tags);
|
||||
|
||||
_logger.LogInformation("탐사 완료: {Count}개 노드", tags.Count);
|
||||
return new CrawlResult
|
||||
{
|
||||
Success = true,
|
||||
Message = $"탐사 완료: {tags.Count}개 노드 발견",
|
||||
TotalNodes = tags.Count,
|
||||
Tags = tags,
|
||||
CsvPath = csvPath
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Crawler 실행 오류");
|
||||
return new CrawlResult { Success = false, Message = ex.Message, Tags = tags };
|
||||
}
|
||||
}
|
||||
|
||||
private async Task BrowseRecursiveAsync(
|
||||
Session session, NodeId nodeId, int level, int maxDepth, List<TagMaster> tags)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 원본 동일: BrowseAsync 객체 방식
|
||||
BrowseDescription description = new()
|
||||
{
|
||||
NodeId = nodeId,
|
||||
BrowseDirection = BrowseDirection.Forward,
|
||||
ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
|
||||
IncludeSubtypes = true,
|
||||
NodeClassMask = (uint)(NodeClass.Variable | NodeClass.Object),
|
||||
ResultMask = (uint)BrowseResultMask.All
|
||||
};
|
||||
|
||||
BrowseResponse response = await session.BrowseAsync(
|
||||
null, null, 0, [description], default);
|
||||
|
||||
if (response?.Results == null || response.Results.Count == 0) return;
|
||||
|
||||
foreach (var result in response.Results)
|
||||
{
|
||||
await ProcessReferencesAsync(session, result.References, level, maxDepth, tags);
|
||||
|
||||
byte[] cp = result.ContinuationPoint;
|
||||
while (cp != null && cp.Length > 0)
|
||||
{
|
||||
// FIX CS8130/CS1503: BrowseNextAsync 튜플 방식 → BrowseNextResponse 객체 방식
|
||||
// v1.5.374.70 에서 반환 타입이 Task<BrowseNextResponse> 로 변경됨
|
||||
BrowseNextResponse nextResponse = await session.BrowseNextAsync(
|
||||
null,
|
||||
false,
|
||||
new ByteStringCollection { cp },
|
||||
default);
|
||||
|
||||
if (nextResponse?.Results != null && nextResponse.Results.Count > 0)
|
||||
{
|
||||
var nextResult = nextResponse.Results[0];
|
||||
await ProcessReferencesAsync(session, nextResult.References, level, maxDepth, tags);
|
||||
cp = nextResult.ContinuationPoint;
|
||||
}
|
||||
else
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 원본과 동일: 특정 노드 권한 에러 무시
|
||||
_logger.LogDebug("노드 탐색 건너뜀 [{Level}] {NodeId}: {Msg}", level, nodeId, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessReferencesAsync(
|
||||
Session session,
|
||||
ReferenceDescriptionCollection? references,
|
||||
int level,
|
||||
int maxDepth,
|
||||
List<TagMaster> tags)
|
||||
{
|
||||
if (references == null || references.Count == 0) return;
|
||||
|
||||
foreach (var rd in references)
|
||||
{
|
||||
NodeId childId = ExpandedNodeId.ToNodeId(rd.NodeId, session.NamespaceUris);
|
||||
|
||||
tags.Add(new TagMaster
|
||||
{
|
||||
TagName = rd.BrowseName.Name ?? "Unknown",
|
||||
FullNodeId = childId.ToString(),
|
||||
NodeClass = rd.NodeClass.ToString(),
|
||||
Level = level
|
||||
});
|
||||
|
||||
_logger.LogDebug("{Indent}[{Class}] {Name} ({Id})",
|
||||
new string(' ', level * 2), rd.NodeClass, rd.BrowseName.Name, childId);
|
||||
|
||||
if (rd.NodeClass == NodeClass.Object && level < maxDepth)
|
||||
await BrowseRecursiveAsync(session, childId, level + 1, maxDepth, tags);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> SaveToCsvAsync(List<TagMaster> tags)
|
||||
{
|
||||
string path = Path.GetFullPath("Honeywell_FullMap.csv");
|
||||
try
|
||||
{
|
||||
await using var sw = new StreamWriter(path);
|
||||
await sw.WriteLineAsync("Level,Class,Name,NodeId");
|
||||
foreach (var tag in tags)
|
||||
await sw.WriteLineAsync($"{tag.Level},{tag.NodeClass},{tag.TagName},{tag.FullNodeId}");
|
||||
|
||||
_logger.LogInformation("CSV 저장 완료: {Path}", path);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "CSV 저장 실패");
|
||||
}
|
||||
return path;
|
||||
}
|
||||
}
|
||||
180
opcUaManager/Services/OpcSessionService.cs
Normal file
180
opcUaManager/Services/OpcSessionService.cs
Normal file
@@ -0,0 +1,180 @@
|
||||
using System.Text;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
using OpcUaManager.Models;
|
||||
|
||||
// Microsoft.AspNetCore.Http.ISession 과 Opc.Ua.Client.ISession 충돌 해소
|
||||
using OpcUaISession = Opc.Ua.Client.ISession;
|
||||
|
||||
// Microsoft.AspNetCore.Http.StatusCodes 와 Opc.Ua.StatusCodes 충돌 해소
|
||||
using OpcStatusCodes = Opc.Ua.StatusCodes;
|
||||
|
||||
namespace OpcUaManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// OPC UA 세션을 싱글톤으로 관리합니다.
|
||||
/// v1.5.374.70 API 기준으로 수정되었습니다.
|
||||
/// </summary>
|
||||
public class OpcSessionService : IAsyncDisposable
|
||||
{
|
||||
private readonly ILogger<OpcSessionService> _logger;
|
||||
private readonly CertService _certService;
|
||||
|
||||
private OpcUaISession? _session;
|
||||
private ApplicationConfiguration? _appConfig;
|
||||
private readonly SemaphoreSlim _lock = new(1, 1);
|
||||
|
||||
public bool IsConnected => _session?.Connected == true;
|
||||
public string SessionId => _session?.SessionId?.ToString() ?? string.Empty;
|
||||
|
||||
public OpcSessionService(ILogger<OpcSessionService> logger, CertService certService)
|
||||
{
|
||||
_logger = logger;
|
||||
_certService = certService;
|
||||
}
|
||||
|
||||
public async Task<ConnectResult> ConnectAsync(ConnectRequest req)
|
||||
{
|
||||
await _lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (IsConnected)
|
||||
await InternalCloseAsync();
|
||||
|
||||
string endpointUrl = $"opc.tcp://{req.ServerIp}:{req.Port}";
|
||||
|
||||
// ── 1. 클라이언트 인증서 로드 ────────────────────────────
|
||||
System.Security.Cryptography.X509Certificates.X509Certificate2? clientCert = null;
|
||||
if (!string.IsNullOrWhiteSpace(req.PfxPath) && File.Exists(req.PfxPath))
|
||||
clientCert = _certService.LoadPfx(req.PfxPath, req.PfxPassword);
|
||||
|
||||
string appUri = string.IsNullOrWhiteSpace(req.ApplicationUri)
|
||||
? $"urn:{System.Net.Dns.GetHostName()}:{req.ApplicationName}"
|
||||
: req.ApplicationUri;
|
||||
|
||||
// ── 2. ApplicationConfiguration ───────────────────────────
|
||||
_appConfig = new ApplicationConfiguration
|
||||
{
|
||||
ApplicationName = req.ApplicationName,
|
||||
ApplicationType = ApplicationType.Client,
|
||||
ApplicationUri = appUri,
|
||||
SecurityConfiguration = new SecurityConfiguration
|
||||
{
|
||||
ApplicationCertificate = clientCert != null
|
||||
? new CertificateIdentifier { Certificate = clientCert }
|
||||
: new CertificateIdentifier(),
|
||||
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 = 15000 },
|
||||
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = req.SessionTimeoutMs }
|
||||
};
|
||||
|
||||
// FIX CS1061: ValidateAsync → Validate (v1.5.374.70에서 동기 메서드)
|
||||
_appConfig.Validate(ApplicationType.Client);
|
||||
|
||||
// FIX CS0104: StatusCodes → OpcStatusCodes 별칭 사용
|
||||
_appConfig.CertificateValidator.CertificateValidation += (_, e) =>
|
||||
{
|
||||
if (e.Error.StatusCode != OpcStatusCodes.Good) e.Accept = true;
|
||||
};
|
||||
|
||||
// ── 3. Endpoint Discovery ─────────────────────────────────
|
||||
_logger.LogInformation("Endpoint Discovery: {Url}", endpointUrl);
|
||||
var endpointConfig = EndpointConfiguration.Create(_appConfig);
|
||||
|
||||
// FIX CS0117: DiscoveryClient.CreateAsync 없음 → 동기 Create 사용
|
||||
EndpointDescriptionCollection endpoints;
|
||||
using (var discovery = DiscoveryClient.Create(new Uri(endpointUrl), endpointConfig))
|
||||
{
|
||||
endpoints = discovery.GetEndpoints(null);
|
||||
}
|
||||
|
||||
var selected = endpoints
|
||||
.OrderByDescending(e => e.SecurityLevel)
|
||||
.FirstOrDefault(e => e.SecurityPolicyUri.Contains(req.SecurityPolicy))
|
||||
?? endpoints.OrderByDescending(e => e.SecurityLevel).First();
|
||||
|
||||
var endpoint = new ConfiguredEndpoint(null, selected, endpointConfig);
|
||||
|
||||
// FIX CS1503: UserIdentity(user, byte[]) → UserIdentity(user, string)
|
||||
// v1.5.374.70 에서 password 파라미터가 string 으로 변경됨
|
||||
var identity = new UserIdentity(req.UserName, req.Password);
|
||||
|
||||
// ── 5. Session.Create ─────────────────────────────────────
|
||||
#pragma warning disable CS0618
|
||||
_session = await Session.Create(
|
||||
_appConfig,
|
||||
endpoint,
|
||||
false,
|
||||
"OpcUaManagerSession",
|
||||
(uint)req.SessionTimeoutMs,
|
||||
identity,
|
||||
null);
|
||||
#pragma warning restore CS0618
|
||||
|
||||
string secMode = selected.SecurityMode.ToString();
|
||||
_logger.LogInformation("OPC UA 연결 완료 | SessionId={Id} | Security={Sec}",
|
||||
_session.SessionId, secMode);
|
||||
|
||||
return new ConnectResult
|
||||
{
|
||||
Success = true,
|
||||
Message = "연결 성공",
|
||||
SessionId = _session.SessionId.ToString(),
|
||||
EndpointUrl = endpointUrl,
|
||||
SecurityMode = secMode
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "OPC UA 연결 실패");
|
||||
return new ConnectResult { Success = false, Message = ex.Message };
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> DisconnectAsync()
|
||||
{
|
||||
await _lock.WaitAsync();
|
||||
try { await InternalCloseAsync(); return true; }
|
||||
finally { _lock.Release(); }
|
||||
}
|
||||
|
||||
private async Task InternalCloseAsync()
|
||||
{
|
||||
if (_session != null)
|
||||
{
|
||||
try { await _session.CloseAsync(); } catch { /* ignore */ }
|
||||
_session.Dispose();
|
||||
_session = null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<(object? Value, string StatusCode)> ReadValueAsync(string nodeId)
|
||||
{
|
||||
if (!IsConnected || _session == null)
|
||||
throw new InvalidOperationException("세션이 연결되어 있지 않습니다.");
|
||||
|
||||
var result = await _session.ReadValueAsync(nodeId);
|
||||
return (result.Value, result.StatusCode.ToString());
|
||||
}
|
||||
|
||||
public Session? GetRawSession() => _session as Session;
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await InternalCloseAsync();
|
||||
_lock.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user