15 KiB
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
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 패키지
<!-- OPC UA 서버 기능 -->
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Server"
Version="1.5.378.134" />
참고:
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)
├── <tagname_1> (Variable: Double/String/…) NodeId: ns=2;s=<tagname>
├── <tagname_2> (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에 서버 설정 섹션 추가:"OpcUaServer": { "Port": 4840, "EnableSecurity": true, "AllowAnonymous": true, "AllowedUsernames": ["opcuser"], "AllowedPasswords": ["opcpass"] } // ApplicationName/ApplicationUri는 기존 클라이언트 설정과 동일하게 사용 // (인증서 재사용을 위해 ApplicationType.ClientAndServer로 전환)
Task 2 — 인터페이스 정의
파일: src/Core/Application/Interfaces/IExperionServices.cs
추가할 인터페이스:
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<RealtimePoint> points);
}
public record OpcServerStatus(
bool Running,
int ConnectedClientCount,
int NodeCount,
string EndpointUrl,
DateTime? StartedAt
);
IExperionRealtimeService에 콜백 훅 추가:
// FlushLoop 완료 후 서버에 값을 밀어넣을 수 있도록
event Action<IEnumerable<LiveValueUpdate>>? 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) 매핑
핵심 구현 패턴:
// 노드 생성
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 서버 모드:
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 수정:
// 기존
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);
}
}
생성자 주입 추가:
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
// OPC UA 서버 서비스 (Singleton + Hosted)
builder.Services.AddSingleton<ExperionOpcServerService>();
builder.Services.AddSingleton<IExperionOpcServerService>(
sp => sp.GetRequiredService<ExperionOpcServerService>());
builder.Services.AddHostedService(
sp => sp.GetRequiredService<ExperionOpcServerService>());
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 서비스를 통해 과거 데이터를 직접 조회할 수 있다.
추가 구현 사항
ExperionOpcServerNodeManager에IHistoricalDataAccess구현ReadRaw()→QueryHistoryAsync()호출 → history_table 조회 반환- 각 노드의
Historizing = true설정 - TimescaleDB가 이미 설치되어 있어 대용량 이력도 고성능 처리 가능
구현 시 주의사항
1. 순환 참조 방지
ExperionRealtimeService가 IExperionOpcServerService를 직접 주입받으면
두 Singleton이 서로를 참조하는 구조가 된다.
해결: IServiceProvider를 주입받아 FlushLoop 첫 실행 시 lazy resolve.
// 생성자
private readonly IServiceProvider _sp;
private IExperionOpcServerService? _opcServer;
// FlushLoopAsync 내부
_opcServer ??= _sp.GetService<IExperionOpcServerService>();
2. 인증서 재사용 — 별도 발급 불필요
OPC UA 인증서는 "어느 서버에 접속하느냐"가 아니라 "이 애플리케이션의 신원" 이다.
ExperionCrawler가 클라이언트로 Honeywell에 접속할 때나, 서버로 외부에 노출될 때나
모두 "ExperionCrawler 자신"이므로 기존 pki/own/certs/{hostname}.pfx 그대로 재사용한다.
변경점은 ApplicationType 하나뿐이다:
// 클라이언트 전용 (기존)
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— 노드 매니저 테스트 코드