diff --git a/CLAUDE.md b/CLAUDE.md index 19a1a23..815a5c5 100644 --- a/CLAUDE.md +++ b/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/, , … (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()` | +| 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/ + ├── ns=2;s=tag_FIC101_PV + ├── + └── … +``` + +**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) #### 증상 diff --git a/src/Core/Application/Interfaces/IExperionServices.cs b/src/Core/Application/Interfaces/IExperionServices.cs index 0f8aacb..c2d9e43 100644 --- a/src/Core/Application/Interfaces/IExperionServices.cs +++ b/src/Core/Application/Interfaces/IExperionServices.cs @@ -69,6 +69,10 @@ public interface IExperionDbService Task> GetTagNamesAsync(); Task QueryHistoryAsync( IEnumerable tagNames, DateTime? from, DateTime? to, int limit); + + // ── OPC UA Server 지원 ──────────────────────────────────────────────────── + /// realtime_table × node_map_master 조인 → nodeId → dataType 사전 반환 + Task> GetRealtimeNodeDataTypesAsync(); } // ── Realtime Service ───────────────────────────────────────────────────────── @@ -87,6 +91,24 @@ public interface IExperionRealtimeService 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 points); +} + +public record OpcServerStatus( + bool Running, + int ConnectedClientCount, + int NodeCount, + string EndpointUrl, + DateTime? StartedAt); + // ── Status Code ────────────────────────────────────────────────────────────── public interface IExperionStatusCodeService diff --git a/src/Infrastructure/Database/ExperionDbContext.cs b/src/Infrastructure/Database/ExperionDbContext.cs index 39a17f8..6355325 100644 --- a/src/Infrastructure/Database/ExperionDbContext.cs +++ b/src/Infrastructure/Database/ExperionDbContext.cs @@ -375,4 +375,19 @@ public class ExperionDbService : IExperionDbService return new NodeMapQueryResult(total, items); } + + /// realtime_table × node_map_master 조인 → nodeId → dataType 사전 반환 + public async Task> 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); + } } diff --git a/src/Infrastructure/OpcUa/ExperionOpcServerNodeManager.cs b/src/Infrastructure/OpcUa/ExperionOpcServerNodeManager.cs new file mode 100644 index 0000000..edbdd07 --- /dev/null +++ b/src/Infrastructure/OpcUa/ExperionOpcServerNodeManager.cs @@ -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; + +/// +/// OPC UA 서버 주소 공간 관리자. +/// ns=2 아래에 ExperionCrawler/ServerInfo/* 와 ExperionCrawler/Realtime/* 노드를 생성한다. +/// +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 _tagNodes = new(); + + public ExperionOpcServerNodeManager( + IServerInternal server, + ApplicationConfiguration configuration) + : base(server, configuration, NamespaceUri) + { + } + + // ── CreateAddressSpace (서버 시작 시 1회 호출) ──────────────────────────── + + public override void CreateAddressSpace(IDictionary> 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 (포인트빌더 빌드 후 호출) ──────────────────────── + + /// realtime_table 의 포인트 목록으로 Realtime 폴더 노드를 재구성한다. + public void RebuildAddressSpace( + IEnumerable points, + IReadOnlyDictionary? 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); + } + } + } + + /// ServerInfo.Status / PointCount 업데이트 + 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>? 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(); + 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 + }; +} diff --git a/src/Infrastructure/OpcUa/ExperionOpcServerService.cs b/src/Infrastructure/OpcUa/ExperionOpcServerService.cs new file mode 100644 index 0000000..928c2ee --- /dev/null +++ b/src/Infrastructure/OpcUa/ExperionOpcServerService.cs @@ -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 서브클래스 ───────────────────────────────────────────────── + +/// 커스텀 NodeManager를 주입한 StandardServer 파생 클래스. +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 서버 서비스 ──────────────────────────────────────────────────────── + +/// +/// ExperionCrawler OPC UA 서버 서비스. +/// IHostedService 와 IExperionOpcServerService 를 모두 구현한다. +/// - IHostedService.StartAsync : 자동 시작 플래그 파일이 있으면 서버 시작 (앱 재기동용) +/// - IHostedService.StopAsync : 앱 종료 — 플래그 파일 유지 (재기동 시 자동 재시작) +/// - StartServerAsync : UI 시작 버튼 — 서버 시작 + 플래그 파일 저장 +/// - StopServerAsync : UI 중지 버튼 — 서버 중지 + 플래그 파일 삭제 +/// +public class ExperionOpcServerService : IExperionOpcServerService, IHostedService, IDisposable +{ + private readonly ILogger _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 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 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("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(); + 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("OpcUaServer:Port", 4841); + var enableSec = _configuration.GetValue("OpcUaServer:EnableSecurity", false); + var allowAnon = _configuration.GetValue("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; + } +} diff --git a/src/Infrastructure/OpcUa/ExperionRealtimeService.cs b/src/Infrastructure/OpcUa/ExperionRealtimeService.cs index e063566..e1d686d 100644 --- a/src/Infrastructure/OpcUa/ExperionRealtimeService.cs +++ b/src/Infrastructure/OpcUa/ExperionRealtimeService.cs @@ -22,6 +22,7 @@ public class ExperionRealtimeService : IExperionRealtimeService, IHostedService, { private readonly IServiceScopeFactory _scopeFactory; private readonly ILogger _logger; + private readonly IServiceProvider _sp; private ISession? _session; private Subscription? _subscription; @@ -33,6 +34,12 @@ public class ExperionRealtimeService : IExperionRealtimeService, IHostedService, private readonly ConcurrentDictionary _pendingUpdates = new(); + // nodeId → RealtimePoint 매핑 (FlushLoop에서 tagname을 찾기 위해 사용) + private Dictionary _pointCache = new(); + + // OPC UA 서버 서비스 (순환 참조 방지를 위해 lazy resolve) + private IExperionOpcServerService? _opcServer; + private volatile bool _running; private int _subscribedCount; private string _statusMsg = "중지됨"; @@ -44,10 +51,12 @@ public class ExperionRealtimeService : IExperionRealtimeService, IHostedService, public ExperionRealtimeService( IServiceScopeFactory scopeFactory, - ILogger logger) + ILogger logger, + IServiceProvider sp) { _scopeFactory = scopeFactory; _logger = logger; + _sp = sp; } // ── IHostedService ──────────────────────────────────────────────────────── @@ -283,6 +292,9 @@ public class ExperionRealtimeService : IExperionRealtimeService, IHostedService, _session.AddSubscription(_subscription); _subscription.Create(); + // nodeId → RealtimePoint 캐시 빌드 (FlushLoop에서 tagname 조회용) + _pointCache = points.ToDictionary(p => p.NodeId, p => p); + _subscribedCount = points.Count; _running = true; _statusMsg = $"구독 중 ({_subscribedCount}개 포인트)"; @@ -342,6 +354,24 @@ public class ExperionRealtimeService : IExperionRealtimeService, IHostedService, { _logger.LogError(ex, "[Realtime] 배치 DB 업데이트 실패"); } + + // OPC UA 서버 노드 값 갱신 (lazy resolve — 순환 참조 방지) + try + { + _opcServer ??= _sp.GetService(); + 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() diff --git a/src/Web/Controllers/ExperionControllers.cs b/src/Web/Controllers/ExperionControllers.cs index 5616719..3840968 100644 --- a/src/Web/Controllers/ExperionControllers.cs +++ b/src/Web/Controllers/ExperionControllers.cs @@ -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; + } + + /// OPC UA 서버 상태 조회 + [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 + }); + } + + /// OPC UA 서버 시작 (자동 재시작 플래그 저장) + [HttpPost("start")] + public async Task 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 }); + } + } + + /// OPC UA 서버 중지 (자동 재시작 플래그 삭제) + [HttpPost("stop")] + public async Task Stop() + { + await _svc.StopServerAsync(); + return Ok(new { success = true, message = "OPC UA 서버 중지 완료" }); + } + + /// 주소 공간 재구성 (포인트빌더 빌드 후 호출) + [HttpPost("rebuild")] + public async Task Rebuild() + { + var points = (await _db.GetRealtimePointsAsync()).ToList(); + _svc.RebuildAddressSpace(points); + return Ok(new { success = true, nodeCount = points.Count }); + } +} + // ── 노드맵 대시보드 ─────────────────────────────────────────────────────────── [ApiController] diff --git a/src/Web/ExperionCrawler.csproj b/src/Web/ExperionCrawler.csproj index 390c1b2..eb59b57 100644 --- a/src/Web/ExperionCrawler.csproj +++ b/src/Web/ExperionCrawler.csproj @@ -16,7 +16,8 @@ - + + diff --git a/src/Web/Program.cs b/src/Web/Program.cs index c5311fd..2243c56 100644 --- a/src/Web/Program.cs +++ b/src/Web/Program.cs @@ -38,6 +38,13 @@ builder.Services.AddHostedService( sp => sp.GetRequiredService()); builder.Services.AddHostedService(); +// ── OPC UA Server BackgroundService ────────────────────────────────────────── +builder.Services.AddSingleton(); +builder.Services.AddSingleton( + sp => sp.GetRequiredService()); +builder.Services.AddHostedService( + sp => sp.GetRequiredService()); + // ── CORS ────────────────────────────────────────────────────────────────────── builder.Services.AddCors(opt => opt.AddDefaultPolicy(p => p.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader())); diff --git a/src/Web/appsettings.json b/src/Web/appsettings.json index 0b0fdb5..43076c1 100644 --- a/src/Web/appsettings.json +++ b/src/Web/appsettings.json @@ -9,5 +9,12 @@ "AllowedHosts": "*", "ConnectionStrings": { "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" ] } } diff --git a/src/Web/bin/Debug/net8.0/linux-x64/ExperionCrawler.deps.json b/src/Web/bin/Debug/net8.0/linux-x64/ExperionCrawler.deps.json index dc20ee6..f697cfc 100644 --- a/src/Web/bin/Debug/net8.0/linux-x64/ExperionCrawler.deps.json +++ b/src/Web/bin/Debug/net8.0/linux-x64/ExperionCrawler.deps.json @@ -15,6 +15,7 @@ "OPCFoundation.NetStandard.Opc.Ua.Client": "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.Server": "1.5.378.134", "Swashbuckle.AspNetCore": "6.8.1" }, "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": { "dependencies": { "Microsoft.Extensions.Logging.Abstractions": "10.0.2", @@ -955,6 +968,13 @@ "path": "opcfoundation.netstandard.opc.ua.security.certificates/1.5.378.134", "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": { "type": "package", "serviceable": true, diff --git a/src/Web/bin/Debug/net8.0/linux-x64/ExperionCrawler.dll b/src/Web/bin/Debug/net8.0/linux-x64/ExperionCrawler.dll index 443b433..6be3e2b 100644 Binary files a/src/Web/bin/Debug/net8.0/linux-x64/ExperionCrawler.dll and b/src/Web/bin/Debug/net8.0/linux-x64/ExperionCrawler.dll differ diff --git a/src/Web/bin/Debug/net8.0/linux-x64/ExperionCrawler.pdb b/src/Web/bin/Debug/net8.0/linux-x64/ExperionCrawler.pdb index 8920044..2efc6f6 100644 Binary files a/src/Web/bin/Debug/net8.0/linux-x64/ExperionCrawler.pdb and b/src/Web/bin/Debug/net8.0/linux-x64/ExperionCrawler.pdb differ diff --git a/src/Web/bin/Debug/net8.0/linux-x64/Opc.Ua.Server.dll b/src/Web/bin/Debug/net8.0/linux-x64/Opc.Ua.Server.dll new file mode 100755 index 0000000..7562dd8 Binary files /dev/null and b/src/Web/bin/Debug/net8.0/linux-x64/Opc.Ua.Server.dll differ diff --git a/src/Web/bin/Debug/net8.0/linux-x64/appsettings.json b/src/Web/bin/Debug/net8.0/linux-x64/appsettings.json index 0b0fdb5..43076c1 100644 --- a/src/Web/bin/Debug/net8.0/linux-x64/appsettings.json +++ b/src/Web/bin/Debug/net8.0/linux-x64/appsettings.json @@ -9,5 +9,12 @@ "AllowedHosts": "*", "ConnectionStrings": { "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" ] } } diff --git a/src/Web/obj/Debug/net8.0/linux-x64/ExperionCrawler.AssemblyInfo.cs b/src/Web/obj/Debug/net8.0/linux-x64/ExperionCrawler.AssemblyInfo.cs index bc92224..be4081e 100644 --- a/src/Web/obj/Debug/net8.0/linux-x64/ExperionCrawler.AssemblyInfo.cs +++ b/src/Web/obj/Debug/net8.0/linux-x64/ExperionCrawler.AssemblyInfo.cs @@ -13,7 +13,7 @@ using System.Reflection; [assembly: System.Reflection.AssemblyCompanyAttribute("ExperionCrawler")] [assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")] [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.AssemblyTitleAttribute("ExperionCrawler")] [assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")] diff --git a/src/Web/obj/Debug/net8.0/linux-x64/ExperionCrawler.AssemblyInfoInputs.cache b/src/Web/obj/Debug/net8.0/linux-x64/ExperionCrawler.AssemblyInfoInputs.cache index 0312010..6b000cc 100644 --- a/src/Web/obj/Debug/net8.0/linux-x64/ExperionCrawler.AssemblyInfoInputs.cache +++ b/src/Web/obj/Debug/net8.0/linux-x64/ExperionCrawler.AssemblyInfoInputs.cache @@ -1 +1 @@ -bd79eb1a1cf349a385f21fccb4ac8e124a58078f4b477cd767336b50ef4a42df +921345a281005866fc86fed7ed49309ead5f6d514afdd2152545c464e2f7e902 diff --git a/src/Web/obj/Debug/net8.0/linux-x64/ExperionCrawler.assets.cache b/src/Web/obj/Debug/net8.0/linux-x64/ExperionCrawler.assets.cache index d5f6815..1ee9971 100644 Binary files a/src/Web/obj/Debug/net8.0/linux-x64/ExperionCrawler.assets.cache and b/src/Web/obj/Debug/net8.0/linux-x64/ExperionCrawler.assets.cache differ diff --git a/src/Web/obj/Debug/net8.0/linux-x64/ExperionCrawler.csproj.AssemblyReference.cache b/src/Web/obj/Debug/net8.0/linux-x64/ExperionCrawler.csproj.AssemblyReference.cache index d6d2b0d..74394f4 100644 Binary files a/src/Web/obj/Debug/net8.0/linux-x64/ExperionCrawler.csproj.AssemblyReference.cache and b/src/Web/obj/Debug/net8.0/linux-x64/ExperionCrawler.csproj.AssemblyReference.cache differ diff --git a/src/Web/obj/Debug/net8.0/linux-x64/ExperionCrawler.csproj.CoreCompileInputs.cache b/src/Web/obj/Debug/net8.0/linux-x64/ExperionCrawler.csproj.CoreCompileInputs.cache index 259ba25..443499f 100644 --- a/src/Web/obj/Debug/net8.0/linux-x64/ExperionCrawler.csproj.CoreCompileInputs.cache +++ b/src/Web/obj/Debug/net8.0/linux-x64/ExperionCrawler.csproj.CoreCompileInputs.cache @@ -1 +1 @@ -e033eed09e2f3d3d2e6ef61be0e2e69b055deb90173793eb30e2e1551c010c0a +c9dbbb40f4de1e460d3292106c6091274466b431f64c11eec57755b60fcadc46 diff --git a/src/Web/obj/Debug/net8.0/linux-x64/ExperionCrawler.csproj.FileListAbsolute.txt b/src/Web/obj/Debug/net8.0/linux-x64/ExperionCrawler.csproj.FileListAbsolute.txt index 7a43119..d5867f6 100644 --- a/src/Web/obj/Debug/net8.0/linux-x64/ExperionCrawler.csproj.FileListAbsolute.txt +++ b/src/Web/obj/Debug/net8.0/linux-x64/ExperionCrawler.csproj.FileListAbsolute.txt @@ -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/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/Opc.Ua.Server.dll diff --git a/src/Web/obj/Debug/net8.0/linux-x64/ExperionCrawler.dll b/src/Web/obj/Debug/net8.0/linux-x64/ExperionCrawler.dll index 443b433..6be3e2b 100644 Binary files a/src/Web/obj/Debug/net8.0/linux-x64/ExperionCrawler.dll and b/src/Web/obj/Debug/net8.0/linux-x64/ExperionCrawler.dll differ diff --git a/src/Web/obj/Debug/net8.0/linux-x64/ExperionCrawler.pdb b/src/Web/obj/Debug/net8.0/linux-x64/ExperionCrawler.pdb index 8920044..2efc6f6 100644 Binary files a/src/Web/obj/Debug/net8.0/linux-x64/ExperionCrawler.pdb and b/src/Web/obj/Debug/net8.0/linux-x64/ExperionCrawler.pdb differ diff --git a/src/Web/obj/Debug/net8.0/linux-x64/ref/ExperionCrawler.dll b/src/Web/obj/Debug/net8.0/linux-x64/ref/ExperionCrawler.dll index 9899e89..3027059 100644 Binary files a/src/Web/obj/Debug/net8.0/linux-x64/ref/ExperionCrawler.dll and b/src/Web/obj/Debug/net8.0/linux-x64/ref/ExperionCrawler.dll differ diff --git a/src/Web/obj/Debug/net8.0/linux-x64/refint/ExperionCrawler.dll b/src/Web/obj/Debug/net8.0/linux-x64/refint/ExperionCrawler.dll index 9899e89..3027059 100644 Binary files a/src/Web/obj/Debug/net8.0/linux-x64/refint/ExperionCrawler.dll and b/src/Web/obj/Debug/net8.0/linux-x64/refint/ExperionCrawler.dll differ diff --git a/src/Web/obj/ExperionCrawler.csproj.nuget.dgspec.json b/src/Web/obj/ExperionCrawler.csproj.nuget.dgspec.json index dd1b514..7b76125 100644 --- a/src/Web/obj/ExperionCrawler.csproj.nuget.dgspec.json +++ b/src/Web/obj/ExperionCrawler.csproj.nuget.dgspec.json @@ -64,6 +64,10 @@ "target": "Package", "version": "[1.5.378.134, )" }, + "OPCFoundation.NetStandard.Opc.Ua.Server": { + "target": "Package", + "version": "[1.5.378.134, )" + }, "Swashbuckle.AspNetCore": { "target": "Package", "version": "[6.8.1, )" diff --git a/src/Web/obj/project.assets.json b/src/Web/obj/project.assets.json index 78c798e..9694f45 100644 --- a/src/Web/obj/project.assets.json +++ b/src/Web/obj/project.assets.json @@ -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": { "type": "package", "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": { "type": "package", "dependencies": { @@ -3590,6 +3624,32 @@ "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": { "sha512": "UYwgU4bvh2/Fy+cbtCvbQqqNFElTvAolRX8faj9JhceMWPXvbwEIF9R1VQI2MKFPkrfXM/ZbGegEanVaj9NkLQ==", "type": "package", @@ -4220,6 +4280,7 @@ "OPCFoundation.NetStandard.Opc.Ua.Client >= 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.Server >= 1.5.378.134", "Swashbuckle.AspNetCore >= 6.8.1" ] }, @@ -4286,6 +4347,10 @@ "target": "Package", "version": "[1.5.378.134, )" }, + "OPCFoundation.NetStandard.Opc.Ua.Server": { + "target": "Package", + "version": "[1.5.378.134, )" + }, "Swashbuckle.AspNetCore": { "target": "Package", "version": "[6.8.1, )" diff --git a/src/Web/obj/project.nuget.cache b/src/Web/obj/project.nuget.cache index 9a607ee..8d839d9 100644 --- a/src/Web/obj/project.nuget.cache +++ b/src/Web/obj/project.nuget.cache @@ -1,6 +1,6 @@ { "version": 2, - "dgSpecHash": "nZDnHnuoVKnh9LKns2Dd8yShLrOc7l/4OOVxd+kW/qMOuRfHF6YgJjgF+Gs9UD9qbqweE7z4+OC8zRHofd/tPg==", + "dgSpecHash": "DvDBBf+ZftMQEZFrHiCN6PjT2qlTihoBAxhRRY0VH8ymmHnuSkQnt2z9twYiIXrWFiroBR5ZTiY60EFQ6yWzUg==", "success": true, "projectFilePath": "/home/pacer/projects/ExperionCrawler/src/Web/ExperionCrawler.csproj", "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.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.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/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", diff --git a/src/Web/wwwroot/css/style.css b/src/Web/wwwroot/css/style.css index a73454f..f1f02cd 100644 --- a/src/Web/wwwroot/css/style.css +++ b/src/Web/wwwroot/css/style.css @@ -583,6 +583,22 @@ tr:last-child td { border-bottom: none; } 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 ─────────────────────────────────────────────── */ .hidden { display: none !important; } diff --git a/src/Web/wwwroot/index.html b/src/Web/wwwroot/index.html index ffa355b..ed2dfe1 100644 --- a/src/Web/wwwroot/index.html +++ b/src/Web/wwwroot/index.html @@ -60,6 +60,11 @@ 07 이력 조회 +
@@ -538,6 +543,37 @@
+ +
+
+
+

OPC UA 서버

+

ExperionCrawler를 OPC UA 서버로 동작시켜 외부 클라이언트에 실시간 값을 제공합니다.

+
+
+ + +
+
+ + 상태 조회 중... +
+
+
+ + +
+ + + + +
+ + +
+