Files
ExperionCrawler/opcServer.md
2026-04-15 01:43:07 +00:00

414 lines
15 KiB
Markdown

# 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
<!-- 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`에 서버 설정 섹션 추가:
```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<RealtimePoint> points);
}
public record OpcServerStatus(
bool Running,
int ConnectedClientCount,
int NodeCount,
string EndpointUrl,
DateTime? StartedAt
);
```
`IExperionRealtimeService`에 콜백 훅 추가:
```csharp
// 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) 매핑
```
핵심 구현 패턴:
```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<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` 서비스를 통해 과거 데이터를 직접 조회할 수 있다.
### 추가 구현 사항
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<IExperionOpcServerService>();
```
### 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` — 노드 매니저 테스트 코드