# ExperionCrawler OPC UA 서버 기능 추가 계획 ## 배경 및 목표 ExperionCrawler는 현재 **Experion HS R530 → ExperionCrawler** 방향의 OPC UA 클라이언트로만 동작한다. 목표는 ExperionCrawler가 동시에 **OPC UA 서버**로도 동작하여, 다른 OPC UA 클라이언트(Experion PKS, SCADA, MES 등)가 ExperionCrawler에 접속해 실시간 값을 읽어갈 수 있도록 하는 것이다. ``` [Experion HS R530] ──(OPC UA Client)──► ExperionCrawler ◄──(OPC UA Client)── [외부 시스템] │ (OPC UA Server) │ [PostgreSQL DB] ``` --- ## 현재 구조 파악 ### 사용 중인 OPC UA SDK ```xml OPCFoundation.NetStandard.Opc.Ua.Core v1.5.378.134 OPCFoundation.NetStandard.Opc.Ua.Client v1.5.378.134 OPCFoundation.NetStandard.Opc.Ua.Configuration v1.5.378.134 ``` ### 현재 실시간 데이터 흐름 ``` Experion R530 └── OPC UA Subscription (500ms sampling) └── OnNotification callback └── ConcurrentDictionary[nodeId] = latestValue └── FlushLoopAsync (500ms) └── BatchUpdateLiveValuesAsync → PostgreSQL realtime_table ``` ### 노출 가능한 데이터 | 데이터 | 출처 | 노드 수 | |--------|------|---------| | 실시간 livevalue | realtime_table | ~1,699개 | | 이력 데이터 | history_table | 무제한 (TimescaleDB) | --- ## 필요 패키지 추가 ### 추가할 NuGet 패키지 ```xml ``` > **참고**: `OPCFoundation.NetStandard.Opc.Ua.Server`는 `StandardServer`, `CustomNodeManager2` > 등 서버 구현에 필요한 클래스를 포함한다. Core/Client/Configuration 패키지와 버전을 맞춰야 한다. --- ## 설계 결정 ### OPC UA 서버 주소 공간 구조 ``` Root └── Objects └── ExperionCrawler (FolderNode) ├── ServerInfo (FolderNode) │ ├── Status (Variable: String) -- "Running" / "Stopped" │ ├── PointCount (Variable: Int32) -- 구독 중인 포인트 수 │ └── LastUpdateTime (Variable: DateTime) └── Realtime (FolderNode) ├── (Variable: Double/String/…) NodeId: ns=2;s= ├── (Variable: Double/String/…) └── … ``` - **Namespace Index**: `ns=2` (ns=0: OPC UA 표준, ns=1: 예약) - **NodeId 명명 규칙**: `ns=2;s={tagname}` (예: `ns=2;s=FIC101_PV`) - **DataType**: realtime_table의 tagname에서 dataType을 추론 (`node_map_master` 조인) - **Historical Access**: Phase 2에서 HA(HistoricalAccess) 인터페이스 추가 ### 값 업데이트 방식 `FlushLoopAsync`에서 DB 업데이트와 **동시에** OPC UA 서버 노드 값을 갱신한다. 별도 DB 폴링 없이 realtime_table 변경 즉시 OPC UA 클라이언트에게 전달된다. ``` ConcurrentDictionary[nodeId] = latestValue └── FlushLoopAsync (500ms) ├── BatchUpdateLiveValuesAsync → PostgreSQL └── OpcServerNodeManager.UpdateNodeValue(tagname, value) ← 신규 ``` ### 서버 포트 및 보안 | 항목 | 기본값 | 비고 | |------|--------|------| | 엔드포인트 URL | `opc.tcp://0.0.0.0:4840` | 포트는 appsettings.json에서 변경 가능 | | 보안 정책 | None + Basic256Sha256 | 두 가지 엔드포인트 동시 제공 | | 인증 | Anonymous + Username/Password | appsettings.json 설정 | | 서버 인증서 | `pki/own/certs/{hostname}.pfx` | 기존 클라이언트 인증서 **재사용** | --- ## 구현 태스크 ### Task 1 — NuGet 패키지 및 설정 추가 **파일**: `src/Web/ExperionCrawler.csproj`, `appsettings.json` - [ ] `OPCFoundation.NetStandard.Opc.Ua.Server` 패키지 추가 - [ ] `appsettings.json`에 서버 설정 섹션 추가: ```json "OpcUaServer": { "Port": 4840, "EnableSecurity": true, "AllowAnonymous": true, "AllowedUsernames": ["opcuser"], "AllowedPasswords": ["opcpass"] } // ApplicationName/ApplicationUri는 기존 클라이언트 설정과 동일하게 사용 // (인증서 재사용을 위해 ApplicationType.ClientAndServer로 전환) ``` --- ### Task 2 — 인터페이스 정의 **파일**: `src/Core/Application/Interfaces/IExperionServices.cs` 추가할 인터페이스: ```csharp public interface IExperionOpcServerService { Task StartAsync(CancellationToken ct = default); Task StopAsync(CancellationToken ct = default); OpcServerStatus GetStatus(); void UpdateNodeValue(string tagname, string? value, DateTime timestamp); void RebuildAddressSpace(IEnumerable points); } public record OpcServerStatus( bool Running, int ConnectedClientCount, int NodeCount, string EndpointUrl, DateTime? StartedAt ); ``` `IExperionRealtimeService`에 콜백 훅 추가: ```csharp // FlushLoop 완료 후 서버에 값을 밀어넣을 수 있도록 event Action>? OnBatchFlushed; ``` --- ### Task 3 — OPC UA 서버 노드 매니저 구현 **파일**: `src/Infrastructure/OpcUa/ExperionOpcServerNodeManager.cs` (신규) `CustomNodeManager2`를 상속받아 구현: ``` ExperionOpcServerNodeManager : CustomNodeManager2 - CreateAddressSpace() → Realtime 폴더 + 태그별 VariableNode 생성 - UpdateNodeValue() → BaseDataVariableState 값 + StatusCode + Timestamp 갱신 - RebuildAddressSpace() → realtime_table 변경 시 노드 재구성 - GetTagDataType() → tagname → OPC UA DataType(NodeId) 매핑 ``` 핵심 구현 패턴: ```csharp // 노드 생성 var variable = new BaseDataVariableState(folderNode) { NodeId = new NodeId($"ns=2;s={point.Tagname}"), BrowseName = new QualifiedName(point.Tagname, NamespaceIndex), DisplayName = point.Tagname, DataType = DataTypeIds.Double, // tagname 기반으로 결정 AccessLevel = AccessLevels.CurrentRead, UserAccessLevel = AccessLevels.CurrentRead, Historizing = false, Value = null, StatusCode = StatusCodes.BadNoData, Timestamp = DateTime.UtcNow }; // 값 업데이트 (FlushLoop에서 호출) variable.Value = Convert.ToDouble(rawValue); variable.Timestamp = timestamp; variable.StatusCode = StatusCodes.Good; variable.ClearChangeMasks(SystemContext, false); ``` --- ### Task 4 — OPC UA 서버 서비스 구현 **파일**: `src/Infrastructure/OpcUa/ExperionOpcServerService.cs` (신규) `BackgroundService` + `IExperionOpcServerService` 구현: ``` ExperionOpcServerService : BackgroundService, IExperionOpcServerService - ExecuteAsync() → 서버 시작 루프 (오류 시 30초 후 재시도) - StartAsync(CancellationToken) → StandardServer 인스턴스 시작 - StopAsync(CancellationToken) → StandardServer 정지 - UpdateNodeValue() → NodeManager에게 위임 - RebuildAddressSpace() → NodeManager에게 위임 - BuildServerConfigAsync() → ApplicationConfiguration 생성 (서버용) - GetConnectedClientCount() → server.CurrentInstance.Sessions?.Count ``` `ApplicationConfiguration` 서버 모드: ```csharp new ApplicationConfiguration { ApplicationName = "ExperionCrawlerServer", ApplicationType = ApplicationType.Server, // ← 핵심: Client → Server ServerConfiguration = new ServerConfiguration { BaseAddresses = { $"opc.tcp://0.0.0.0:{port}" }, SecurityPolicies = { /* None + Basic256Sha256 */ }, UserTokenPolicies = { /* Anonymous + UserName */ }, MaxSessionCount = 100, MaxSubscriptionCount = 500, } } ``` --- ### Task 5 — ExperionRealtimeService 연동 **파일**: `src/Infrastructure/OpcUa/ExperionRealtimeService.cs` `FlushLoopAsync` 수정: ```csharp // 기존 await _dbService.BatchUpdateLiveValuesAsync(updates); // 변경 후 await _dbService.BatchUpdateLiveValuesAsync(updates); // OPC UA 서버 노드 값 동기 갱신 if (_opcServer?.GetStatus().Running == true) { foreach (var u in updates) { var tagname = _pointCache.GetValueOrDefault(u.NodeId)?.Tagname; if (tagname != null) _opcServer.UpdateNodeValue(tagname, u.Value, u.Timestamp); } } ``` 생성자 주입 추가: ```csharp private readonly IExperionOpcServerService? _opcServer; // Program.cs에서 순환 참조 방지를 위해 IServiceProvider로 lazy resolve 권장 ``` --- ### Task 6 — API 컨트롤러 추가 **파일**: `src/Web/Controllers/ExperionControllers.cs` `ExperionOpcServerController` 추가: ``` POST /api/opcserver/start → 서버 시작 POST /api/opcserver/stop → 서버 중지 GET /api/opcserver/status → { running, clientCount, nodeCount, endpointUrl, startedAt } POST /api/opcserver/rebuild → 주소 공간 재구성 (포인트 테이블 변경 후 호출) ``` --- ### Task 7 — Program.cs 서비스 등록 **파일**: `src/Web/Program.cs` ```csharp // OPC UA 서버 서비스 (Singleton + Hosted) builder.Services.AddSingleton(); builder.Services.AddSingleton( sp => sp.GetRequiredService()); builder.Services.AddHostedService( sp => sp.GetRequiredService()); ``` --- ### Task 8 — 웹 UI 추가 **파일**: `src/Web/wwwroot/index.html`, `src/Web/wwwroot/js/app.js`, `src/Web/wwwroot/css/style.css` 사이드바에 **08 OPC UA 서버** 탭 추가: ``` ┌─────────────────────────────────────────────────┐ │ OPC UA 서버 │ ├─────────────────────────────────────────────────┤ │ 상태: ● 실행 중 접속 클라이언트: 3 │ │ 엔드포인트: opc.tcp://192.168.1.10:4840 │ │ 노드 수: 1,699 시작 시각: 2026-04-14 09:00:00 │ ├─────────────────────────────────────────────────┤ │ [▶ 서버 시작] [■ 서버 중지] [↺ 주소공간 재구성] │ └─────────────────────────────────────────────────┘ ``` `app.js`에 `srvLoad()`, `srvStart()`, `srvStop()`, `srvRebuild()` 함수 구현 상태 폴링 주기: 5초 (클라이언트 수 실시간 표시) --- ### Task 9 — 자동 시작 플래그 **파일**: `src/Infrastructure/OpcUa/ExperionOpcServerService.cs` 앱 재기동 시 자동 서버 시작을 지원: - 서버 시작 시 `opcserver_autostart.json` 저장 (기존 `realtime_autostart.json` 패턴 동일) - `IHostedService.StartAsync`에서 파일 존재 시 자동 시작 - 서버 중지 시 파일 삭제 --- ## Phase 2 — Historical Access (HA) 확장 (선택적) OPC UA HA(Historical Data Access) 인터페이스를 추가하면 클라이언트가 `ReadRaw`, `ReadProcessed` 서비스를 통해 과거 데이터를 직접 조회할 수 있다. ### 추가 구현 사항 1. `ExperionOpcServerNodeManager`에 `IHistoricalDataAccess` 구현 2. `ReadRaw()` → `QueryHistoryAsync()` 호출 → history_table 조회 반환 3. 각 노드의 `Historizing = true` 설정 4. TimescaleDB가 이미 설치되어 있어 대용량 이력도 고성능 처리 가능 --- ## 구현 시 주의사항 ### 1. 순환 참조 방지 `ExperionRealtimeService`가 `IExperionOpcServerService`를 직접 주입받으면 두 Singleton이 서로를 참조하는 구조가 된다. **해결**: `IServiceProvider`를 주입받아 `FlushLoop` 첫 실행 시 lazy resolve. ```csharp // 생성자 private readonly IServiceProvider _sp; private IExperionOpcServerService? _opcServer; // FlushLoopAsync 내부 _opcServer ??= _sp.GetService(); ``` ### 2. 인증서 재사용 — 별도 발급 불필요 OPC UA 인증서는 "어느 서버에 접속하느냐"가 아니라 **"이 애플리케이션의 신원"** 이다. ExperionCrawler가 클라이언트로 Honeywell에 접속할 때나, 서버로 외부에 노출될 때나 모두 "ExperionCrawler 자신"이므로 **기존 `pki/own/certs/{hostname}.pfx` 그대로 재사용**한다. 변경점은 `ApplicationType` 하나뿐이다: ```csharp // 클라이언트 전용 (기존) ApplicationType = ApplicationType.Client // 서버 기동 시 (겸용) ApplicationType = ApplicationType.ClientAndServer ``` `ExperionCertificateService`는 **절대 수정하지 않는다** (완벽 동작 중, 메모리 참조). `ExperionOpcServerService`의 `BuildServerConfigAsync()`에서 기존 인증서 경로를 그대로 참조한다. ### 3. 포트 충돌 확인 OPC UA 기본 포트 4840이 이미 Experion HS R530에 의해 사용 중일 수 있다. appsettings.json에서 다른 포트(예: 4841)로 변경 가능하도록 설계한다. ### 4. 주소 공간 재구성 타이밍 포인트빌더에서 `BuildRealtimeTable` 실행 시 realtime_table이 재생성된다. 이 시점에 `RebuildAddressSpace()`를 자동 호출하거나, UI에서 수동 호출하도록 설계한다. 자동 연동 시: `ExperionPointBuilderController`의 `Build` 엔드포인트에서 `RebuildAddressSpace()` 호출 추가. ### 5. 값 DataType 매핑 OPC UA 서버는 노드 생성 시 DataType을 고정해야 한다. realtime_table에는 dataType이 없으므로 `node_map_master`를 조인해서 가져와야 한다. `RealtimePoint` 엔티티에 `DataType` 컬럼 추가가 필요할 수 있다. | node_map_master.data_type | OPC UA DataType | |--------------------------|-----------------| | Double | DataTypeIds.Double | | Float | DataTypeIds.Float | | Int32 | DataTypeIds.Int32 | | Int64 | DataTypeIds.Int64 | | Boolean | DataTypeIds.Boolean | | String | DataTypeIds.String | | DateTime | DataTypeIds.DateTime | | 기타/NULL | DataTypeIds.String (fallback) | --- ## 예상 작업량 요약 | Task | 파일 수 | 난이도 | 비고 | |------|---------|--------|------| | 1. 패키지/설정 | 2 | 낮음 | | | 2. 인터페이스 | 1 | 낮음 | | | 3. NodeManager | 1 (신규) | **높음** | OPC UA 주소 공간 설계 핵심 | | 4. ServerService | 1 (신규) | **높음** | StandardServer 설정 복잡 | | 5. Realtime 연동 | 1 | 중간 | lazy resolve 패턴 | | 6. API 컨트롤러 | 1 | 낮음 | | | 7. Program.cs | 1 | 낮음 | | | 8. 웹 UI | 3 | 중간 | | | 9. 자동 시작 | 1 | 낮음 | 기존 패턴 재사용 | **핵심 난관**: Task 3, 4 — OPC SDK의 `StandardServer` + `CustomNodeManager2` API는 클라이언트 API보다 복잡하고 문서가 부족함. OPC Foundation 예제 코드(`UA-.NETStandard` GitHub) 참조 필수. --- ## 참고 리소스 - OPC Foundation .NET Standard GitHub: `https://github.com/OPCFoundation/UA-.NETStandard` - 예제: `Applications/ConsoleReferenceServer` — 최소 구현 서버 참조 구현 - 예제: `Tests/Opc.Ua.Server.Tests` — 노드 매니저 테스트 코드