# 5. OPC UA 서버 기능 (Phase 1) — 완료
This commit is contained in:
87
CLAUDE.md
87
CLAUDE.md
@@ -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)
|
||||||
|
|
||||||
#### 증상
|
#### 증상
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
229
src/Infrastructure/OpcUa/ExperionOpcServerNodeManager.cs
Normal file
229
src/Infrastructure/OpcUa/ExperionOpcServerNodeManager.cs
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
272
src/Infrastructure/OpcUa/ExperionOpcServerService.cs
Normal file
272
src/Infrastructure/OpcUa/ExperionOpcServerService.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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 설치 불필요) -->
|
||||||
|
|||||||
@@ -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()));
|
||||||
|
|||||||
@@ -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" ]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
Binary file not shown.
BIN
src/Web/bin/Debug/net8.0/linux-x64/Opc.Ua.Server.dll
Executable file
BIN
src/Web/bin/Debug/net8.0/linux-x64/Opc.Ua.Server.dll
Executable file
Binary file not shown.
@@ -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" ]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")]
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
bd79eb1a1cf349a385f21fccb4ac8e124a58078f4b477cd767336b50ef4a42df
|
921345a281005866fc86fed7ed49309ead5f6d514afdd2152545c464e2f7e902
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -1 +1 @@
|
|||||||
e033eed09e2f3d3d2e6ef61be0e2e69b055deb90173793eb30e2e1551c010c0a
|
c9dbbb40f4de1e460d3292106c6091274466b431f64c11eec57755b60fcadc46
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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, )"
|
||||||
|
|||||||
@@ -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, )"
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
37
todo.md
@@ -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()` 활용
|
||||||
|
|||||||
Reference in New Issue
Block a user