# 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` — 노드 매니저 테스트 코드
