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

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.ServerStandardServer, 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.jssrvLoad(), 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. ExperionOpcServerNodeManagerIHistoricalDataAccess 구현
  2. ReadRaw()QueryHistoryAsync() 호출 → history_table 조회 반환
  3. 각 노드의 Historizing = true 설정
  4. TimescaleDB가 이미 설치되어 있어 대용량 이력도 고성능 처리 가능

구현 시 주의사항

1. 순환 참조 방지

ExperionRealtimeServiceIExperionOpcServerService를 직접 주입받으면 두 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절대 수정하지 않는다 (완벽 동작 중, 메모리 참조). ExperionOpcServerServiceBuildServerConfigAsync()에서 기존 인증서 경로를 그대로 참조한다.

3. 포트 충돌 확인

OPC UA 기본 포트 4840이 이미 Experion HS R530에 의해 사용 중일 수 있다. appsettings.json에서 다른 포트(예: 4841)로 변경 가능하도록 설계한다.

4. 주소 공간 재구성 타이밍

포인트빌더에서 BuildRealtimeTable 실행 시 realtime_table이 재생성된다. 이 시점에 RebuildAddressSpace()를 자동 호출하거나, UI에서 수동 호출하도록 설계한다. 자동 연동 시: ExperionPointBuilderControllerBuild 엔드포인트에서 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 — 노드 매니저 테스트 코드