diff --git a/CLAUDE.md b/CLAUDE.md index bbcf9df..19a1a23 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,6 +7,133 @@ ## 완료된 작업 +### 로그 정리 — 스냅샷 로그 2줄 → 1줄 (2026-04-15) + +#### 증상 +히스토리 스냅샷 1회 저장마다 터미널에 로그 2줄 출력: +``` +[ExperionDb] history 스냅샷: 1752건 @ 01:14:18 +[HistoryService] 스냅샷 저장: 1752건 +``` + +#### 원인 +DB 저장 완료 후 `ExperionDbService`에서 `LogInformation`, 호출자 `ExperionHistoryService`에서도 `LogInformation`. 저장은 1회이나 로그가 2줄. + +#### 수정 파일 + +| 파일 | 수정 내용 | +|------|----------| +| `src/Infrastructure/Database/ExperionDbContext.cs` | `SnapshotToHistoryAsync()` 내부 로그를 `LogInformation` → `LogDebug`로 변경 | + +#### 결과 +운영 로그(`Information` 레벨)에서 `[HistoryService] 스냅샷 저장: N건` 1줄만 출력. + +--- + +### 버그 수정 — Ctrl+C 종료 시 자동재시작 플래그 삭제 오류 (2026-04-15) + +#### 증상 +Ctrl+C로 앱 종료 시 `realtime_autostart.json` 플래그 파일이 삭제되어, 재기동 후 자동 구독 시작이 동작하지 않음. + +#### 원인 +`IHostedService.StopAsync(CancellationToken)` (앱 종료 훅)이 UI 수동 중지 메서드인 `StopAsync()`를 그대로 호출. `StopAsync()`는 플래그 파일을 삭제하므로 앱 종료와 수동 중지를 구분하지 못했음. + +#### 수정 파일 + +| 파일 | 수정 내용 | +|------|----------| +| `src/Infrastructure/OpcUa/ExperionRealtimeService.cs` | `IHostedService.StopAsync(CancellationToken)` 분리 — `_cts.Cancel()` + 태스크 대기만 수행, 플래그 파일 삭제 없음 | + +#### 동작 구분 + +| 종료 방식 | 플래그 파일 | +|----------|------------| +| Ctrl+C (앱 종료) | **유지** → 재기동 시 자동 구독 시작 | +| UI 중지 버튼 | **삭제** → 재기동 후 자동 시작 없음 | + +--- + +### 버그 수정 — 이력 조회 중복 키 예외 (2026-04-15) + +#### 증상 +이력 조회 시 서버 500 에러: +``` +System.ArgumentException: An item with the same key has already been added. +Key: p-6102.hzset.fieldvalue +at ExperionDbService.QueryHistoryAsync ... line 342 +``` + +#### 원인 +`history_table`에 동일 `recorded_at` + 동일 `tagname` 조합이 중복 저장된 행 존재. `.ToDictionary(r => r.TagName, r => r.Value)` 호출 시 중복 키로 예외 발생. + +#### 수정 파일 + +| 파일 | 수정 내용 | +|------|----------| +| `src/Infrastructure/Database/ExperionDbContext.cs` | `TagName` 기준 `GroupBy` 추가 → 중복 시 `.Last().Value` 사용 | + +--- + +### 기능 추가 — 이력 조회 날짜/시간 팝업 피커 (2026-04-15) + +#### 배경 +- `datetime-local` 입력이 Windows 브라우저 로케일에 따라 AM/PM 12시간제로 표시됨 +- 서버(Ubuntu UTC) / 브라우저(Windows KST) 시간대 차이로 인한 표시 혼란 + +#### 설계 +- `datetime-local` 입력 제거 → 클릭 시 커스텀 달력+시간 팝업 오픈 +- 달력: 월 이동 가능, 오늘 날짜 amber 강조, 선택일 반전 표시 +- 시간: 24시간제, `−`/`+` 버튼 또는 직접 입력 (0–23시, 0–59분) +- 확인 시 `YYYY-MM-DD HH:MM` 형식으로 필드 표시 +- hidden input에 로컬 시간 문자열 저장 → `new Date(...).toISOString()`으로 KST→UTC 변환 후 서버 전송 (기존 로직 유지) + +#### 수정 파일 + +| 파일 | 수정 내용 | +|------|----------| +| `src/Web/wwwroot/index.html` | `datetime-local` 2개 → `.dt-display` + `hidden input` 교체; 팝업 HTML(`#dt-popup`, `#dt-overlay`) 추가 | +| `src/Web/wwwroot/css/style.css` | `.dt-popup`, `.dt-cal-grid`, `.dt-day`, `.dt-time-row` 등 피커 전용 다크 테마 스타일 추가; 기존 `datetime-local` AM/PM 숨김 CSS 제거 | +| `src/Web/wwwroot/js/app.js` | `dtOpen()`, `dtRenderCal()`, `dtSelectDay()`, `dtPrevMonth()`, `dtNextMonth()`, `dtAdjTime()`, `dtClampTime()`, `dtConfirm()`, `dtClear()`, `dtClose()` 구현; `histReset()`에서 `dtClearField()` 호출로 표시 텍스트 초기화 | + +#### 빌드 결과 +- 경고 8건 (기존 동일), **에러 0건** — 빌드 성공 + +--- + +### 버그 수정 — 단일 태그 읽기 성공/실패 판정 오류 (2026-04-15) + +#### 증상 +서버접속테스트 페이지에서 단일 태그 읽기 시, OPC UA 서버가 `BadNodeIdUnknown(0x80340000)` 등 에러 상태 코드를 반환해도 "✅ 읽기 성공"으로 표시되는 버그. + +#### 원인 +`ExperionOpcClient.cs`의 `ReadTagsAsync` 내부에서 `StatusCode` 값과 무관하게 `Success = true`를 하드코딩해서 `ExperionReadResult`를 생성했음. + +#### 수정 파일 + +| 파일 | 수정 내용 | +|------|----------| +| `src/Infrastructure/OpcUa/ExperionOpcClient.cs` | `StatusCode.IsGood()` 결과를 `Success` 플래그로 사용. Bad이면 `Success=false`, `Value=null`, `Error`에 상태 코드 메시지 설정 | + +#### 결과 +`BadNodeIdUnknown` 등 Bad 상태 코드 수신 시 → ❌ 읽기 실패로 정상 표시 + +#### 빌드 결과 (경고 상세) +경고 8건, **에러 0건** — 빌드 성공 + +| # | 파일 | 내용 | +|---|------|------| +| 1 | `ExperionOpcClient.cs:108` | `Session.Create()` → `ISessionFactory.CreateAsync` 사용 권장 | +| 2 | `ExperionRealtimeService.cs:161` | `Subscription.ApplyChanges()` → `ApplyChangesAsync()` 사용 권장 | +| 3 | `ExperionRealtimeService.cs:168` | 동일 | +| 4 | `ExperionRealtimeService.cs:277` | `Subscription.Create()` → `CreateAsync()` 사용 권장 | +| 5 | `ExperionRealtimeService.cs:346` | `Subscription.Delete()` → `DeleteAsync()` 사용 권장 | +| 6 | `ExperionRealtimeService.cs:424` | `Session.Create()` → `ISessionFactory.CreateAsync` 사용 권장 | +| 7–8 | (위 항목 중 중복 카운트) | — | + +전부 OPC UA SDK가 동기 메서드를 `[Obsolete]`로 표시하고 비동기 버전을 권장하는 경고. 기능상 문제 없음. + +--- + ### 노드맵 대시보드 구현 (2026-04-14) node_map_master 테이블을 조회·탐색할 수 있는 웹 대시보드를 풀스택으로 구현했다. @@ -315,6 +442,27 @@ at ExperionRealtimeService.<b__0>d.MoveNext() --- +### TimescaleDB 관련 결정 사항 (2026-04-14) + +PostgreSQL에 TimescaleDB 확장이 설치되어 있음. + +#### 결론: 앱 코드 수정 불필요 + +TimescaleDB는 PostgreSQL **확장(extension)** 이므로: +- 연결 문자열: 기존 PostgreSQL 그대로 사용 +- EF Core / Npgsql 드라이버: 그대로 사용 +- `history_table` hypertable 전환은 DB에서 DDL 한 줄만 실행 + +```sql +SELECT create_hypertable('history_table', 'recorded_at'); +``` + +이 명령을 DB에서 한 번 실행하면 이후 INSERT/SELECT는 코드 변경 없이 TimescaleDB가 자동으로 시계열 최적화를 적용함. + +**DbContext, 엔티티, 컨트롤러 등 앱 코드는 전혀 수정 불필요.** + +--- + ## 구현 계획 (참고용) ### Task 1 — RealtimeTable + 포인트빌더 대시보드 diff --git a/opcServer.md b/opcServer.md new file mode 100644 index 0000000..39a2086 --- /dev/null +++ b/opcServer.md @@ -0,0 +1,413 @@ +# ExperionCrawler OPC UA 서버 기능 추가 계획 + +## 배경 및 목표 + +ExperionCrawler는 현재 **Experion HS R530 → ExperionCrawler** 방향의 OPC UA 클라이언트로만 동작한다. +목표는 ExperionCrawler가 동시에 **OPC UA 서버**로도 동작하여, 다른 OPC UA 클라이언트(Experion PKS, SCADA, MES 등)가 ExperionCrawler에 접속해 실시간 값을 읽어갈 수 있도록 하는 것이다. + +``` +[Experion HS R530] ──(OPC UA Client)──► ExperionCrawler ◄──(OPC UA Client)── [외부 시스템] + │ + (OPC UA Server) + │ + [PostgreSQL DB] +``` + +--- + +## 현재 구조 파악 + +### 사용 중인 OPC UA SDK +```xml +OPCFoundation.NetStandard.Opc.Ua.Core v1.5.378.134 +OPCFoundation.NetStandard.Opc.Ua.Client v1.5.378.134 +OPCFoundation.NetStandard.Opc.Ua.Configuration v1.5.378.134 +``` + +### 현재 실시간 데이터 흐름 +``` +Experion R530 + └── OPC UA Subscription (500ms sampling) + └── OnNotification callback + └── ConcurrentDictionary[nodeId] = latestValue + └── FlushLoopAsync (500ms) + └── BatchUpdateLiveValuesAsync → PostgreSQL realtime_table +``` + +### 노출 가능한 데이터 +| 데이터 | 출처 | 노드 수 | +|--------|------|---------| +| 실시간 livevalue | realtime_table | ~1,699개 | +| 이력 데이터 | history_table | 무제한 (TimescaleDB) | + +--- + +## 필요 패키지 추가 + +### 추가할 NuGet 패키지 +```xml + + +``` + +> **참고**: `OPCFoundation.NetStandard.Opc.Ua.Server`는 `StandardServer`, `CustomNodeManager2` +> 등 서버 구현에 필요한 클래스를 포함한다. Core/Client/Configuration 패키지와 버전을 맞춰야 한다. + +--- + +## 설계 결정 + +### OPC UA 서버 주소 공간 구조 +``` +Root +└── Objects + └── ExperionCrawler (FolderNode) + ├── ServerInfo (FolderNode) + │ ├── Status (Variable: String) -- "Running" / "Stopped" + │ ├── PointCount (Variable: Int32) -- 구독 중인 포인트 수 + │ └── LastUpdateTime (Variable: DateTime) + └── Realtime (FolderNode) + ├── (Variable: Double/String/…) NodeId: ns=2;s= + ├── (Variable: Double/String/…) + └── … +``` + +- **Namespace Index**: `ns=2` (ns=0: OPC UA 표준, ns=1: 예약) +- **NodeId 명명 규칙**: `ns=2;s={tagname}` (예: `ns=2;s=FIC101_PV`) +- **DataType**: realtime_table의 tagname에서 dataType을 추론 (`node_map_master` 조인) +- **Historical Access**: Phase 2에서 HA(HistoricalAccess) 인터페이스 추가 + +### 값 업데이트 방식 +`FlushLoopAsync`에서 DB 업데이트와 **동시에** OPC UA 서버 노드 값을 갱신한다. +별도 DB 폴링 없이 realtime_table 변경 즉시 OPC UA 클라이언트에게 전달된다. + +``` +ConcurrentDictionary[nodeId] = latestValue + └── FlushLoopAsync (500ms) + ├── BatchUpdateLiveValuesAsync → PostgreSQL + └── OpcServerNodeManager.UpdateNodeValue(tagname, value) ← 신규 +``` + +### 서버 포트 및 보안 +| 항목 | 기본값 | 비고 | +|------|--------|------| +| 엔드포인트 URL | `opc.tcp://0.0.0.0:4840` | 포트는 appsettings.json에서 변경 가능 | +| 보안 정책 | None + Basic256Sha256 | 두 가지 엔드포인트 동시 제공 | +| 인증 | Anonymous + Username/Password | appsettings.json 설정 | +| 서버 인증서 | `pki/own/certs/{hostname}.pfx` | 기존 클라이언트 인증서 **재사용** | + +--- + +## 구현 태스크 + +### Task 1 — NuGet 패키지 및 설정 추가 +**파일**: `src/Web/ExperionCrawler.csproj`, `appsettings.json` + +- [ ] `OPCFoundation.NetStandard.Opc.Ua.Server` 패키지 추가 +- [ ] `appsettings.json`에 서버 설정 섹션 추가: + ```json + "OpcUaServer": { + "Port": 4840, + "EnableSecurity": true, + "AllowAnonymous": true, + "AllowedUsernames": ["opcuser"], + "AllowedPasswords": ["opcpass"] + } + // ApplicationName/ApplicationUri는 기존 클라이언트 설정과 동일하게 사용 + // (인증서 재사용을 위해 ApplicationType.ClientAndServer로 전환) + ``` + +--- + +### Task 2 — 인터페이스 정의 +**파일**: `src/Core/Application/Interfaces/IExperionServices.cs` + +추가할 인터페이스: +```csharp +public interface IExperionOpcServerService +{ + Task StartAsync(CancellationToken ct = default); + Task StopAsync(CancellationToken ct = default); + OpcServerStatus GetStatus(); + void UpdateNodeValue(string tagname, string? value, DateTime timestamp); + void RebuildAddressSpace(IEnumerable points); +} + +public record OpcServerStatus( + bool Running, + int ConnectedClientCount, + int NodeCount, + string EndpointUrl, + DateTime? StartedAt +); +``` + +`IExperionRealtimeService`에 콜백 훅 추가: +```csharp +// FlushLoop 완료 후 서버에 값을 밀어넣을 수 있도록 +event Action>? OnBatchFlushed; +``` + +--- + +### Task 3 — OPC UA 서버 노드 매니저 구현 +**파일**: `src/Infrastructure/OpcUa/ExperionOpcServerNodeManager.cs` (신규) + +`CustomNodeManager2`를 상속받아 구현: + +``` +ExperionOpcServerNodeManager : CustomNodeManager2 + - CreateAddressSpace() → Realtime 폴더 + 태그별 VariableNode 생성 + - UpdateNodeValue() → BaseDataVariableState 값 + StatusCode + Timestamp 갱신 + - RebuildAddressSpace() → realtime_table 변경 시 노드 재구성 + - GetTagDataType() → tagname → OPC UA DataType(NodeId) 매핑 +``` + +핵심 구현 패턴: +```csharp +// 노드 생성 +var variable = new BaseDataVariableState(folderNode) +{ + NodeId = new NodeId($"ns=2;s={point.Tagname}"), + BrowseName = new QualifiedName(point.Tagname, NamespaceIndex), + DisplayName = point.Tagname, + DataType = DataTypeIds.Double, // tagname 기반으로 결정 + AccessLevel = AccessLevels.CurrentRead, + UserAccessLevel = AccessLevels.CurrentRead, + Historizing = false, + Value = null, + StatusCode = StatusCodes.BadNoData, + Timestamp = DateTime.UtcNow +}; + +// 값 업데이트 (FlushLoop에서 호출) +variable.Value = Convert.ToDouble(rawValue); +variable.Timestamp = timestamp; +variable.StatusCode = StatusCodes.Good; +variable.ClearChangeMasks(SystemContext, false); +``` + +--- + +### Task 4 — OPC UA 서버 서비스 구현 +**파일**: `src/Infrastructure/OpcUa/ExperionOpcServerService.cs` (신규) + +`BackgroundService` + `IExperionOpcServerService` 구현: + +``` +ExperionOpcServerService : BackgroundService, IExperionOpcServerService + - ExecuteAsync() → 서버 시작 루프 (오류 시 30초 후 재시도) + - StartAsync(CancellationToken) → StandardServer 인스턴스 시작 + - StopAsync(CancellationToken) → StandardServer 정지 + - UpdateNodeValue() → NodeManager에게 위임 + - RebuildAddressSpace() → NodeManager에게 위임 + - BuildServerConfigAsync() → ApplicationConfiguration 생성 (서버용) + - GetConnectedClientCount() → server.CurrentInstance.Sessions?.Count +``` + +`ApplicationConfiguration` 서버 모드: +```csharp +new ApplicationConfiguration +{ + ApplicationName = "ExperionCrawlerServer", + ApplicationType = ApplicationType.Server, // ← 핵심: Client → Server + ServerConfiguration = new ServerConfiguration + { + BaseAddresses = { $"opc.tcp://0.0.0.0:{port}" }, + SecurityPolicies = { /* None + Basic256Sha256 */ }, + UserTokenPolicies = { /* Anonymous + UserName */ }, + MaxSessionCount = 100, + MaxSubscriptionCount = 500, + } +} +``` + +--- + +### Task 5 — ExperionRealtimeService 연동 +**파일**: `src/Infrastructure/OpcUa/ExperionRealtimeService.cs` + +`FlushLoopAsync` 수정: +```csharp +// 기존 +await _dbService.BatchUpdateLiveValuesAsync(updates); + +// 변경 후 +await _dbService.BatchUpdateLiveValuesAsync(updates); + +// OPC UA 서버 노드 값 동기 갱신 +if (_opcServer?.GetStatus().Running == true) +{ + foreach (var u in updates) + { + var tagname = _pointCache.GetValueOrDefault(u.NodeId)?.Tagname; + if (tagname != null) + _opcServer.UpdateNodeValue(tagname, u.Value, u.Timestamp); + } +} +``` + +생성자 주입 추가: +```csharp +private readonly IExperionOpcServerService? _opcServer; +// Program.cs에서 순환 참조 방지를 위해 IServiceProvider로 lazy resolve 권장 +``` + +--- + +### Task 6 — API 컨트롤러 추가 +**파일**: `src/Web/Controllers/ExperionControllers.cs` + +`ExperionOpcServerController` 추가: +``` +POST /api/opcserver/start → 서버 시작 +POST /api/opcserver/stop → 서버 중지 +GET /api/opcserver/status → { running, clientCount, nodeCount, endpointUrl, startedAt } +POST /api/opcserver/rebuild → 주소 공간 재구성 (포인트 테이블 변경 후 호출) +``` + +--- + +### Task 7 — Program.cs 서비스 등록 +**파일**: `src/Web/Program.cs` + +```csharp +// OPC UA 서버 서비스 (Singleton + Hosted) +builder.Services.AddSingleton(); +builder.Services.AddSingleton( + sp => sp.GetRequiredService()); +builder.Services.AddHostedService( + sp => sp.GetRequiredService()); +``` + +--- + +### Task 8 — 웹 UI 추가 +**파일**: `src/Web/wwwroot/index.html`, `src/Web/wwwroot/js/app.js`, `src/Web/wwwroot/css/style.css` + +사이드바에 **08 OPC UA 서버** 탭 추가: +``` +┌─────────────────────────────────────────────────┐ +│ OPC UA 서버 │ +├─────────────────────────────────────────────────┤ +│ 상태: ● 실행 중 접속 클라이언트: 3 │ +│ 엔드포인트: opc.tcp://192.168.1.10:4840 │ +│ 노드 수: 1,699 시작 시각: 2026-04-14 09:00:00 │ +├─────────────────────────────────────────────────┤ +│ [▶ 서버 시작] [■ 서버 중지] [↺ 주소공간 재구성] │ +└─────────────────────────────────────────────────┘ +``` + +`app.js`에 `srvLoad()`, `srvStart()`, `srvStop()`, `srvRebuild()` 함수 구현 +상태 폴링 주기: 5초 (클라이언트 수 실시간 표시) + +--- + +### Task 9 — 자동 시작 플래그 +**파일**: `src/Infrastructure/OpcUa/ExperionOpcServerService.cs` + +앱 재기동 시 자동 서버 시작을 지원: +- 서버 시작 시 `opcserver_autostart.json` 저장 (기존 `realtime_autostart.json` 패턴 동일) +- `IHostedService.StartAsync`에서 파일 존재 시 자동 시작 +- 서버 중지 시 파일 삭제 + +--- + +## Phase 2 — Historical Access (HA) 확장 (선택적) + +OPC UA HA(Historical Data Access) 인터페이스를 추가하면 +클라이언트가 `ReadRaw`, `ReadProcessed` 서비스를 통해 과거 데이터를 직접 조회할 수 있다. + +### 추가 구현 사항 +1. `ExperionOpcServerNodeManager`에 `IHistoricalDataAccess` 구현 +2. `ReadRaw()` → `QueryHistoryAsync()` 호출 → history_table 조회 반환 +3. 각 노드의 `Historizing = true` 설정 +4. TimescaleDB가 이미 설치되어 있어 대용량 이력도 고성능 처리 가능 + +--- + +## 구현 시 주의사항 + +### 1. 순환 참조 방지 +`ExperionRealtimeService`가 `IExperionOpcServerService`를 직접 주입받으면 +두 Singleton이 서로를 참조하는 구조가 된다. +**해결**: `IServiceProvider`를 주입받아 `FlushLoop` 첫 실행 시 lazy resolve. + +```csharp +// 생성자 +private readonly IServiceProvider _sp; +private IExperionOpcServerService? _opcServer; + +// FlushLoopAsync 내부 +_opcServer ??= _sp.GetService(); +``` + +### 2. 인증서 재사용 — 별도 발급 불필요 +OPC UA 인증서는 "어느 서버에 접속하느냐"가 아니라 **"이 애플리케이션의 신원"** 이다. +ExperionCrawler가 클라이언트로 Honeywell에 접속할 때나, 서버로 외부에 노출될 때나 +모두 "ExperionCrawler 자신"이므로 **기존 `pki/own/certs/{hostname}.pfx` 그대로 재사용**한다. + +변경점은 `ApplicationType` 하나뿐이다: +```csharp +// 클라이언트 전용 (기존) +ApplicationType = ApplicationType.Client + +// 서버 기동 시 (겸용) +ApplicationType = ApplicationType.ClientAndServer +``` + +`ExperionCertificateService`는 **절대 수정하지 않는다** (완벽 동작 중, 메모리 참조). +`ExperionOpcServerService`의 `BuildServerConfigAsync()`에서 기존 인증서 경로를 그대로 참조한다. + +### 3. 포트 충돌 확인 +OPC UA 기본 포트 4840이 이미 Experion HS R530에 의해 사용 중일 수 있다. +appsettings.json에서 다른 포트(예: 4841)로 변경 가능하도록 설계한다. + +### 4. 주소 공간 재구성 타이밍 +포인트빌더에서 `BuildRealtimeTable` 실행 시 realtime_table이 재생성된다. +이 시점에 `RebuildAddressSpace()`를 자동 호출하거나, UI에서 수동 호출하도록 설계한다. +자동 연동 시: `ExperionPointBuilderController`의 `Build` 엔드포인트에서 `RebuildAddressSpace()` 호출 추가. + +### 5. 값 DataType 매핑 +OPC UA 서버는 노드 생성 시 DataType을 고정해야 한다. +realtime_table에는 dataType이 없으므로 `node_map_master`를 조인해서 가져와야 한다. +`RealtimePoint` 엔티티에 `DataType` 컬럼 추가가 필요할 수 있다. + +| node_map_master.data_type | OPC UA DataType | +|--------------------------|-----------------| +| Double | DataTypeIds.Double | +| Float | DataTypeIds.Float | +| Int32 | DataTypeIds.Int32 | +| Int64 | DataTypeIds.Int64 | +| Boolean | DataTypeIds.Boolean | +| String | DataTypeIds.String | +| DateTime | DataTypeIds.DateTime | +| 기타/NULL | DataTypeIds.String (fallback) | + +--- + +## 예상 작업량 요약 + +| Task | 파일 수 | 난이도 | 비고 | +|------|---------|--------|------| +| 1. 패키지/설정 | 2 | 낮음 | | +| 2. 인터페이스 | 1 | 낮음 | | +| 3. NodeManager | 1 (신규) | **높음** | OPC UA 주소 공간 설계 핵심 | +| 4. ServerService | 1 (신규) | **높음** | StandardServer 설정 복잡 | +| 5. Realtime 연동 | 1 | 중간 | lazy resolve 패턴 | +| 6. API 컨트롤러 | 1 | 낮음 | | +| 7. Program.cs | 1 | 낮음 | | +| 8. 웹 UI | 3 | 중간 | | +| 9. 자동 시작 | 1 | 낮음 | 기존 패턴 재사용 | + +**핵심 난관**: Task 3, 4 — OPC SDK의 `StandardServer` + `CustomNodeManager2` API는 +클라이언트 API보다 복잡하고 문서가 부족함. OPC Foundation 예제 코드(`UA-.NETStandard` GitHub) 참조 필수. + +--- + +## 참고 리소스 + +- OPC Foundation .NET Standard GitHub: `https://github.com/OPCFoundation/UA-.NETStandard` +- 예제: `Applications/ConsoleReferenceServer` — 최소 구현 서버 참조 구현 +- 예제: `Tests/Opc.Ua.Server.Tests` — 노드 매니저 테스트 코드 diff --git a/src/Infrastructure/Database/ExperionDbContext.cs b/src/Infrastructure/Database/ExperionDbContext.cs index 2dc7af3..39a17f8 100644 --- a/src/Infrastructure/Database/ExperionDbContext.cs +++ b/src/Infrastructure/Database/ExperionDbContext.cs @@ -315,7 +315,7 @@ public class ExperionDbService : IExperionDbService await _ctx.HistoryRecords.AddRangeAsync(rows); var saved = await _ctx.SaveChangesAsync(); - _logger.LogInformation("[ExperionDb] history 스냅샷: {Count}건 @ {Time:HH:mm:ss}", saved, now); + _logger.LogDebug("[ExperionDb] history 스냅샷: {Count}건 @ {Time:HH:mm:ss}", saved, now); return saved; } @@ -341,7 +341,8 @@ public class ExperionDbService : IExperionDbService .GroupBy(x => x.RecordedAt) .Select(g => new HistoryRow( g.Key, - g.ToDictionary(r => r.TagName, r => r.Value) + g.GroupBy(r => r.TagName) + .ToDictionary(tg => tg.Key, tg => tg.Last().Value) as IReadOnlyDictionary)) .ToList(); diff --git a/src/Infrastructure/OpcUa/ExperionOpcClient.cs b/src/Infrastructure/OpcUa/ExperionOpcClient.cs index eaccc0a..05decba 100644 --- a/src/Infrastructure/OpcUa/ExperionOpcClient.cs +++ b/src/Infrastructure/OpcUa/ExperionOpcClient.cs @@ -168,12 +168,14 @@ public class ExperionOpcClient : IExperionOpcClient var readResponse = await session.ReadAsync(null, 0, TimestampsToReturn.Both, new ReadValueIdCollection { nodeToRead }, CancellationToken.None); var dv = readResponse.Results[0]; - var statusStr = StatusCode.IsGood(dv.StatusCode) + bool isGood = StatusCode.IsGood(dv.StatusCode); + var statusStr = isGood ? "Good" : $"0x{(uint)dv.StatusCode:X8}"; + string? errorMsg = isGood ? null : $"OPC UA 상태 코드 오류: {statusStr}"; results.Add(new ExperionReadResult( - true, nodeId, dv.Value, statusStr, null, dv.SourceTimestamp)); + isGood, nodeId, isGood ? dv.Value : null, statusStr, errorMsg, dv.SourceTimestamp)); } catch (Exception ex) { diff --git a/src/Infrastructure/OpcUa/ExperionRealtimeService.cs b/src/Infrastructure/OpcUa/ExperionRealtimeService.cs index 2db0aab..e063566 100644 --- a/src/Infrastructure/OpcUa/ExperionRealtimeService.cs +++ b/src/Infrastructure/OpcUa/ExperionRealtimeService.cs @@ -74,7 +74,14 @@ public class ExperionRealtimeService : IExperionRealtimeService, IHostedService, public async Task StopAsync(CancellationToken cancellationToken) { - await StopAsync(); + // 앱 종료(Ctrl+C 등) 시: 플래그 파일은 유지 → 재기동 시 자동 재시작 + _cts?.Cancel(); + var tasks = new[] { _monitorTask, _flushTask } + .Where(t => t != null).Select(t => t!).ToArray(); + if (tasks.Length > 0) + await Task.WhenAll(tasks).WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + _running = false; + _logger.LogInformation("[Realtime] 구독 중지 완료 (앱 종료 — 자동 재시작 플래그 유지)"); } // ── IExperionRealtimeService ────────────────────────────────────────────── 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 ffe37b3..c829ce8 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 0f09471..4322af0 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/realtime_autostart.json b/src/Web/bin/Debug/net8.0/linux-x64/realtime_autostart.json new file mode 100644 index 0000000..37e00cb --- /dev/null +++ b/src/Web/bin/Debug/net8.0/linux-x64/realtime_autostart.json @@ -0,0 +1 @@ +{"Id":0,"ServerHostName":"192.168.0.20","Port":4840,"ClientHostName":"dbsvr","UserName":"mngr","Password":"mngr","EndpointUrl":"opc.tcp://192.168.0.20:4840","ApplicationUri":"urn:dbsvr:ExperionCrawlerClient"} \ No newline at end of file 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 bfd80a2..d91b60f 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+323aec34af7ca04f7d345dbb888bd9eb45bcde93")] +[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+68758f1bb83d32b4800bd97f3d70323be096b9d0")] [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 198480f..38c6cd7 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 @@ -bd5528d96831e1269e5461c65f271393ce4b10f88cb7442548dd757f5587c262 +259756df70d95a486eed7ec635a53d85c7b061c7dfe1903c04135250ae50f242 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 f845e79..7a43119 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 @@ -123,3 +123,4 @@ /home/pacer/projects/ExperionCrawler/src/Web/obj/Debug/net8.0/linux-x64/ExperionCrawler.pdb /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 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 ffe37b3..c829ce8 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 0f09471..4322af0 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 377197f..50973b8 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 377197f..50973b8 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/realtime_autostart.json b/src/Web/realtime_autostart.json new file mode 100644 index 0000000..37e00cb --- /dev/null +++ b/src/Web/realtime_autostart.json @@ -0,0 +1 @@ +{"Id":0,"ServerHostName":"192.168.0.20","Port":4840,"ClientHostName":"dbsvr","UserName":"mngr","Password":"mngr","EndpointUrl":"opc.tcp://192.168.0.20:4840","ApplicationUri":"urn:dbsvr:ExperionCrawlerClient"} \ No newline at end of file diff --git a/src/Web/wwwroot/css/style.css b/src/Web/wwwroot/css/style.css index b2bb991..a73454f 100644 --- a/src/Web/wwwroot/css/style.css +++ b/src/Web/wwwroot/css/style.css @@ -498,6 +498,91 @@ tr:last-child td { border-bottom: none; } .pb-name-grid { grid-template-columns: repeat(2, 1fr); } } +/* ── Custom DateTime Picker ──────────────────────────────── */ +.dt-display { + cursor: pointer; user-select: none; + color: var(--t0); display: flex; align-items: center; +} +.dt-display:hover { border-color: var(--a); } + +.dt-overlay { + position: fixed; inset: 0; z-index: 900; +} + +.dt-popup { + position: fixed; z-index: 901; + background: var(--s2); border: 1px solid var(--bd2); + border-radius: var(--rl); padding: 16px; + box-shadow: 0 8px 32px rgba(0,0,0,.6); + width: 280px; +} + +.dt-cal-nav { + display: flex; align-items: center; justify-content: space-between; + margin-bottom: 10px; +} +.dt-month-label { font-weight: 700; color: var(--t0); font-size: 14px; } +.dt-nav-btn { + background: var(--s3); border: 1px solid var(--bd); + color: var(--t1); border-radius: var(--r); + width: 28px; height: 28px; cursor: pointer; font-size: 16px; + display: flex; align-items: center; justify-content: center; + transition: all var(--tr); +} +.dt-nav-btn:hover { background: var(--s4); color: var(--t0); } + +.dt-cal-grid { + display: grid; grid-template-columns: repeat(7, 1fr); + gap: 2px; margin-bottom: 12px; +} +.dt-dow { + text-align: center; font-size: 11px; font-weight: 600; + color: var(--t2); padding: 4px 0; +} +.dt-day { + text-align: center; padding: 6px 2px; font-size: 12px; + border-radius: var(--r); cursor: pointer; color: var(--t1); + transition: background var(--tr); +} +.dt-day:hover { background: var(--s4); color: var(--t0); } +.dt-day.other-month { color: var(--t2); } +.dt-day.today { color: var(--a); font-weight: 700; } +.dt-day.selected { + background: var(--a); color: #000; font-weight: 700; +} +.dt-day.selected:hover { background: var(--a2); } + +.dt-time-row { + display: flex; align-items: center; gap: 8px; + padding: 10px 0; border-top: 1px solid var(--bd); + border-bottom: 1px solid var(--bd); margin-bottom: 12px; +} +.dt-time-label { color: var(--t2); font-size: 12px; flex: 1; } +.dt-time-sep { color: var(--t1); font-weight: 700; font-size: 16px; } +.dt-time-ctrl { + display: flex; align-items: center; gap: 4px; +} +.dt-time-ctrl button { + background: var(--s3); border: 1px solid var(--bd); + color: var(--t1); border-radius: var(--r); + width: 24px; height: 24px; cursor: pointer; font-size: 14px; + display: flex; align-items: center; justify-content: center; + transition: all var(--tr); +} +.dt-time-ctrl button:hover { background: var(--s4); color: var(--t0); } +.dt-time-inp { + width: 42px; text-align: center; + background: var(--s3); border: 1px solid var(--bd); + color: var(--t0); border-radius: var(--r); + padding: 3px 4px; font-size: 14px; font-family: var(--fm); +} +.dt-time-inp::-webkit-inner-spin-button, +.dt-time-inp::-webkit-outer-spin-button { -webkit-appearance: none; } + +.dt-pop-btns { + display: flex; justify-content: flex-end; gap: 6px; +} + /* ── Utility ─────────────────────────────────────────────── */ .hidden { display: none !important; } diff --git a/src/Web/wwwroot/index.html b/src/Web/wwwroot/index.html index 1cc191b..ffa355b 100644 --- a/src/Web/wwwroot/index.html +++ b/src/Web/wwwroot/index.html @@ -149,7 +149,7 @@
단일 태그 읽기
@@ -194,7 +194,7 @@
수집 노드 목록 (한 줄에 하나씩)
+ placeholder="ns=1;s=...">ns=1;s=sinamserver:p-6102.hzset.fieldvalue
@@ -430,7 +430,7 @@
수동 포인트 추가
- +
@@ -512,11 +512,13 @@
- + +
— 선택 안 함 —
- + +
— 선택 안 함 —
@@ -536,6 +538,36 @@
+ + + + diff --git a/src/Web/wwwroot/js/app.js b/src/Web/wwwroot/js/app.js index c104f06..d3ae8b2 100644 --- a/src/Web/wwwroot/js/app.js +++ b/src/Web/wwwroot/js/app.js @@ -748,12 +748,134 @@ async function histQuery() { function histReset() { HIST_TAG_IDS.forEach(id => { document.getElementById(id).value = ''; }); - document.getElementById('hf-from').value = ''; - document.getElementById('hf-to').value = ''; + dtClearField('from'); + dtClearField('to'); document.getElementById('hf-limit').value = '500'; document.getElementById('hist-result-info').classList.add('hidden'); document.getElementById('hist-table').classList.add('hidden'); } +/* ── Custom DateTime Picker ──────────────────────────────────── */ +const _dtp = { target: null, year: 0, month: 0, + selYear: null, selMonth: null, selDay: null, + selHour: 0, selMin: 0 }; + +const _DTP_DAYS = ['일','월','화','수','목','금','토']; + +function dtOpen(target) { + _dtp.target = target; + const hidden = document.getElementById(`hf-${target}`).value; + const d = hidden ? new Date(hidden) : new Date(); + _dtp.year = d.getFullYear(); + _dtp.month = d.getMonth(); + if (hidden && !isNaN(d)) { + _dtp.selYear = d.getFullYear(); _dtp.selMonth = d.getMonth(); + _dtp.selDay = d.getDate(); + _dtp.selHour = d.getHours(); _dtp.selMin = d.getMinutes(); + } else { + _dtp.selYear = null; _dtp.selMonth = null; _dtp.selDay = null; + _dtp.selHour = 0; _dtp.selMin = 0; + } + document.getElementById('dt-hour').value = String(_dtp.selHour).padStart(2,'0'); + document.getElementById('dt-min').value = String(_dtp.selMin).padStart(2,'0'); + dtRenderCal(); + + // 팝업 위치 계산 + const popup = document.getElementById('dt-popup'); + const display = document.getElementById(`dtp-${target}-display`); + const rect = display.getBoundingClientRect(); + popup.classList.remove('hidden'); + document.getElementById('dt-overlay').classList.remove('hidden'); + + // 화면 아래 공간 부족하면 위쪽으로 + const spaceBelow = window.innerHeight - rect.bottom; + if (spaceBelow < popup.offsetHeight + 8) { + popup.style.top = (rect.top - popup.offsetHeight - 4) + 'px'; + } else { + popup.style.top = (rect.bottom + 4) + 'px'; + } + popup.style.left = Math.min(rect.left, window.innerWidth - 296) + 'px'; +} + +function dtRenderCal() { + document.getElementById('dt-month-label').textContent = + `${_dtp.year}년 ${_dtp.month + 1}월`; + const first = new Date(_dtp.year, _dtp.month, 1).getDay(); + const daysInMon = new Date(_dtp.year, _dtp.month + 1, 0).getDate(); + const daysInPrev = new Date(_dtp.year, _dtp.month, 0).getDate(); + const today = new Date(); + let html = _DTP_DAYS.map(d => `
${d}
`).join(''); + + for (let i = first - 1; i >= 0; i--) + html += `
${daysInPrev - i}
`; + + for (let d = 1; d <= daysInMon; d++) { + let cls = 'dt-day'; + if (_dtp.year === today.getFullYear() && _dtp.month === today.getMonth() && d === today.getDate()) + cls += ' today'; + if (_dtp.selYear === _dtp.year && _dtp.selMonth === _dtp.month && _dtp.selDay === d) + cls += ' selected'; + html += `
${d}
`; + } + const trailing = (first + daysInMon) % 7; + for (let d = 1; d <= (trailing ? 7 - trailing : 0); d++) + html += `
${d}
`; + + document.getElementById('dt-cal-grid').innerHTML = html; +} + +function dtSelectDay(day) { + _dtp.selYear = _dtp.year; _dtp.selMonth = _dtp.month; _dtp.selDay = day; + dtRenderCal(); +} +function dtPrevMonth() { + if (--_dtp.month < 0) { _dtp.month = 11; _dtp.year--; } + dtRenderCal(); +} +function dtNextMonth() { + if (++_dtp.month > 11) { _dtp.month = 0; _dtp.year++; } + dtRenderCal(); +} +function dtAdjTime(part, delta) { + if (part === 'h') { + _dtp.selHour = ((_dtp.selHour + delta) + 24) % 24; + document.getElementById('dt-hour').value = String(_dtp.selHour).padStart(2,'0'); + } else { + _dtp.selMin = ((_dtp.selMin + delta) + 60) % 60; + document.getElementById('dt-min').value = String(_dtp.selMin).padStart(2,'0'); + } +} +function dtClampTime(part, el) { + const max = part === 'h' ? 23 : 59; + let v = parseInt(el.value); + if (isNaN(v) || v < 0) v = 0; + if (v > max) v = max; + el.value = String(v).padStart(2,'0'); + if (part === 'h') _dtp.selHour = v; else _dtp.selMin = v; +} +function dtConfirm() { + if (_dtp.selDay === null) { alert('날짜를 선택하세요.'); return; } + _dtp.selHour = parseInt(document.getElementById('dt-hour').value) || 0; + _dtp.selMin = parseInt(document.getElementById('dt-min').value) || 0; + const p = n => String(n).padStart(2,'0'); + const val = `${_dtp.selYear}-${p(_dtp.selMonth+1)}-${p(_dtp.selDay)}T${p(_dtp.selHour)}:${p(_dtp.selMin)}`; + document.getElementById(`hf-${_dtp.target}`).value = val; + document.getElementById(`dtp-${_dtp.target}-display`).textContent = + `${_dtp.selYear}-${p(_dtp.selMonth+1)}-${p(_dtp.selDay)} ${p(_dtp.selHour)}:${p(_dtp.selMin)}`; + dtClose(); +} +function dtClear() { dtClearField(_dtp.target); dtClose(); } +function dtClearField(target) { + if (!target) return; + document.getElementById(`hf-${target}`).value = ''; + document.getElementById(`dtp-${target}-display`).textContent = '— 선택 안 함 —'; +} +function dtCancel() { dtClose(); } +function dtClose() { + document.getElementById('dt-popup').classList.add('hidden'); + document.getElementById('dt-overlay').classList.add('hidden'); + _dtp.target = null; +} + /* ── 초기 실행 ───────────────────────────────────────────────── */ certStatus(); diff --git a/todo.md b/todo.md index 73be73b..a2c4cce 100644 --- a/todo.md +++ b/todo.md @@ -1,33 +1,33 @@ -# 1. Experion Server에서 데이터를 리얼타임으로 가져와서 저장하는 테이블 만들기 -- 1. RealtimeTable은 tagname, node_id, livevalue, timestamp 컬럼으로 구성되어야 함. -- 2. RealtimeTable node_map_master에서 조합 추출한다. +## 1. Experion Server에서 데이터를 리얼타임으로 가져와서 저장하는 테이블 만들기 +- [x] 1.1 RealtimeTable은 tagname, node_id, livevalue, timestamp 컬럼으로 구성되어야 함. +- [x] 1.2 RealtimeTable node_map_master에서 조합 추출한다. SELECT * FROM node_map_master WHERE name IN ('pv', 'sp', 'op', 'qv', 'qv.value', 'qv.fieldvalue') AND data_type = 'Double'; 을 데이터 베이스 레코드로 삽입 -- 3. tagname 컬럼은 2.항에서 추출된 레코드의 node_id 에서 오른쪽 끝에서 ':'문자를 만나기 전까지의 문자열로 채운다 (실제로 운전자가 사용하는 태그명이 된다.) +- [x] 1.3 tagname 컬럼은 2.항에서 추출된 레코드의 node_id 에서 오른쪽 끝에서 ':'문자를 만나기 전까지의 문자열로 채운다 (실제로 운전자가 사용하는 태그명이 된다.) -- 4. 웹페이지 :테이블 만들기 기능은 별도의 웹페이지 '포인트빌더' 대시보드를 추가하여 구현한다. -SELECT * -FROM node_map_master -WHERE name IN ('pv', 'sp', 'op', 'qv', 'qv.value', 'qv.fieldvalue') - AND data_type = 'Double';의 항목을 선택하는 드롭다운 메뉴 항목을 - name = 8개 - data_type = 2개 (data_type 드롭다운 항목은 노드맵대시보드 페이지의 '데이터 타입'항목 참조) -테이블 작성하기 버튼 +- [x] 1.4 웹페이지 :테이블 만들기 기능은 별도의 웹페이지 '포인트빌더' 대시보드를 추가하여 구현한다. + SELECT * + FROM node_map_master + WHERE name IN ('pv', 'sp', 'op', 'qv', 'qv.value', 'qv.fieldvalue') + AND data_type = 'Double';의 항목을 선택하는 드롭다운 메뉴 항목을 + name = 8개 + data_type = 2개 (data_type 드롭다운 항목은 노드맵대시보드 페이지의 '데이터 타입'항목 참조) + 테이블 작성하기 버튼 -- 5. node_id 를 직접입력하여 수동 추가 하는 항목도 만들어줘 +- [x] 1.5 node_id 를 직접입력하여 수동 추가 하는 항목도 만들어줘 -- 6. 약 2000여개의 데이터 이므로 테이블 구조 설계를 잘해야 함 +- [x] 1.6 약 2000여개의 데이터 이므로 테이블 구조 설계를 잘해야 함 # 2. 실시간 opcUA 서버 데이터 를 RealtimeTable 레코드의 livevalue 컬럼에 넣는 로직만들기 -- 1. opcUA 서버는 값이 변경되지 않으면 값을 주지 않는다, opcUA 통신 규약을 참조하여 실시간 데이터 업데이트 로직만들기 +- [x] 2.1 opcUA 서버는 값이 변경되지 않으면 값을 주지 않는다, opcUA 통신 규약을 참조하여 실시간 데이터 업데이트 로직만들기 # 3. HistoryTable 만들기 -- 1. 위의 RealtimeTable의 실시간 값을 정해진 시간마다 시계열 데이터로 저장하는 HistoryTable을 만들어서 레코드 기록하는 로직만들기 +- [x] 3.1 위의 RealtimeTable의 실시간 값을 정해진 시간마다 시계열 데이터로 저장하는 HistoryTable을 만들어서 레코드 기록하는 로직만들기 # 4. HistoryTable의 웹페이지 추가 -- 1. 표시 테이블 컬럼은 드롭다운 으로 선택 , 한 테이블에 8개 까지 선택가능하게 -- 2. 시작 시간과 종료 시간 선택 한 범위내에서만 테이블에 표시 +- [x] 4.1 표시 테이블 컬럼은 드롭다운 으로 선택 , 한 테이블에 8개 까지 선택가능하게 +- [x] 4.2 시작 시간과 종료 시간 선택 한 범위내에서만 테이블에 표시