- P&ID 그래프 파이프라인 구현 (py) - pid_geometric_extractor.py: 기하학적 특징 추출 - pid_intelligent_mapper.py: 태그 매핑 - pid_topology_builder.py: 위상 구축 - test_pipeline_phase2.py, test_pipeline_phase3.py: 테스트 - MCP 서버 개선 - server.py: 멀티프로세싱 지원 - pipeline/: 분석, 추출, 매핑, 위상 모듈 추가 - C# P&ID 그래프 서비스 - PidGraphDtos.cs: DTO 정의 - PidGraphService.cs: 비즈니스 로직 - PidGraphController.cs: API 컨트롤러 - OPC UA 서비스 개선 - ExperionOpcServerService.cs - ExperionRealtimeService.cs - ExperionFastService.cs - MCP 클라이언트 및 호스팅 서비스 개선 - McpClient.cs - McpServerHostedService.cs - 웹 UI 개선 - pid_graph_view.html: P&ID 그래프 뷰어 - pid-viewer.js: 뷰어 로직 - app.js: 메인 앱 - pid_graph.css: 스타일 - 프로젝트 설정 업데이트 - ExperionCrawler.csproj - Program.cs
504 lines
19 KiB
C#
504 lines
19 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using ExperionCrawler.Core.Application.Interfaces;
|
|
using ExperionCrawler.Core.Domain.Entities;
|
|
using ExperionCrawler.Infrastructure.Certificates;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Hosting;
|
|
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 Subscription 기반 실시간 livevalue 업데이트 서비스.
|
|
/// 값이 변경될 때만 콜백을 받아 realtime_table 을 갱신합니다.
|
|
/// </summary>
|
|
public class ExperionRealtimeService : IExperionRealtimeService, IHostedService, IDisposable
|
|
{
|
|
private readonly IServiceScopeFactory _scopeFactory;
|
|
private readonly ILogger<ExperionRealtimeService> _logger;
|
|
private readonly IServiceProvider _sp;
|
|
private readonly IOpcUaConfigProvider _configProvider;
|
|
|
|
private ISession? _session;
|
|
private Subscription? _subscription;
|
|
private CancellationTokenSource? _cts;
|
|
private Task? _monitorTask;
|
|
private Task? _flushTask;
|
|
|
|
// 콜백에서 최신 값만 기록 (노드당 1개 유지) → 500ms 배치 flush
|
|
private readonly ConcurrentDictionary<string, (string? value, DateTime timestamp)>
|
|
_pendingUpdates = new();
|
|
|
|
// nodeId → RealtimePoint 매핑 (FlushLoop에서 tagname을 찾기 위해 사용)
|
|
private Dictionary<string, Core.Domain.Entities.RealtimePoint> _pointCache = new();
|
|
|
|
// OPC UA 서버 서비스 (순환 참조 방지를 위해 lazy resolve)
|
|
private IExperionOpcServerService? _opcServer;
|
|
|
|
private volatile bool _running;
|
|
private int _subscribedCount;
|
|
private string _statusMsg = "중지됨";
|
|
private ExperionServerConfig? _currentCfg;
|
|
private volatile bool _restarting = false; // 재진입 방지 플래그
|
|
|
|
// 자동 재시작 플래그 파일 경로
|
|
private static readonly string FlagPath =
|
|
Path.GetFullPath("realtime_autostart.json");
|
|
|
|
public ExperionRealtimeService(
|
|
IServiceScopeFactory scopeFactory,
|
|
ILogger<ExperionRealtimeService> logger,
|
|
IServiceProvider sp,
|
|
IOpcUaConfigProvider configProvider)
|
|
{
|
|
_scopeFactory = scopeFactory;
|
|
_logger = logger;
|
|
_sp = sp;
|
|
_configProvider = configProvider;
|
|
}
|
|
|
|
// ── IHostedService ────────────────────────────────────────────────────────
|
|
|
|
public async Task StartAsync(CancellationToken cancellationToken)
|
|
{
|
|
// 앱 기동 시 플래그 파일이 있으면 자동 구독 시작
|
|
if (!File.Exists(FlagPath)) return;
|
|
try
|
|
{
|
|
var json = await File.ReadAllTextAsync(FlagPath, cancellationToken);
|
|
var cfg = JsonSerializer.Deserialize<ExperionServerConfig>(json);
|
|
if (cfg != null)
|
|
{
|
|
_logger.LogInformation("[Realtime] 자동 재시작 플래그 감지 — 구독 자동 시작");
|
|
await StartAsync(cfg);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "[Realtime] 자동 재시작 플래그 읽기 실패 — 무시");
|
|
}
|
|
}
|
|
|
|
public async Task StopAsync(CancellationToken cancellationToken)
|
|
{
|
|
// 앱 종료(Ctrl+C 등) 시: 플래그 파일은 유지 → 재기동 시 자동 재시작
|
|
_cts?.Cancel();
|
|
var tasks = new[] { _monitorTask, _flushTask }
|
|
.Where(t => t != null).Select(t => t!).ToArray();
|
|
if (tasks.Length > 0)
|
|
{
|
|
try
|
|
{
|
|
// 종료 시 대기 시간을 2초로 단축하여 빠른 셧다운 유도
|
|
await Task.WhenAll(tasks).WaitAsync(TimeSpan.FromSeconds(2), cancellationToken).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex) { _logger.LogDebug(ex, "[Realtime] StopAsync 대기 중 타임아웃 또는 취소 발생"); }
|
|
}
|
|
_running = false;
|
|
_logger.LogInformation("[Realtime] 구독 중지 완료 (앱 종료 — 자동 재시작 플래그 유지)");
|
|
}
|
|
|
|
// ── IExperionRealtimeService ──────────────────────────────────────────────
|
|
|
|
public async Task StartAsync(ExperionServerConfig cfg)
|
|
{
|
|
if (_running || _restarting)
|
|
{
|
|
_logger.LogWarning("[Realtime] 이미 실행 중 또는 재시작 중. 무시합니다.");
|
|
return;
|
|
}
|
|
|
|
_restarting = true;
|
|
try
|
|
{
|
|
if (_running)
|
|
{
|
|
_logger.LogWarning("[Realtime] 이미 실행 중. 재시작합니다.");
|
|
await StopAsync();
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
_restarting = false;
|
|
}
|
|
|
|
// 플래그 파일 저장 (앱 재기동 시 자동 재시작용)
|
|
try
|
|
{
|
|
var json = JsonSerializer.Serialize(cfg);
|
|
await File.WriteAllTextAsync(FlagPath, json);
|
|
_logger.LogInformation("[Realtime] 자동 재시작 플래그 저장: {Path}", FlagPath);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "[Realtime] 플래그 파일 저장 실패 (무시)");
|
|
}
|
|
|
|
_currentCfg = cfg;
|
|
_cts = new CancellationTokenSource();
|
|
_monitorTask = Task.Run(() => RunLoopAsync(_cts.Token));
|
|
_logger.LogInformation("[Realtime] 구독 시작 요청: {Url}", cfg.EndpointUrl);
|
|
}
|
|
|
|
public async Task StopAsync()
|
|
{
|
|
if (_restarting)
|
|
{
|
|
_logger.LogWarning("[Realtime] 재시작 중이므로 StopAsync 무시 (restarting 플래그 취소)");
|
|
return;
|
|
}
|
|
|
|
// 플래그 파일 삭제 (자동 재시작 비활성화)
|
|
try
|
|
{
|
|
if (File.Exists(FlagPath)) File.Delete(FlagPath);
|
|
_logger.LogInformation("[Realtime] 자동 재시작 플래그 삭제");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "[Realtime] 플래그 파일 삭제 실패 (무시)");
|
|
}
|
|
|
|
_cts?.Cancel();
|
|
|
|
var tasks = new List<Task>();
|
|
if (_monitorTask != null) tasks.Add(_monitorTask);
|
|
if (_flushTask != null) tasks.Add(_flushTask);
|
|
if (tasks.Count > 0)
|
|
await Task.WhenAll(tasks).WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
|
|
|
|
await CleanupSessionAsync();
|
|
_pendingUpdates.Clear();
|
|
_running = false;
|
|
_subscribedCount = 0;
|
|
_statusMsg = "중지됨";
|
|
_logger.LogInformation("[Realtime] 구독 중지 완료");
|
|
}
|
|
|
|
public RealtimeServiceStatus GetStatus()
|
|
=> new(_running, _subscribedCount, _statusMsg);
|
|
|
|
public async Task<(bool Success, string Message)> AddMonitoredItemAsync(string nodeId)
|
|
{
|
|
// 구독 중이 아니면 DB에만 저장된 상태 — 다음 구독 시작 시 자동 포함
|
|
if (!_running || _subscription == null)
|
|
return (true, "구독 중 아님 — 다음 구독 시작 시 자동 포함됩니다.");
|
|
await Task.CompletedTask;
|
|
|
|
var item = new MonitoredItem(_subscription.DefaultItem)
|
|
{
|
|
StartNodeId = new NodeId(nodeId),
|
|
AttributeId = Attributes.Value,
|
|
SamplingInterval = 500,
|
|
QueueSize = 1,
|
|
DiscardOldest = true,
|
|
DisplayName = nodeId
|
|
};
|
|
item.Notification += OnNotification;
|
|
_subscription.AddItem(item);
|
|
|
|
try
|
|
{
|
|
// OPC UA 서버에 실제 적용 — 서버가 node_id 유효성 검증
|
|
#pragma warning disable CS0618 // 'ApplyChanges()' is obsolete
|
|
_subscription.ApplyChanges();
|
|
#pragma warning restore CS0618 // 'ApplyChanges()' is obsolete
|
|
|
|
// 서버 응답 상태 확인 (Error가 null이면 정상)
|
|
if (item.Status.Error != null && !StatusCode.IsGood(item.Status.Error.StatusCode))
|
|
{
|
|
// 유효하지 않은 node_id → subscription에서 제거
|
|
_subscription.RemoveItem(item);
|
|
#pragma warning disable CS0618 // 'ApplyChanges()' is obsolete
|
|
_subscription.ApplyChanges();
|
|
#pragma warning restore CS0618 // 'ApplyChanges()' is obsolete
|
|
|
|
var code = item.Status.Error.StatusCode;
|
|
_logger.LogWarning("[Realtime] 잘못된 node_id: {NodeId} — {Code}", nodeId, code);
|
|
return (false, $"OPC UA 서버가 노드를 거부했습니다: {code}");
|
|
}
|
|
|
|
_subscribedCount++;
|
|
_statusMsg = $"구독 중 ({_subscribedCount}개 포인트)";
|
|
_logger.LogInformation("[Realtime] 핫 추가 성공: {NodeId}", nodeId);
|
|
return (true, "구독에 즉시 추가되었습니다.");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_subscription.RemoveItem(item);
|
|
_logger.LogError(ex, "[Realtime] MonitoredItem 추가 실패: {NodeId}", nodeId);
|
|
return (false, $"MonitoredItem 추가 중 오류: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
// ── 내부 루프 ─────────────────────────────────────────────────────────────
|
|
|
|
private async Task RunLoopAsync(CancellationToken ct)
|
|
{
|
|
while (!ct.IsCancellationRequested)
|
|
{
|
|
try
|
|
{
|
|
await ConnectAndSubscribeAsync(ct);
|
|
|
|
// 세션이 살아있는 동안 KeepAlive 대기
|
|
while (!ct.IsCancellationRequested &&
|
|
_session != null && _session.Connected)
|
|
{
|
|
await Task.Delay(5_000, ct);
|
|
}
|
|
}
|
|
catch (OperationCanceledException) { break; }
|
|
catch (Exception ex)
|
|
{
|
|
_running = false;
|
|
_statusMsg = $"재연결 대기 중: {ex.Message}";
|
|
_logger.LogWarning(ex, "[Realtime] 연결 오류, 30초 후 재시도");
|
|
await CleanupSessionAsync();
|
|
try { await Task.Delay(30_000, ct); }
|
|
catch (OperationCanceledException) { break; }
|
|
}
|
|
}
|
|
|
|
_running = false;
|
|
_statusMsg = "중지됨";
|
|
}
|
|
|
|
private async Task ConnectAndSubscribeAsync(CancellationToken ct)
|
|
{
|
|
if (_currentCfg == null) return;
|
|
|
|
_statusMsg = "연결 중...";
|
|
_logger.LogInformation("[Realtime] OPC UA 접속 시도: {Url}", _currentCfg.EndpointUrl);
|
|
|
|
var appConfig = await BuildConfigAsync(_currentCfg);
|
|
var endpoint = await SelectEndpointAsync(appConfig, _currentCfg.EndpointUrl, ct);
|
|
_session = await CreateSessionAsync(appConfig, endpoint, _currentCfg);
|
|
|
|
_logger.LogInformation("[Realtime] 세션 생성 완료");
|
|
|
|
// realtime_table 의 node_id 목록 조회
|
|
List<RealtimePoint> points;
|
|
using (var scope = _scopeFactory.CreateScope())
|
|
{
|
|
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
|
|
points = (await db.GetRealtimePointsAsync()).ToList();
|
|
}
|
|
|
|
if (points.Count == 0)
|
|
{
|
|
_statusMsg = "포인트 없음 (포인트빌더에서 먼저 빌드하세요)";
|
|
_logger.LogWarning("[Realtime] realtime_table 이 비어 있습니다.");
|
|
return;
|
|
}
|
|
|
|
// Subscription 생성
|
|
_subscription = new Subscription(_session.DefaultSubscription)
|
|
{
|
|
PublishingInterval = 1_000,
|
|
KeepAliveCount = 10,
|
|
LifetimeCount = 100,
|
|
MaxNotificationsPerPublish = 1000,
|
|
PublishingEnabled = true,
|
|
Priority = 0
|
|
};
|
|
|
|
// MonitoredItem 등록
|
|
foreach (var pt in points)
|
|
{
|
|
var item = new MonitoredItem(_subscription.DefaultItem)
|
|
{
|
|
StartNodeId = new NodeId(pt.NodeId),
|
|
AttributeId = Attributes.Value,
|
|
SamplingInterval = 500,
|
|
QueueSize = 1,
|
|
DiscardOldest = true,
|
|
DisplayName = pt.NodeId
|
|
};
|
|
item.Notification += OnNotification;
|
|
_subscription.AddItem(item);
|
|
}
|
|
|
|
_session.AddSubscription(_subscription);
|
|
#pragma warning disable CS0618 // 'Create()' is obsolete
|
|
_subscription.Create();
|
|
#pragma warning restore CS0618 // 'Create()' is obsolete
|
|
|
|
// nodeId → RealtimePoint 캐시 빌드 (FlushLoop에서 tagname 조회용)
|
|
_pointCache = points.ToDictionary(p => p.NodeId, p => p);
|
|
|
|
_subscribedCount = points.Count;
|
|
_running = true;
|
|
_statusMsg = $"구독 중 ({_subscribedCount}개 포인트)";
|
|
_logger.LogInformation("[Realtime] 구독 완료: {Count}개 포인트", _subscribedCount);
|
|
|
|
// 배치 flush 태스크 시작 (콜백 → dictionary → 500ms 단위 배치 DB 업데이트)
|
|
_flushTask = Task.Run(() => FlushLoopAsync(ct), ct);
|
|
}
|
|
|
|
// 콜백: Task.Run 없이 dictionary에만 기록 (최신 값 덮어쓰기)
|
|
private void OnNotification(MonitoredItem item, MonitoredItemNotificationEventArgs e)
|
|
{
|
|
foreach (var val in item.DequeueValues())
|
|
{
|
|
var nodeId = item.DisplayName;
|
|
var value = val.Value?.ToString();
|
|
var timestamp = val.SourceTimestamp == DateTime.MinValue ? DateTime.UtcNow : val.SourceTimestamp;
|
|
_pendingUpdates[nodeId] = (value, timestamp);
|
|
}
|
|
}
|
|
|
|
// 배치 flush 루프 — 500ms 주기, 단일 DbContext로 일괄 업데이트
|
|
private async Task FlushLoopAsync(CancellationToken ct)
|
|
{
|
|
while (!ct.IsCancellationRequested)
|
|
{
|
|
try { await Task.Delay(500, ct); }
|
|
catch (OperationCanceledException) { break; }
|
|
await FlushPendingAsync();
|
|
}
|
|
// 종료 시 남은 항목 최종 flush
|
|
await FlushPendingAsync();
|
|
}
|
|
|
|
private async Task FlushPendingAsync()
|
|
{
|
|
if (_pendingUpdates.IsEmpty) return;
|
|
|
|
// 스냅샷 후 제거 (새 콜백은 계속 dictionary에 추가 가능)
|
|
var snapshot = _pendingUpdates.ToArray();
|
|
foreach (var kv in snapshot)
|
|
_pendingUpdates.TryRemove(kv.Key, out _);
|
|
|
|
var updates = snapshot
|
|
.Select(kv => new LiveValueUpdate(kv.Key, kv.Value.value, kv.Value.timestamp))
|
|
.ToList();
|
|
|
|
try
|
|
{
|
|
using var scope = _scopeFactory.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
|
|
var count = await db.BatchUpdateLiveValuesAsync(updates);
|
|
_logger.LogDebug("[Realtime] 배치 업데이트: {Count}/{Total}건",
|
|
count, updates.Count);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "[Realtime] 배치 DB 업데이트 실패");
|
|
}
|
|
|
|
// OPC UA 서버 노드 값 갱신 (lazy resolve — 순환 참조 방지)
|
|
try
|
|
{
|
|
_opcServer ??= _sp.GetService<IExperionOpcServerService>();
|
|
if (_opcServer?.GetStatus().Running == true)
|
|
{
|
|
foreach (var u in updates)
|
|
{
|
|
if (_pointCache.TryGetValue(u.NodeId, out var pt))
|
|
_opcServer.UpdateNodeValue(pt.TagName, u.Value, u.Timestamp);
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogDebug(ex, "[Realtime] OPC 서버 노드 값 갱신 실패 (무시)");
|
|
}
|
|
}
|
|
|
|
private async Task CleanupSessionAsync()
|
|
{
|
|
try
|
|
{
|
|
if (_subscription != null)
|
|
{
|
|
#pragma warning disable CS0618 // 'Delete()' is obsolete
|
|
_subscription.Delete(true);
|
|
#pragma warning restore CS0618 // 'Delete()' is obsolete
|
|
_subscription = null;
|
|
}
|
|
if (_session != null)
|
|
{
|
|
if (_session.Connected)
|
|
await _session.CloseAsync();
|
|
_session.Dispose();
|
|
_session = null;
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "[Realtime] 세션 정리 중 오류 (무시)");
|
|
}
|
|
}
|
|
|
|
// ── OPC UA 헬퍼 ─────────────────────────────────────────────────────────────
|
|
|
|
private async Task<ApplicationConfiguration> BuildConfigAsync(ExperionServerConfig cfg)
|
|
{
|
|
return await _configProvider.GetConfigAsync(cfg);
|
|
}
|
|
|
|
private static async Task<ConfiguredEndpoint> SelectEndpointAsync(
|
|
ApplicationConfiguration appConfig, string endpointUrl,
|
|
CancellationToken ct = default)
|
|
{
|
|
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
|
timeoutCts.CancelAfter(TimeSpan.FromSeconds(10));
|
|
|
|
var endpointConfig = EndpointConfiguration.Create(appConfig);
|
|
using var discovery = await DiscoveryClient.CreateAsync(
|
|
appConfig, new Uri(endpointUrl), DiagnosticsMasks.All, timeoutCts.Token);
|
|
var endpoints = await discovery.GetEndpointsAsync(null);
|
|
var selected = endpoints
|
|
.OrderByDescending(e => e.SecurityLevel)
|
|
.FirstOrDefault(e => e.SecurityPolicyUri.Contains("Basic256Sha256"))
|
|
?? endpoints[0];
|
|
return new ConfiguredEndpoint(null, selected, endpointConfig);
|
|
}
|
|
|
|
// OPC UA Session 생성 (비동기)
|
|
private static async Task<ISession> CreateSessionAsync(
|
|
ApplicationConfiguration appConfig,
|
|
ConfiguredEndpoint endpoint,
|
|
ExperionServerConfig cfg)
|
|
{
|
|
var identity = new UserIdentity(cfg.UserName, Encoding.UTF8.GetBytes(cfg.Password));
|
|
return await new DefaultSessionFactory(null).CreateAsync(
|
|
appConfig,
|
|
endpoint,
|
|
false,
|
|
"ExperionRealtimeSession",
|
|
60_000,
|
|
identity,
|
|
null,
|
|
CancellationToken.None);
|
|
}
|
|
|
|
private volatile bool _disposed = false;
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed) return;
|
|
_disposed = true;
|
|
|
|
_cts?.Cancel();
|
|
// StopAsync에서 이미 Task.WhenAll로 대기하므로, Dispose에서는 await 없이 정리만 수행
|
|
// CleanupSessionAsync는 이미 완료된 상태를 가정
|
|
try
|
|
{
|
|
CleanupSessionAsync().GetAwaiter().GetResult();
|
|
}
|
|
catch
|
|
{
|
|
// Ignore exceptions during disposal
|
|
}
|
|
_cts?.Dispose();
|
|
}
|
|
}
|