# 5. OPC UA 서버 기능 (Phase 1) — 완료

This commit is contained in:
windpacer
2026-04-15 08:19:55 +00:00
parent 85e596d66b
commit d9f5bfd6f6
32 changed files with 1013 additions and 6 deletions

View File

@@ -7,6 +7,93 @@
## 완료된 작업 ## 완료된 작업
### 기능 추가 — OPC UA 서버 기능 (2026-04-15)
#### 배경
ExperionCrawler가 OPC UA 클라이언트 역할만 했으나, 외부 OPC UA 클라이언트(SCADA, MES 등)가 ExperionCrawler에 접속해 실시간 값을 읽을 수 있도록 OPC UA 서버 기능 추가.
#### 아키텍처
```
[Experion HS R530] ──(OPC UA Client)──► ExperionCrawler ◄──(OPC UA Client)── [외부 시스템]
(OPC UA Server)
[PostgreSQL DB]
```
#### 주소 공간 구조
```
Root/Objects/ExperionCrawler
├── ServerInfo/Status, PointCount, LastUpdateTime
└── Realtime/<tagname_1>, <tagname_2>, … (ns=2;s=tag_{tagname})
```
#### 수정/추가 파일
| 파일 | 수정 내용 |
|------|----------|
| `src/Web/ExperionCrawler.csproj` | `OPCFoundation.NetStandard.Opc.Ua.Server v1.5.378.134` 패키지 추가 |
| `src/Web/appsettings.json` | `OpcUaServer` 섹션 추가 (Port:4841, EnableSecurity:false, AllowAnonymous:true) |
| `src/Core/Application/Interfaces/IExperionServices.cs` | `IExperionOpcServerService` 인터페이스, `OpcServerStatus` record, `GetRealtimeNodeDataTypesAsync()` 추가 |
| `src/Infrastructure/OpcUa/ExperionOpcServerNodeManager.cs` | 신규 — `CustomNodeManager2` 상속, 주소 공간 관리 (`CreateAddressSpace`, `RebuildAddressSpace`, `UpdateNodeValue`) |
| `src/Infrastructure/OpcUa/ExperionOpcServerService.cs` | 신규 — `ExperionStandardServer` + `ExperionOpcServerService` (`IHostedService` + `IExperionOpcServerService`) |
| `src/Infrastructure/OpcUa/ExperionRealtimeService.cs` | `_pointCache` (nodeId→RealtimePoint) 추가; `FlushPendingAsync`에서 OPC 서버 노드 값 lazy 갱신 |
| `src/Infrastructure/Database/ExperionDbContext.cs` | `GetRealtimeNodeDataTypesAsync()` — realtime_table × node_map_master 조인 |
| `src/Web/Controllers/ExperionControllers.cs` | `ExperionOpcServerController` 추가 (start/stop/status/rebuild) |
| `src/Web/Program.cs` | `ExperionOpcServerService` Singleton+HostedService 등록 |
| `src/Web/wwwroot/index.html` | 08 OPC UA 서버 탭 + pane-opcsvr 섹션 추가 |
| `src/Web/wwwroot/js/app.js` | `srvLoad/Start/Stop/Rebuild/_srvRender/_srvStartPoll/_srvStopPoll` 구현 |
| `src/Web/wwwroot/css/style.css` | `.srv-status-card`, `.srv-meta`, `.dot.grn` 스타일 추가 |
#### 주요 설계 결정
| 항목 | 결정 |
|------|------|
| 인증서 | 기존 `pki/own/certs/{hostname}.pfx` 재사용 (`ApplicationType.ClientAndServer`) |
| 포트 | 기본 4841 (4840은 Experion HS R530이 사용 가능) |
| 보안 | 기본 None (appsettings.json에서 변경 가능) |
| 자동 재시작 | `opcserver_autostart.json` 플래그 파일 패턴 (RealtimeService와 동일) |
| 순환 참조 | `IServiceProvider` lazy resolve — `_opcServer ??= _sp.GetService<IExperionOpcServerService>()` |
| FlushLoop 연동 | 500ms 배치 DB 업데이트 후 → OPC 서버 노드 값도 동시 갱신 (DB 폴링 없음) |
#### API 엔드포인트
- `GET /api/opcserver/status` — 상태 조회 (running, clientCount, nodeCount, endpointUrl, startedAt)
- `POST /api/opcserver/start` — 서버 시작
- `POST /api/opcserver/stop` — 서버 중지
- `POST /api/opcserver/rebuild` — 주소 공간 재구성
#### 빌드 결과
- 경고 11건 (기존 8건 + OPC SDK Server Start/Stop deprecated 3건), **에러 0건** — 빌드 성공
#### OPC UA 서버가 노출하는 데이터
**데이터 출처**: `realtime_table`에 등록된 포인트 전체 (포인트빌더에서 빌드/수동 추가한 포인트)
**주소 공간 구조**
```
Root/Objects/ExperionCrawler
├── ServerInfo/
│ ├── Status (String) — "Running" / "Stopped"
│ ├── PointCount (Int32) — 구독 중인 포인트 수
│ └── LastUpdateTime (DateTime) — 마지막 값 갱신 시각
└── Realtime/
├── <tagname_1> ns=2;s=tag_FIC101_PV
├── <tagname_2>
└── …
```
**NodeId 명명 규칙**: `ns=2;s=tag_{tagname}`
**DataType 결정**: `realtime_table` × `node_map_master` 조인
- Double/Float/Int32/Int64/Boolean/DateTime → 해당 OPC UA 타입
- 기타/NULL → String (fallback)
**접근 제한**: 읽기 전용 (`AccessLevel = CurrentRead`), `Historizing = false`
**갱신 주기**: Experion HS R530 → FlushLoop 500ms 배치 → DB + OPC 서버 노드 동시 갱신
---
### 로그 정리 — 스냅샷 로그 2줄 → 1줄 (2026-04-15) ### 로그 정리 — 스냅샷 로그 2줄 → 1줄 (2026-04-15)
#### 증상 #### 증상

View File

@@ -69,6 +69,10 @@ public interface IExperionDbService
Task<IEnumerable<string>> GetTagNamesAsync(); Task<IEnumerable<string>> GetTagNamesAsync();
Task<HistoryQueryResult> QueryHistoryAsync( Task<HistoryQueryResult> QueryHistoryAsync(
IEnumerable<string> tagNames, DateTime? from, DateTime? to, int limit); IEnumerable<string> tagNames, DateTime? from, DateTime? to, int limit);
// ── OPC UA Server 지원 ────────────────────────────────────────────────────
/// <summary>realtime_table × node_map_master 조인 → nodeId → dataType 사전 반환</summary>
Task<IReadOnlyDictionary<string, string>> GetRealtimeNodeDataTypesAsync();
} }
// ── Realtime Service ───────────────────────────────────────────────────────── // ── Realtime Service ─────────────────────────────────────────────────────────
@@ -87,6 +91,24 @@ public interface IExperionRealtimeService
public record RealtimeServiceStatus(bool Running, int SubscribedCount, string Message); public record RealtimeServiceStatus(bool Running, int SubscribedCount, string Message);
// ── OPC UA Server ─────────────────────────────────────────────────────────────
public interface IExperionOpcServerService
{
Task StartServerAsync();
Task StopServerAsync();
OpcServerStatus GetStatus();
void UpdateNodeValue(string tagname, string? value, DateTime timestamp);
void RebuildAddressSpace(IEnumerable<ExperionCrawler.Core.Domain.Entities.RealtimePoint> points);
}
public record OpcServerStatus(
bool Running,
int ConnectedClientCount,
int NodeCount,
string EndpointUrl,
DateTime? StartedAt);
// ── Status Code ────────────────────────────────────────────────────────────── // ── Status Code ──────────────────────────────────────────────────────────────
public interface IExperionStatusCodeService public interface IExperionStatusCodeService

View File

@@ -375,4 +375,19 @@ public class ExperionDbService : IExperionDbService
return new NodeMapQueryResult(total, items); return new NodeMapQueryResult(total, items);
} }
/// <summary>realtime_table × node_map_master 조인 → nodeId → dataType 사전 반환</summary>
public async Task<IReadOnlyDictionary<string, string>> GetRealtimeNodeDataTypesAsync()
{
var result = await (
from rt in _ctx.RealtimePoints
join nm in _ctx.NodeMapMasters on rt.NodeId equals nm.NodeId into joined
from nm in joined.DefaultIfEmpty()
select new { rt.NodeId, DataType = nm == null ? "String" : nm.DataType }
).ToListAsync();
return result
.GroupBy(x => x.NodeId)
.ToDictionary(g => g.Key, g => g.First().DataType);
}
} }

View File

@@ -0,0 +1,229 @@
using System.Collections.Concurrent;
using ExperionCrawler.Core.Domain.Entities;
using Opc.Ua;
using Opc.Ua.Server;
using StatusCodes = Opc.Ua.StatusCodes;
namespace ExperionCrawler.Infrastructure.OpcUa;
/// <summary>
/// OPC UA 서버 주소 공간 관리자.
/// ns=2 아래에 ExperionCrawler/ServerInfo/* 와 ExperionCrawler/Realtime/* 노드를 생성한다.
/// </summary>
public class ExperionOpcServerNodeManager : CustomNodeManager2
{
private const string NamespaceUri = "urn:ExperionCrawler:Realtime";
private FolderState? _realtimeFolder;
private BaseDataVariableState? _statusNode;
private BaseDataVariableState? _pointCountNode;
private BaseDataVariableState? _lastUpdateNode;
private readonly ConcurrentDictionary<string, BaseDataVariableState> _tagNodes = new();
public ExperionOpcServerNodeManager(
IServerInternal server,
ApplicationConfiguration configuration)
: base(server, configuration, NamespaceUri)
{
}
// ── CreateAddressSpace (서버 시작 시 1회 호출) ────────────────────────────
public override void CreateAddressSpace(IDictionary<NodeId, IList<IReference>> externalReferences)
{
lock (Lock)
{
// Root folder: Objects/ExperionCrawler
var root = CreateFolderNode(null, "ExperionCrawler", "ExperionCrawler", externalReferences);
// ServerInfo 하위 변수 노드
var info = CreateFolderNode(root, "ExperionCrawler_ServerInfo", "ServerInfo", null);
_statusNode = CreateVarNode(info, "EC_Status", "Status", DataTypeIds.String, ValueRanks.Scalar, (string)"Stopped");
_pointCountNode = CreateVarNode(info, "EC_PointCount", "PointCount", DataTypeIds.Int32, ValueRanks.Scalar, (int)0);
_lastUpdateNode = CreateVarNode(info, "EC_LastUpdate", "LastUpdateTime", DataTypeIds.DateTime, ValueRanks.Scalar, DateTime.UtcNow);
// Realtime 폴더 — RebuildAddressSpace() 호출 시 태그 노드 추가
_realtimeFolder = CreateFolderNode(root, "ExperionCrawler_Realtime", "Realtime", null);
AddPredefinedNode(SystemContext, root);
}
}
// ── RebuildAddressSpace (포인트빌더 빌드 후 호출) ────────────────────────
/// <summary>realtime_table 의 포인트 목록으로 Realtime 폴더 노드를 재구성한다.</summary>
public void RebuildAddressSpace(
IEnumerable<RealtimePoint> points,
IReadOnlyDictionary<string, string>? dataTypeMap = null)
{
if (_realtimeFolder == null) return;
lock (Lock)
{
// 기존 태그 노드 제거
foreach (var kv in _tagNodes)
{
_realtimeFolder.RemoveChild(kv.Value);
DeleteNode(SystemContext, kv.Value.NodeId);
}
_tagNodes.Clear();
// 새 태그 노드 추가
foreach (var pt in points)
{
var dtStr = dataTypeMap?.GetValueOrDefault(pt.NodeId) ?? "String";
var dtId = MapDataType(dtStr);
// NodeId는 ns=2;s=tag_{tagname} 형식
var node = CreateVarNode(_realtimeFolder, $"tag_{pt.TagName}", pt.TagName, dtId, ValueRanks.Scalar, null);
node.StatusCode = StatusCodes.BadNoData;
_tagNodes[pt.TagName] = node;
AddPredefinedNode(SystemContext, node);
}
// ServerInfo 업데이트
if (_pointCountNode != null)
{
_pointCountNode.Value = _tagNodes.Count;
_pointCountNode.ClearChangeMasks(SystemContext, false);
}
}
}
// ── UpdateNodeValue (FlushLoop 에서 500ms 마다 호출) ─────────────────────
public void UpdateNodeValue(string tagname, string? rawValue, DateTime timestamp)
{
if (!_tagNodes.TryGetValue(tagname, out var node)) return;
lock (Lock)
{
node.Value = ParseValue(node.DataType, rawValue);
node.Timestamp = timestamp;
node.StatusCode = rawValue == null ? StatusCodes.BadNoData : StatusCodes.Good;
node.ClearChangeMasks(SystemContext, false);
if (_lastUpdateNode != null)
{
_lastUpdateNode.Value = timestamp;
_lastUpdateNode.ClearChangeMasks(SystemContext, false);
}
}
}
/// <summary>ServerInfo.Status / PointCount 업데이트</summary>
public void UpdateServerStatus(string status, int pointCount)
{
lock (Lock)
{
if (_statusNode != null)
{
_statusNode.Value = status;
_statusNode.ClearChangeMasks(SystemContext, false);
}
if (_pointCountNode != null)
{
_pointCountNode.Value = pointCount;
_pointCountNode.ClearChangeMasks(SystemContext, false);
}
}
}
public int TagNodeCount => _tagNodes.Count;
// ── 헬퍼 ─────────────────────────────────────────────────────────────────
private FolderState CreateFolderNode(
NodeState? parent, string nodeIdStr, string displayName,
IDictionary<NodeId, IList<IReference>>? externalReferences)
{
var folder = new FolderState(parent)
{
SymbolicName = displayName,
ReferenceTypeId = ReferenceTypes.Organizes,
TypeDefinitionId = ObjectTypeIds.FolderType,
NodeId = new NodeId(nodeIdStr, NamespaceIndex),
BrowseName = new QualifiedName(displayName, NamespaceIndex),
DisplayName = new LocalizedText("en", displayName),
WriteMask = AttributeWriteMask.None,
UserWriteMask = AttributeWriteMask.None,
EventNotifier = EventNotifiers.None
};
if (parent != null)
{
parent.AddChild(folder);
}
else if (externalReferences != null)
{
if (!externalReferences.TryGetValue(ObjectIds.ObjectsFolder, out var refs))
externalReferences[ObjectIds.ObjectsFolder] = refs = new List<IReference>();
refs.Add(new NodeStateReference(ReferenceTypes.Organizes, false, folder.NodeId));
folder.AddReference(ReferenceTypes.Organizes, true, ObjectIds.ObjectsFolder);
}
return folder;
}
private BaseDataVariableState CreateVarNode(
NodeState parent, string nodeIdStr, string displayName,
NodeId dataTypeId, int valueRank, object? initialValue)
{
var v = new BaseDataVariableState(parent)
{
SymbolicName = displayName,
ReferenceTypeId = ReferenceTypes.HasComponent,
TypeDefinitionId = VariableTypeIds.BaseDataVariableType,
NodeId = new NodeId(nodeIdStr, NamespaceIndex),
BrowseName = new QualifiedName(displayName, NamespaceIndex),
DisplayName = new LocalizedText("en", displayName),
WriteMask = AttributeWriteMask.None,
UserWriteMask = AttributeWriteMask.None,
DataType = dataTypeId,
ValueRank = valueRank,
ArrayDimensions = null,
AccessLevel = AccessLevels.CurrentRead,
UserAccessLevel = AccessLevels.CurrentRead,
Historizing = false,
Value = initialValue,
StatusCode = StatusCodes.Good,
Timestamp = DateTime.UtcNow
};
parent.AddChild(v);
return v;
}
private static object? ParseValue(NodeId dataTypeId, string? raw)
{
if (raw == null) return null;
try
{
if (dataTypeId == DataTypeIds.Double)
return double.Parse(raw, System.Globalization.CultureInfo.InvariantCulture);
if (dataTypeId == DataTypeIds.Float)
return float.Parse(raw, System.Globalization.CultureInfo.InvariantCulture);
if (dataTypeId == DataTypeIds.Int32)
return int.Parse(raw, System.Globalization.CultureInfo.InvariantCulture);
if (dataTypeId == DataTypeIds.Int64)
return long.Parse(raw, System.Globalization.CultureInfo.InvariantCulture);
if (dataTypeId == DataTypeIds.Boolean)
return bool.Parse(raw);
if (dataTypeId == DataTypeIds.DateTime)
return DateTime.Parse(raw, System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.AdjustToUniversal);
}
catch { /* fallback to string */ }
return raw;
}
private static NodeId MapDataType(string? dt) => dt?.ToLowerInvariant() switch
{
"double" => DataTypeIds.Double,
"float" => DataTypeIds.Float,
"int32" => DataTypeIds.Int32,
"int64" => DataTypeIds.Int64,
"boolean" => DataTypeIds.Boolean,
"datetime" => DataTypeIds.DateTime,
_ => DataTypeIds.String
};
}

View File

@@ -0,0 +1,272 @@
using ExperionCrawler.Core.Application.Interfaces;
using ExperionCrawler.Core.Domain.Entities;
using ExperionCrawler.Infrastructure.Certificates;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Opc.Ua;
using Opc.Ua.Server;
namespace ExperionCrawler.Infrastructure.OpcUa;
// ── StandardServer 서브클래스 ─────────────────────────────────────────────────
/// <summary>커스텀 NodeManager를 주입한 StandardServer 파생 클래스.</summary>
internal sealed class ExperionStandardServer : StandardServer
{
internal ExperionOpcServerNodeManager? NodeManager { get; private set; }
protected override MasterNodeManager CreateMasterNodeManager(
IServerInternal server, ApplicationConfiguration configuration)
{
NodeManager = new ExperionOpcServerNodeManager(server, configuration);
return new MasterNodeManager(server, configuration, null, NodeManager);
}
protected override ServerProperties LoadServerProperties() => new()
{
ManufacturerName = "ExperionCrawler",
ProductName = "ExperionCrawler OPC UA Server",
ProductUri = "urn:ExperionCrawler:OpcUaServer",
SoftwareVersion = "1.0.0",
BuildNumber = "1",
BuildDate = DateTime.UtcNow
};
}
// ── OPC UA 서버 서비스 ────────────────────────────────────────────────────────
/// <summary>
/// ExperionCrawler OPC UA 서버 서비스.
/// IHostedService 와 IExperionOpcServerService 를 모두 구현한다.
/// - IHostedService.StartAsync : 자동 시작 플래그 파일이 있으면 서버 시작 (앱 재기동용)
/// - IHostedService.StopAsync : 앱 종료 — 플래그 파일 유지 (재기동 시 자동 재시작)
/// - StartServerAsync : UI 시작 버튼 — 서버 시작 + 플래그 파일 저장
/// - StopServerAsync : UI 중지 버튼 — 서버 중지 + 플래그 파일 삭제
/// </summary>
public class ExperionOpcServerService : IExperionOpcServerService, IHostedService, IDisposable
{
private readonly ILogger<ExperionOpcServerService> _logger;
private readonly IServiceScopeFactory _scopeFactory;
private readonly IConfiguration _configuration;
private ExperionStandardServer? _server;
private ExperionOpcServerNodeManager? _nodeManager;
private volatile bool _running;
private DateTime? _startedAt;
private string _endpointUrl = string.Empty;
private static readonly string FlagPath =
Path.GetFullPath("opcserver_autostart.json");
public ExperionOpcServerService(
ILogger<ExperionOpcServerService> logger,
IServiceScopeFactory scopeFactory,
IConfiguration configuration)
{
_logger = logger;
_scopeFactory = scopeFactory;
_configuration = configuration;
}
// ── IHostedService ────────────────────────────────────────────────────────
async Task IHostedService.StartAsync(CancellationToken ct)
{
if (!File.Exists(FlagPath)) return;
try
{
_logger.LogInformation("[OpcServer] 자동 시작 플래그 감지 — OPC UA 서버 자동 시작");
await StartInternalAsync(saveFlag: false, ct);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[OpcServer] 자동 시작 실패 — 무시하고 계속");
}
}
Task IHostedService.StopAsync(CancellationToken ct)
{
// 앱 종료 시: 서버 인스턴스 정리만, 플래그 파일은 유지 → 재기동 후 자동 시작
StopInternal(deleteFlag: false);
return Task.CompletedTask;
}
// ── IExperionOpcServerService ────────────────────────────────────────────
public async Task StartServerAsync()
{
await StartInternalAsync(saveFlag: true, CancellationToken.None);
}
public Task StopServerAsync()
{
// UI 중지 버튼: 플래그 삭제 → 재기동 시 자동 시작 안 함
StopInternal(deleteFlag: true);
return Task.CompletedTask;
}
public OpcServerStatus GetStatus()
{
int clientCount = 0;
try
{
clientCount = _server?.CurrentInstance?.SessionManager?.GetSessions()?.Count ?? 0;
}
catch { /* ignore */ }
return new OpcServerStatus(
_running, clientCount,
_nodeManager?.TagNodeCount ?? 0,
_endpointUrl, _startedAt);
}
public void UpdateNodeValue(string tagname, string? value, DateTime timestamp)
=> _nodeManager?.UpdateNodeValue(tagname, value, timestamp);
public void RebuildAddressSpace(IEnumerable<RealtimePoint> points)
=> _nodeManager?.RebuildAddressSpace(points);
// ── 내부 구현 ─────────────────────────────────────────────────────────────
private async Task StartInternalAsync(bool saveFlag, CancellationToken ct)
{
if (_running)
{
_logger.LogWarning("[OpcServer] 이미 실행 중입니다.");
return;
}
var config = BuildServerConfig();
_server = new ExperionStandardServer();
_server.Start(config);
_nodeManager = _server.NodeManager;
_running = true;
_startedAt = DateTime.UtcNow;
var port = _configuration.GetValue<int>("OpcUaServer:Port", 4841);
_endpointUrl = $"opc.tcp://0.0.0.0:{port}";
_logger.LogInformation("[OpcServer] 서버 시작: {Url}", _endpointUrl);
// DB에서 realtime 포인트 조회 후 주소 공간 구성
await RebuildFromDbAsync();
if (saveFlag)
{
try { await File.WriteAllTextAsync(FlagPath, "{}", ct); }
catch (Exception ex) { _logger.LogWarning(ex, "[OpcServer] 플래그 저장 실패 (무시)"); }
}
_nodeManager?.UpdateServerStatus("Running", _nodeManager.TagNodeCount);
}
private void StopInternal(bool deleteFlag)
{
if (!_running) return;
try
{
_nodeManager?.UpdateServerStatus("Stopped", 0);
_server?.Stop();
}
catch (Exception ex) { _logger.LogWarning(ex, "[OpcServer] 서버 Stop() 중 오류 (무시)"); }
_server = null;
_nodeManager = null;
_running = false;
_startedAt = null;
if (deleteFlag)
{
try { if (File.Exists(FlagPath)) File.Delete(FlagPath); }
catch (Exception ex) { _logger.LogWarning(ex, "[OpcServer] 플래그 삭제 실패 (무시)"); }
_logger.LogInformation("[OpcServer] 서버 중지 완료 (자동 재시작 플래그 삭제)");
}
else
{
_logger.LogInformation("[OpcServer] 서버 중지 완료 (앱 종료 — 자동 재시작 플래그 유지)");
}
}
private async Task RebuildFromDbAsync()
{
if (_nodeManager == null) return;
try
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
var points = (await db.GetRealtimePointsAsync()).ToList();
var dtMap = await db.GetRealtimeNodeDataTypesAsync();
_nodeManager.RebuildAddressSpace(points, dtMap);
_logger.LogInformation("[OpcServer] 주소 공간 구성: {Count}개 태그 노드", points.Count);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[OpcServer] 주소 공간 구성 실패 (무시)");
}
}
private ApplicationConfiguration BuildServerConfig()
{
var port = _configuration.GetValue<int>("OpcUaServer:Port", 4841);
var enableSec = _configuration.GetValue<bool>("OpcUaServer:EnableSecurity", false);
var allowAnon = _configuration.GetValue<bool>("OpcUaServer:AllowAnonymous", true);
// 기존 클라이언트 인증서 재사용 (ExperionCertificateService 불변)
var hostName = System.Net.Dns.GetHostName();
var cert = ExperionCertificateService.TryLoadCertificate(hostName);
var userTokenPolicies = new UserTokenPolicyCollection();
if (allowAnon)
userTokenPolicies.Add(new UserTokenPolicy(UserTokenType.Anonymous) { PolicyId = "Anonymous" });
var secPolicies = new ServerSecurityPolicyCollection
{
new() { SecurityMode = MessageSecurityMode.None, SecurityPolicyUri = SecurityPolicies.None }
};
if (enableSec)
{
secPolicies.Add(new() {
SecurityMode = MessageSecurityMode.SignAndEncrypt,
SecurityPolicyUri = SecurityPolicies.Basic256Sha256
});
userTokenPolicies.Add(new UserTokenPolicy(UserTokenType.UserName)
{ PolicyId = "UserName", SecurityPolicyUri = SecurityPolicies.Basic256Sha256 });
}
return new ApplicationConfiguration
{
ApplicationName = "ExperionCrawlerServer",
ApplicationType = ApplicationType.ClientAndServer,
ApplicationUri = $"urn:{hostName}:ExperionCrawlerServer",
SecurityConfiguration = new SecurityConfiguration
{
ApplicationCertificate = cert != null
? new CertificateIdentifier { Certificate = cert }
: new CertificateIdentifier(),
TrustedPeerCertificates = new CertificateTrustList { StoreType = "Directory", StorePath = Path.GetFullPath("pki/trusted") },
TrustedIssuerCertificates = new CertificateTrustList { StoreType = "Directory", StorePath = Path.GetFullPath("pki/issuers") },
RejectedCertificateStore = new CertificateTrustList { StoreType = "Directory", StorePath = Path.GetFullPath("pki/rejected") },
AutoAcceptUntrustedCertificates = true,
AddAppCertToTrustedStore = true
},
TransportQuotas = new TransportQuotas { OperationTimeout = 15_000 },
ServerConfiguration = new ServerConfiguration
{
BaseAddresses = new StringCollection { $"opc.tcp://0.0.0.0:{port}" },
SecurityPolicies = secPolicies,
UserTokenPolicies = userTokenPolicies,
MaxSessionCount = 100,
MaxSubscriptionCount = 500,
DiagnosticsEnabled = false
}
};
}
public void Dispose()
{
try { _server?.Stop(); } catch { /* ignore */ }
_server = null;
}
}

View File

@@ -22,6 +22,7 @@ public class ExperionRealtimeService : IExperionRealtimeService, IHostedService,
{ {
private readonly IServiceScopeFactory _scopeFactory; private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<ExperionRealtimeService> _logger; private readonly ILogger<ExperionRealtimeService> _logger;
private readonly IServiceProvider _sp;
private ISession? _session; private ISession? _session;
private Subscription? _subscription; private Subscription? _subscription;
@@ -33,6 +34,12 @@ public class ExperionRealtimeService : IExperionRealtimeService, IHostedService,
private readonly ConcurrentDictionary<string, (string? value, DateTime timestamp)> private readonly ConcurrentDictionary<string, (string? value, DateTime timestamp)>
_pendingUpdates = new(); _pendingUpdates = new();
// nodeId → RealtimePoint 매핑 (FlushLoop에서 tagname을 찾기 위해 사용)
private Dictionary<string, Core.Domain.Entities.RealtimePoint> _pointCache = new();
// OPC UA 서버 서비스 (순환 참조 방지를 위해 lazy resolve)
private IExperionOpcServerService? _opcServer;
private volatile bool _running; private volatile bool _running;
private int _subscribedCount; private int _subscribedCount;
private string _statusMsg = "중지됨"; private string _statusMsg = "중지됨";
@@ -44,10 +51,12 @@ public class ExperionRealtimeService : IExperionRealtimeService, IHostedService,
public ExperionRealtimeService( public ExperionRealtimeService(
IServiceScopeFactory scopeFactory, IServiceScopeFactory scopeFactory,
ILogger<ExperionRealtimeService> logger) ILogger<ExperionRealtimeService> logger,
IServiceProvider sp)
{ {
_scopeFactory = scopeFactory; _scopeFactory = scopeFactory;
_logger = logger; _logger = logger;
_sp = sp;
} }
// ── IHostedService ──────────────────────────────────────────────────────── // ── IHostedService ────────────────────────────────────────────────────────
@@ -283,6 +292,9 @@ public class ExperionRealtimeService : IExperionRealtimeService, IHostedService,
_session.AddSubscription(_subscription); _session.AddSubscription(_subscription);
_subscription.Create(); _subscription.Create();
// nodeId → RealtimePoint 캐시 빌드 (FlushLoop에서 tagname 조회용)
_pointCache = points.ToDictionary(p => p.NodeId, p => p);
_subscribedCount = points.Count; _subscribedCount = points.Count;
_running = true; _running = true;
_statusMsg = $"구독 중 ({_subscribedCount}개 포인트)"; _statusMsg = $"구독 중 ({_subscribedCount}개 포인트)";
@@ -342,6 +354,24 @@ public class ExperionRealtimeService : IExperionRealtimeService, IHostedService,
{ {
_logger.LogError(ex, "[Realtime] 배치 DB 업데이트 실패"); _logger.LogError(ex, "[Realtime] 배치 DB 업데이트 실패");
} }
// OPC UA 서버 노드 값 갱신 (lazy resolve — 순환 참조 방지)
try
{
_opcServer ??= _sp.GetService<IExperionOpcServerService>();
if (_opcServer?.GetStatus().Running == true)
{
foreach (var u in updates)
{
if (_pointCache.TryGetValue(u.NodeId, out var pt))
_opcServer.UpdateNodeValue(pt.TagName, u.Value, u.Timestamp);
}
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "[Realtime] OPC 서버 노드 값 갱신 실패 (무시)");
}
} }
private async Task CleanupSessionAsync() private async Task CleanupSessionAsync()

View File

@@ -401,6 +401,70 @@ public class ExperionHistoryController : ControllerBase
} }
} }
// ── OPC UA 서버 ───────────────────────────────────────────────────────────────
[ApiController]
[Route("api/opcserver")]
public class ExperionOpcServerController : ControllerBase
{
private readonly IExperionOpcServerService _svc;
private readonly IExperionDbService _db;
public ExperionOpcServerController(
IExperionOpcServerService svc, IExperionDbService db)
{
_svc = svc;
_db = db;
}
/// <summary>OPC UA 서버 상태 조회</summary>
[HttpGet("status")]
public IActionResult Status()
{
var s = _svc.GetStatus();
return Ok(new
{
running = s.Running,
connectedClientCount = s.ConnectedClientCount,
nodeCount = s.NodeCount,
endpointUrl = s.EndpointUrl,
startedAt = s.StartedAt
});
}
/// <summary>OPC UA 서버 시작 (자동 재시작 플래그 저장)</summary>
[HttpPost("start")]
public async Task<IActionResult> Start()
{
try
{
await _svc.StartServerAsync();
return Ok(new { success = true, message = "OPC UA 서버 시작 완료" });
}
catch (Exception ex)
{
return StatusCode(500, new { success = false, message = ex.Message });
}
}
/// <summary>OPC UA 서버 중지 (자동 재시작 플래그 삭제)</summary>
[HttpPost("stop")]
public async Task<IActionResult> Stop()
{
await _svc.StopServerAsync();
return Ok(new { success = true, message = "OPC UA 서버 중지 완료" });
}
/// <summary>주소 공간 재구성 (포인트빌더 빌드 후 호출)</summary>
[HttpPost("rebuild")]
public async Task<IActionResult> Rebuild()
{
var points = (await _db.GetRealtimePointsAsync()).ToList();
_svc.RebuildAddressSpace(points);
return Ok(new { success = true, nodeCount = points.Count });
}
}
// ── 노드맵 대시보드 ─────────────────────────────────────────────────────────── // ── 노드맵 대시보드 ───────────────────────────────────────────────────────────
[ApiController] [ApiController]

View File

@@ -16,7 +16,8 @@
<!-- OPC UA : 기존 버전 준수 --> <!-- OPC UA : 기존 버전 준수 -->
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Core" Version="1.5.378.134" /> <PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Core" Version="1.5.378.134" />
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" Version="1.5.378.134" /> <PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" Version="1.5.378.134" />
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Configuration" Version="1.5.378.134" /> <PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Configuration" Version="1.5.378.134" />
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Server" Version="1.5.378.134" />
<!-- CSV --> <!-- CSV -->
<PackageReference Include="CsvHelper" Version="33.0.1" /> <PackageReference Include="CsvHelper" Version="33.0.1" />
<!-- SQLite (Ubuntu 서버, 별도 DB 설치 불필요) --> <!-- SQLite (Ubuntu 서버, 별도 DB 설치 불필요) -->

View File

@@ -38,6 +38,13 @@ builder.Services.AddHostedService(
sp => sp.GetRequiredService<ExperionRealtimeService>()); sp => sp.GetRequiredService<ExperionRealtimeService>());
builder.Services.AddHostedService<ExperionHistoryService>(); builder.Services.AddHostedService<ExperionHistoryService>();
// ── OPC UA Server BackgroundService ──────────────────────────────────────────
builder.Services.AddSingleton<ExperionOpcServerService>();
builder.Services.AddSingleton<IExperionOpcServerService>(
sp => sp.GetRequiredService<ExperionOpcServerService>());
builder.Services.AddHostedService(
sp => sp.GetRequiredService<ExperionOpcServerService>());
// ── CORS ────────────────────────────────────────────────────────────────────── // ── CORS ──────────────────────────────────────────────────────────────────────
builder.Services.AddCors(opt => builder.Services.AddCors(opt =>
opt.AddDefaultPolicy(p => p.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader())); opt.AddDefaultPolicy(p => p.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader()));

View File

@@ -9,5 +9,12 @@
"AllowedHosts": "*", "AllowedHosts": "*",
"ConnectionStrings": { "ConnectionStrings": {
"DefaultConnection": "Host=localhost;Port=5432;Database=postgres;Username=postgres;Password=postgres;Trust Server Certificate=true" "DefaultConnection": "Host=localhost;Port=5432;Database=postgres;Username=postgres;Password=postgres;Trust Server Certificate=true"
},
"OpcUaServer": {
"Port": 4841,
"EnableSecurity": false,
"AllowAnonymous": true,
"AllowedUsernames": [ "opcuser" ],
"AllowedPasswords": [ "opcpass" ]
} }
} }

View File

@@ -15,6 +15,7 @@
"OPCFoundation.NetStandard.Opc.Ua.Client": "1.5.378.134", "OPCFoundation.NetStandard.Opc.Ua.Client": "1.5.378.134",
"OPCFoundation.NetStandard.Opc.Ua.Configuration": "1.5.378.134", "OPCFoundation.NetStandard.Opc.Ua.Configuration": "1.5.378.134",
"OPCFoundation.NetStandard.Opc.Ua.Core": "1.5.378.134", "OPCFoundation.NetStandard.Opc.Ua.Core": "1.5.378.134",
"OPCFoundation.NetStandard.Opc.Ua.Server": "1.5.378.134",
"Swashbuckle.AspNetCore": "6.8.1" "Swashbuckle.AspNetCore": "6.8.1"
}, },
"runtime": { "runtime": {
@@ -527,6 +528,18 @@
} }
} }
}, },
"OPCFoundation.NetStandard.Opc.Ua.Server/1.5.378.134": {
"dependencies": {
"OPCFoundation.NetStandard.Opc.Ua.Configuration": "1.5.378.134",
"OPCFoundation.NetStandard.Opc.Ua.Core": "1.5.378.134"
},
"runtime": {
"lib/net8.0/Opc.Ua.Server.dll": {
"assemblyVersion": "1.5.378.0",
"fileVersion": "1.5.378.134"
}
}
},
"OPCFoundation.NetStandard.Opc.Ua.Types/1.5.378.134": { "OPCFoundation.NetStandard.Opc.Ua.Types/1.5.378.134": {
"dependencies": { "dependencies": {
"Microsoft.Extensions.Logging.Abstractions": "10.0.2", "Microsoft.Extensions.Logging.Abstractions": "10.0.2",
@@ -955,6 +968,13 @@
"path": "opcfoundation.netstandard.opc.ua.security.certificates/1.5.378.134", "path": "opcfoundation.netstandard.opc.ua.security.certificates/1.5.378.134",
"hashPath": "opcfoundation.netstandard.opc.ua.security.certificates.1.5.378.134.nupkg.sha512" "hashPath": "opcfoundation.netstandard.opc.ua.security.certificates.1.5.378.134.nupkg.sha512"
}, },
"OPCFoundation.NetStandard.Opc.Ua.Server/1.5.378.134": {
"type": "package",
"serviceable": true,
"sha512": "sha512-qdM5DQwo42cBsW9V+5w4wHD/fDXwjBpbuFYEgXTm9zrB2Cy3dadIsmVDswV3oZWm45k6DwzMy1SB2+xzIK/ppQ==",
"path": "opcfoundation.netstandard.opc.ua.server/1.5.378.134",
"hashPath": "opcfoundation.netstandard.opc.ua.server.1.5.378.134.nupkg.sha512"
},
"OPCFoundation.NetStandard.Opc.Ua.Types/1.5.378.134": { "OPCFoundation.NetStandard.Opc.Ua.Types/1.5.378.134": {
"type": "package", "type": "package",
"serviceable": true, "serviceable": true,

Binary file not shown.

View File

@@ -9,5 +9,12 @@
"AllowedHosts": "*", "AllowedHosts": "*",
"ConnectionStrings": { "ConnectionStrings": {
"DefaultConnection": "Host=localhost;Port=5432;Database=postgres;Username=postgres;Password=postgres;Trust Server Certificate=true" "DefaultConnection": "Host=localhost;Port=5432;Database=postgres;Username=postgres;Password=postgres;Trust Server Certificate=true"
},
"OpcUaServer": {
"Port": 4841,
"EnableSecurity": false,
"AllowAnonymous": true,
"AllowedUsernames": [ "opcuser" ],
"AllowedPasswords": [ "opcpass" ]
} }
} }

View File

@@ -13,7 +13,7 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("ExperionCrawler")] [assembly: System.Reflection.AssemblyCompanyAttribute("ExperionCrawler")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")] [assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")] [assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+9325b13f2ba5302deb9c831c876cd4730f0b010a")] [assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+85e596d66b4d229d2c740ae81c0d0f0d1563b3b9")]
[assembly: System.Reflection.AssemblyProductAttribute("ExperionCrawler")] [assembly: System.Reflection.AssemblyProductAttribute("ExperionCrawler")]
[assembly: System.Reflection.AssemblyTitleAttribute("ExperionCrawler")] [assembly: System.Reflection.AssemblyTitleAttribute("ExperionCrawler")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")] [assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]

View File

@@ -1 +1 @@
bd79eb1a1cf349a385f21fccb4ac8e124a58078f4b477cd767336b50ef4a42df 921345a281005866fc86fed7ed49309ead5f6d514afdd2152545c464e2f7e902

View File

@@ -1 +1 @@
e033eed09e2f3d3d2e6ef61be0e2e69b055deb90173793eb30e2e1551c010c0a c9dbbb40f4de1e460d3292106c6091274466b431f64c11eec57755b60fcadc46

View File

@@ -124,3 +124,4 @@
/home/pacer/projects/ExperionCrawler/src/Web/obj/Debug/net8.0/linux-x64/ExperionCrawler.genruntimeconfig.cache /home/pacer/projects/ExperionCrawler/src/Web/obj/Debug/net8.0/linux-x64/ExperionCrawler.genruntimeconfig.cache
/home/pacer/projects/ExperionCrawler/src/Web/obj/Debug/net8.0/linux-x64/ref/ExperionCrawler.dll /home/pacer/projects/ExperionCrawler/src/Web/obj/Debug/net8.0/linux-x64/ref/ExperionCrawler.dll
/home/pacer/projects/ExperionCrawler/src/Web/bin/Debug/net8.0/linux-x64/realtime_autostart.json /home/pacer/projects/ExperionCrawler/src/Web/bin/Debug/net8.0/linux-x64/realtime_autostart.json
/home/pacer/projects/ExperionCrawler/src/Web/bin/Debug/net8.0/linux-x64/Opc.Ua.Server.dll

View File

@@ -64,6 +64,10 @@
"target": "Package", "target": "Package",
"version": "[1.5.378.134, )" "version": "[1.5.378.134, )"
}, },
"OPCFoundation.NetStandard.Opc.Ua.Server": {
"target": "Package",
"version": "[1.5.378.134, )"
},
"Swashbuckle.AspNetCore": { "Swashbuckle.AspNetCore": {
"target": "Package", "target": "Package",
"version": "[6.8.1, )" "version": "[6.8.1, )"

View File

@@ -730,6 +730,23 @@
} }
} }
}, },
"OPCFoundation.NetStandard.Opc.Ua.Server/1.5.378.134": {
"type": "package",
"dependencies": {
"OPCFoundation.NetStandard.Opc.Ua.Configuration": "1.5.378.134",
"OPCFoundation.NetStandard.Opc.Ua.Core": "1.5.378.134"
},
"compile": {
"lib/net8.0/Opc.Ua.Server.dll": {
"related": ".xml"
}
},
"runtime": {
"lib/net8.0/Opc.Ua.Server.dll": {
"related": ".xml"
}
}
},
"OPCFoundation.NetStandard.Opc.Ua.Types/1.5.378.134": { "OPCFoundation.NetStandard.Opc.Ua.Types/1.5.378.134": {
"type": "package", "type": "package",
"dependencies": { "dependencies": {
@@ -1837,6 +1854,23 @@
} }
} }
}, },
"OPCFoundation.NetStandard.Opc.Ua.Server/1.5.378.134": {
"type": "package",
"dependencies": {
"OPCFoundation.NetStandard.Opc.Ua.Configuration": "1.5.378.134",
"OPCFoundation.NetStandard.Opc.Ua.Core": "1.5.378.134"
},
"compile": {
"lib/net8.0/Opc.Ua.Server.dll": {
"related": ".xml"
}
},
"runtime": {
"lib/net8.0/Opc.Ua.Server.dll": {
"related": ".xml"
}
}
},
"OPCFoundation.NetStandard.Opc.Ua.Types/1.5.378.134": { "OPCFoundation.NetStandard.Opc.Ua.Types/1.5.378.134": {
"type": "package", "type": "package",
"dependencies": { "dependencies": {
@@ -3590,6 +3624,32 @@
"opcfoundation.netstandard.opc.ua.security.certificates.nuspec" "opcfoundation.netstandard.opc.ua.security.certificates.nuspec"
] ]
}, },
"OPCFoundation.NetStandard.Opc.Ua.Server/1.5.378.134": {
"sha512": "qdM5DQwo42cBsW9V+5w4wHD/fDXwjBpbuFYEgXTm9zrB2Cy3dadIsmVDswV3oZWm45k6DwzMy1SB2+xzIK/ppQ==",
"type": "package",
"path": "opcfoundation.netstandard.opc.ua.server/1.5.378.134",
"files": [
".nupkg.metadata",
".signature.p7s",
"LICENSE.txt",
"NugetREADME.md",
"images/logo.jpg",
"lib/net10.0/Opc.Ua.Server.dll",
"lib/net10.0/Opc.Ua.Server.xml",
"lib/net472/Opc.Ua.Server.dll",
"lib/net472/Opc.Ua.Server.xml",
"lib/net48/Opc.Ua.Server.dll",
"lib/net48/Opc.Ua.Server.xml",
"lib/net8.0/Opc.Ua.Server.dll",
"lib/net8.0/Opc.Ua.Server.xml",
"lib/net9.0/Opc.Ua.Server.dll",
"lib/net9.0/Opc.Ua.Server.xml",
"lib/netstandard2.1/Opc.Ua.Server.dll",
"lib/netstandard2.1/Opc.Ua.Server.xml",
"opcfoundation.netstandard.opc.ua.server.1.5.378.134.nupkg.sha512",
"opcfoundation.netstandard.opc.ua.server.nuspec"
]
},
"OPCFoundation.NetStandard.Opc.Ua.Types/1.5.378.134": { "OPCFoundation.NetStandard.Opc.Ua.Types/1.5.378.134": {
"sha512": "UYwgU4bvh2/Fy+cbtCvbQqqNFElTvAolRX8faj9JhceMWPXvbwEIF9R1VQI2MKFPkrfXM/ZbGegEanVaj9NkLQ==", "sha512": "UYwgU4bvh2/Fy+cbtCvbQqqNFElTvAolRX8faj9JhceMWPXvbwEIF9R1VQI2MKFPkrfXM/ZbGegEanVaj9NkLQ==",
"type": "package", "type": "package",
@@ -4220,6 +4280,7 @@
"OPCFoundation.NetStandard.Opc.Ua.Client >= 1.5.378.134", "OPCFoundation.NetStandard.Opc.Ua.Client >= 1.5.378.134",
"OPCFoundation.NetStandard.Opc.Ua.Configuration >= 1.5.378.134", "OPCFoundation.NetStandard.Opc.Ua.Configuration >= 1.5.378.134",
"OPCFoundation.NetStandard.Opc.Ua.Core >= 1.5.378.134", "OPCFoundation.NetStandard.Opc.Ua.Core >= 1.5.378.134",
"OPCFoundation.NetStandard.Opc.Ua.Server >= 1.5.378.134",
"Swashbuckle.AspNetCore >= 6.8.1" "Swashbuckle.AspNetCore >= 6.8.1"
] ]
}, },
@@ -4286,6 +4347,10 @@
"target": "Package", "target": "Package",
"version": "[1.5.378.134, )" "version": "[1.5.378.134, )"
}, },
"OPCFoundation.NetStandard.Opc.Ua.Server": {
"target": "Package",
"version": "[1.5.378.134, )"
},
"Swashbuckle.AspNetCore": { "Swashbuckle.AspNetCore": {
"target": "Package", "target": "Package",
"version": "[6.8.1, )" "version": "[6.8.1, )"

View File

@@ -1,6 +1,6 @@
{ {
"version": 2, "version": 2,
"dgSpecHash": "nZDnHnuoVKnh9LKns2Dd8yShLrOc7l/4OOVxd+kW/qMOuRfHF6YgJjgF+Gs9UD9qbqweE7z4+OC8zRHofd/tPg==", "dgSpecHash": "DvDBBf+ZftMQEZFrHiCN6PjT2qlTihoBAxhRRY0VH8ymmHnuSkQnt2z9twYiIXrWFiroBR5ZTiY60EFQ6yWzUg==",
"success": true, "success": true,
"projectFilePath": "/home/pacer/projects/ExperionCrawler/src/Web/ExperionCrawler.csproj", "projectFilePath": "/home/pacer/projects/ExperionCrawler/src/Web/ExperionCrawler.csproj",
"expectedPackageFiles": [ "expectedPackageFiles": [
@@ -38,6 +38,7 @@
"/home/pacer/.nuget/packages/opcfoundation.netstandard.opc.ua.configuration/1.5.378.134/opcfoundation.netstandard.opc.ua.configuration.1.5.378.134.nupkg.sha512", "/home/pacer/.nuget/packages/opcfoundation.netstandard.opc.ua.configuration/1.5.378.134/opcfoundation.netstandard.opc.ua.configuration.1.5.378.134.nupkg.sha512",
"/home/pacer/.nuget/packages/opcfoundation.netstandard.opc.ua.core/1.5.378.134/opcfoundation.netstandard.opc.ua.core.1.5.378.134.nupkg.sha512", "/home/pacer/.nuget/packages/opcfoundation.netstandard.opc.ua.core/1.5.378.134/opcfoundation.netstandard.opc.ua.core.1.5.378.134.nupkg.sha512",
"/home/pacer/.nuget/packages/opcfoundation.netstandard.opc.ua.security.certificates/1.5.378.134/opcfoundation.netstandard.opc.ua.security.certificates.1.5.378.134.nupkg.sha512", "/home/pacer/.nuget/packages/opcfoundation.netstandard.opc.ua.security.certificates/1.5.378.134/opcfoundation.netstandard.opc.ua.security.certificates.1.5.378.134.nupkg.sha512",
"/home/pacer/.nuget/packages/opcfoundation.netstandard.opc.ua.server/1.5.378.134/opcfoundation.netstandard.opc.ua.server.1.5.378.134.nupkg.sha512",
"/home/pacer/.nuget/packages/opcfoundation.netstandard.opc.ua.types/1.5.378.134/opcfoundation.netstandard.opc.ua.types.1.5.378.134.nupkg.sha512", "/home/pacer/.nuget/packages/opcfoundation.netstandard.opc.ua.types/1.5.378.134/opcfoundation.netstandard.opc.ua.types.1.5.378.134.nupkg.sha512",
"/home/pacer/.nuget/packages/swashbuckle.aspnetcore/6.8.1/swashbuckle.aspnetcore.6.8.1.nupkg.sha512", "/home/pacer/.nuget/packages/swashbuckle.aspnetcore/6.8.1/swashbuckle.aspnetcore.6.8.1.nupkg.sha512",
"/home/pacer/.nuget/packages/swashbuckle.aspnetcore.swagger/6.8.1/swashbuckle.aspnetcore.swagger.6.8.1.nupkg.sha512", "/home/pacer/.nuget/packages/swashbuckle.aspnetcore.swagger/6.8.1/swashbuckle.aspnetcore.swagger.6.8.1.nupkg.sha512",

View File

@@ -583,6 +583,22 @@ tr:last-child td { border-bottom: none; }
display: flex; justify-content: flex-end; gap: 6px; display: flex; justify-content: flex-end; gap: 6px;
} }
/* ── OPC UA 서버 탭 ──────────────────────────────────────── */
.srv-status-card {
background: var(--s2); border: 1px solid var(--bd);
border-radius: var(--r); padding: 18px 22px; margin-top: 8px;
}
.srv-status-row {
display: flex; align-items: center; gap: 10px; margin-bottom: 12px;
}
.srv-label { font-size: 18px; font-weight: 600; color: var(--t0); }
.srv-meta {
display: flex; flex-wrap: wrap; gap: 16px;
font-size: 13px; color: var(--t1);
}
.srv-meta span b { color: var(--t0); }
.dot.grn { background: var(--grn); box-shadow: 0 0 6px var(--grn); }
/* ── Utility ─────────────────────────────────────────────── */ /* ── Utility ─────────────────────────────────────────────── */
.hidden { display: none !important; } .hidden { display: none !important; }

View File

@@ -60,6 +60,11 @@
<span class="ni">07</span> <span class="ni">07</span>
<span class="nl">이력 조회</span> <span class="nl">이력 조회</span>
</li> </li>
<li class="nav-item" data-tab="opcsvr">
<span class="ni">08</span>
<span class="nl">OPC UA 서버</span>
<span class="nb" id="opcsvr-dot"></span>
</li>
</ul> </ul>
<div class="sb-foot"> <div class="sb-foot">
@@ -538,6 +543,37 @@
</main> </main>
</div> </div>
<!-- ══════════════════════════════════════════════════════
08 OPC UA 서버
═══════════════════════════════════════════════════════ -->
<section class="pane" id="pane-opcsvr">
<header class="pane-hdr">
<div>
<h1>OPC UA 서버</h1>
<p class="sub">ExperionCrawler를 OPC UA 서버로 동작시켜 외부 클라이언트에 실시간 값을 제공합니다.</p>
</div>
</header>
<!-- 상태 카드 -->
<div class="srv-status-card" id="srv-status-card">
<div class="srv-status-row">
<span class="dot" id="srv-dot"></span>
<span id="srv-status-txt" class="srv-label">상태 조회 중...</span>
</div>
<div class="srv-meta" id="srv-meta"></div>
</div>
<!-- 버튼 행 -->
<div class="row-btns" style="margin-top:12px">
<button class="btn-a" onclick="srvStart()">▶ 서버 시작</button>
<button class="btn-b" onclick="srvStop()">■ 서버 중지</button>
<button class="btn-b" onclick="srvRebuild()">↺ 주소공간 재구성</button>
<button class="btn-b" onclick="srvLoad()">↻ 상태 새로고침</button>
</div>
<div id="srv-log" class="log-box hidden" style="margin-top:16px"></div>
</section>
<!-- ── 날짜/시간 선택 팝업 ──────────────────────────────────── --> <!-- ── 날짜/시간 선택 팝업 ──────────────────────────────────── -->
<div id="dt-overlay" class="dt-overlay hidden" onclick="dtCancel()"></div> <div id="dt-overlay" class="dt-overlay hidden" onclick="dtCancel()"></div>
<div id="dt-popup" class="dt-popup hidden"> <div id="dt-popup" class="dt-popup hidden">

View File

@@ -9,6 +9,7 @@ document.querySelectorAll('.nav-item').forEach(item => {
// nm-dash: 탭 진입 시 API 호출 없음 — 조회 버튼으로만 동작 // nm-dash: 탭 진입 시 API 호출 없음 — 조회 버튼으로만 동작
// pb: 탭 진입 시 API 호출 없음 — ▼ 옵션 불러오기 버튼으로만 동작 // pb: 탭 진입 시 API 호출 없음 — ▼ 옵션 불러오기 버튼으로만 동작
// hist: 탭 진입 시 API 호출 없음 // hist: 탭 진입 시 API 호출 없음
if (tab === 'opcsvr') srvLoad();
}); });
}); });
@@ -877,5 +878,90 @@ function dtClose() {
_dtp.target = null; _dtp.target = null;
} }
/* ── OPC UA 서버 ─────────────────────────────────────────────── */
let _srvPollTimer = null;
async function srvLoad() {
try {
const s = await api('GET', '/api/opcserver/status');
_srvRender(s);
} catch (e) {
srvLog([{ c:'err', t:`상태 조회 실패: ${e.message}` }]);
}
}
async function srvStart() {
srvLog([{ c:'info', t:'OPC UA 서버 시작 중...' }]);
try {
const r = await api('POST', '/api/opcserver/start');
srvLog([{ c: r.success ? 'ok' : 'err', t: r.message }]);
await srvLoad();
_srvStartPoll();
} catch (e) {
srvLog([{ c:'err', t:`시작 실패: ${e.message}` }]);
}
}
async function srvStop() {
srvLog([{ c:'info', t:'OPC UA 서버 중지 중...' }]);
try {
const r = await api('POST', '/api/opcserver/stop');
srvLog([{ c: r.success ? 'ok' : 'err', t: r.message }]);
_srvStopPoll();
await srvLoad();
} catch (e) {
srvLog([{ c:'err', t:`중지 실패: ${e.message}` }]);
}
}
async function srvRebuild() {
srvLog([{ c:'info', t:'주소 공간 재구성 중...' }]);
try {
const r = await api('POST', '/api/opcserver/rebuild');
srvLog([{ c: r.success ? 'ok' : 'err', t: `재구성 완료 — ${r.nodeCount}개 노드` }]);
await srvLoad();
} catch (e) {
srvLog([{ c:'err', t:`재구성 실패: ${e.message}` }]);
}
}
function _srvRender(s) {
const dot = document.getElementById('srv-dot');
const txt = document.getElementById('srv-status-txt');
const meta = document.getElementById('srv-meta');
const ndot = document.getElementById('opcsvr-dot');
if (s.running) {
dot.className = 'dot grn';
txt.textContent = '● 실행 중';
if (ndot) ndot.className = 'nb grn';
} else {
dot.className = 'dot';
txt.textContent = '○ 중지됨';
if (ndot) ndot.className = 'nb';
}
const started = s.startedAt ? new Date(s.startedAt).toLocaleString('ko-KR') : '—';
meta.innerHTML =
`<span>엔드포인트: <b>${esc(s.endpointUrl || '—')}</b></span>` +
`<span>접속 클라이언트: <b>${s.connectedClientCount}</b></span>` +
`<span>노드 수: <b>${s.nodeCount}</b></span>` +
`<span>시작 시각: <b>${started}</b></span>`;
}
function srvLog(lines) {
log('srv-log', lines);
}
function _srvStartPoll() {
_srvStopPoll();
_srvPollTimer = setInterval(srvLoad, 5000);
}
function _srvStopPoll() {
if (_srvPollTimer) { clearInterval(_srvPollTimer); _srvPollTimer = null; }
}
/* ── 초기 실행 ───────────────────────────────────────────────── */ /* ── 초기 실행 ───────────────────────────────────────────────── */
certStatus(); certStatus();

37
todo.md
View File

@@ -31,3 +31,40 @@
# 4. HistoryTable의 웹페이지 추가 # 4. HistoryTable의 웹페이지 추가
- [x] 4.1 표시 테이블 컬럼은 드롭다운 으로 선택 , 한 테이블에 8개 까지 선택가능하게 - [x] 4.1 표시 테이블 컬럼은 드롭다운 으로 선택 , 한 테이블에 8개 까지 선택가능하게
- [x] 4.2 시작 시간과 종료 시간 선택 한 범위내에서만 테이블에 표시 - [x] 4.2 시작 시간과 종료 시간 선택 한 범위내에서만 테이블에 표시
# 5. OPC UA 서버 기능 (Phase 1) — 완료
- [x] 5.1 `OPCFoundation.NetStandard.Opc.Ua.Server` 패키지 추가
- [x] 5.2 `ExperionOpcServerNodeManager` — CustomNodeManager2 상속, Realtime 폴더에 태그별 변수 노드 생성
- [x] 5.3 `ExperionOpcServerService` — StandardServer 기반, IHostedService + IExperionOpcServerService
- [x] 5.4 FlushLoop 500ms 배치 후 OPC 서버 노드 값 동시 갱신 (DB 폴링 없음)
- [x] 5.5 포인트 NodeId → tagname 캐시 (`_pointCache`) 추가
- [x] 5.6 API: POST /api/opcserver/start·stop, GET /api/opcserver/status, POST /api/opcserver/rebuild
- [x] 5.7 웹 UI: 08 OPC UA 서버 탭 (상태 카드, 시작/중지/재구성 버튼, 5초 폴링)
- [x] 5.8 자동 재시작 플래그 `opcserver_autostart.json` (RealtimeService 패턴 동일)
- [x] 5.9 기존 클라이언트 인증서 재사용 (`ApplicationType.ClientAndServer`)
포트: 기본 4841 (4840은 Experion HS R530이 사용 가능)
# 6. OPC UA 서버 기능 (Phase 2)
- [ ] 6.1 **Historical Access (HA) 구현**
- `ExperionOpcServerNodeManager``IHistoricalDataAccess` 구현
- `ReadRaw()``QueryHistoryAsync()``history_table` 조회 후 반환
- 각 Realtime 노드에 `Historizing = true` 설정
- TimescaleDB가 이미 설치되어 있어 대용량 이력도 고성능 처리 가능
- [ ] 6.2 **포인트빌더 빌드 시 주소 공간 자동 재구성**
- `ExperionPointBuilderController``Build` 엔드포인트에서
`_opcServer.RebuildAddressSpace(points)` 자동 호출
- 현재는 UI에서 수동 "↺ 주소공간 재구성" 버튼으로만 동작
- [ ] 6.3 **Username/Password 인증 추가**
- `appsettings.json``AllowedUsernames` / `AllowedPasswords` 를 실제 검증 로직에 연결
- `ExperionOpcServerService.BuildServerConfig()`에 UserNameToken 유효성 검사기 등록
- [ ] 6.4 **보안 정책 활성화 (Basic256Sha256)**
- `appsettings.json`에서 `EnableSecurity: true`로 설정 시
SignAndEncrypt 엔드포인트 자동 활성화 (코드는 이미 구현됨)
- 클라이언트 인증서 신뢰 관리 UI 검토
- [ ] 6.5 **연결 클라이언트 목록 웹 UI**
- 접속 중인 클라이언트 IP, 세션 ID, 구독 수를 웹 UI에 표시
- `_server.CurrentInstance.SessionManager.GetSessions()` 활용