운전시 버그 수정
This commit is contained in:
413
opcServer.md
Normal file
413
opcServer.md
Normal file
@@ -0,0 +1,413 @@
|
||||
# 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` — 노드 매니저 테스트 코드
|
||||
Reference in New Issue
Block a user