운전시 버그 수정

This commit is contained in:
windpacer
2026-04-15 01:43:07 +00:00
parent 68758f1bb8
commit 9325b13f2b
20 changed files with 845 additions and 32 deletions

148
CLAUDE.md
View File

@@ -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시간제, ``/`+` 버튼 또는 직접 입력 (023시, 059분)
- 확인 시 `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` 사용 권장 |
| 78 | (위 항목 중 중복 카운트) | — |
전부 OPC UA SDK가 동기 메서드를 `[Obsolete]`로 표시하고 비동기 버전을 권장하는 경고. 기능상 문제 없음.
---
### 노드맵 대시보드 구현 (2026-04-14) ### 노드맵 대시보드 구현 (2026-04-14)
node_map_master 테이블을 조회·탐색할 수 있는 웹 대시보드를 풀스택으로 구현했다. node_map_master 테이블을 조회·탐색할 수 있는 웹 대시보드를 풀스택으로 구현했다.
@@ -315,6 +442,27 @@ at ExperionRealtimeService.<<OnNotification>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 + 포인트빌더 대시보드 ### Task 1 — RealtimeTable + 포인트빌더 대시보드

413
opcServer.md Normal file
View File

@@ -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
<!-- OPC UA 서버 기능 -->
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Server"
Version="1.5.378.134" />
```
> **참고**: `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)
├── <tagname_1> (Variable: Double/String/…) NodeId: ns=2;s=<tagname>
├── <tagname_2> (Variable: Double/String/…)
└── …
```
- **Namespace Index**: `ns=2` (ns=0: OPC UA 표준, ns=1: 예약)
- **NodeId 명명 규칙**: `ns=2;s={tagname}` (예: `ns=2;s=FIC101_PV`)
- **DataType**: realtime_table의 tagname에서 dataType을 추론 (`node_map_master` 조인)
- **Historical Access**: Phase 2에서 HA(HistoricalAccess) 인터페이스 추가
### 값 업데이트 방식
`FlushLoopAsync`에서 DB 업데이트와 **동시에** OPC UA 서버 노드 값을 갱신한다.
별도 DB 폴링 없이 realtime_table 변경 즉시 OPC UA 클라이언트에게 전달된다.
```
ConcurrentDictionary[nodeId] = latestValue
└── FlushLoopAsync (500ms)
├── BatchUpdateLiveValuesAsync → PostgreSQL
└── OpcServerNodeManager.UpdateNodeValue(tagname, value) ← 신규
```
### 서버 포트 및 보안
| 항목 | 기본값 | 비고 |
|------|--------|------|
| 엔드포인트 URL | `opc.tcp://0.0.0.0:4840` | 포트는 appsettings.json에서 변경 가능 |
| 보안 정책 | None + Basic256Sha256 | 두 가지 엔드포인트 동시 제공 |
| 인증 | Anonymous + Username/Password | appsettings.json 설정 |
| 서버 인증서 | `pki/own/certs/{hostname}.pfx` | 기존 클라이언트 인증서 **재사용** |
---
## 구현 태스크
### Task 1 — NuGet 패키지 및 설정 추가
**파일**: `src/Web/ExperionCrawler.csproj`, `appsettings.json`
- [ ] `OPCFoundation.NetStandard.Opc.Ua.Server` 패키지 추가
- [ ] `appsettings.json`에 서버 설정 섹션 추가:
```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<RealtimePoint> points);
}
public record OpcServerStatus(
bool Running,
int ConnectedClientCount,
int NodeCount,
string EndpointUrl,
DateTime? StartedAt
);
```
`IExperionRealtimeService`에 콜백 훅 추가:
```csharp
// FlushLoop 완료 후 서버에 값을 밀어넣을 수 있도록
event Action<IEnumerable<LiveValueUpdate>>? OnBatchFlushed;
```
---
### Task 3 — OPC UA 서버 노드 매니저 구현
**파일**: `src/Infrastructure/OpcUa/ExperionOpcServerNodeManager.cs` (신규)
`CustomNodeManager2`를 상속받아 구현:
```
ExperionOpcServerNodeManager : CustomNodeManager2
- CreateAddressSpace() → Realtime 폴더 + 태그별 VariableNode 생성
- UpdateNodeValue() → BaseDataVariableState 값 + StatusCode + Timestamp 갱신
- RebuildAddressSpace() → realtime_table 변경 시 노드 재구성
- GetTagDataType() → tagname → OPC UA DataType(NodeId) 매핑
```
핵심 구현 패턴:
```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<ExperionOpcServerService>();
builder.Services.AddSingleton<IExperionOpcServerService>(
sp => sp.GetRequiredService<ExperionOpcServerService>());
builder.Services.AddHostedService(
sp => sp.GetRequiredService<ExperionOpcServerService>());
```
---
### Task 8 — 웹 UI 추가
**파일**: `src/Web/wwwroot/index.html`, `src/Web/wwwroot/js/app.js`, `src/Web/wwwroot/css/style.css`
사이드바에 **08 OPC UA 서버** 탭 추가:
```
┌─────────────────────────────────────────────────┐
│ OPC UA 서버 │
├─────────────────────────────────────────────────┤
│ 상태: ● 실행 중 접속 클라이언트: 3 │
│ 엔드포인트: opc.tcp://192.168.1.10:4840 │
│ 노드 수: 1,699 시작 시각: 2026-04-14 09:00:00 │
├─────────────────────────────────────────────────┤
│ [▶ 서버 시작] [■ 서버 중지] [↺ 주소공간 재구성] │
└─────────────────────────────────────────────────┘
```
`app.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<IExperionOpcServerService>();
```
### 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` — 노드 매니저 테스트 코드

View File

@@ -315,7 +315,7 @@ public class ExperionDbService : IExperionDbService
await _ctx.HistoryRecords.AddRangeAsync(rows); await _ctx.HistoryRecords.AddRangeAsync(rows);
var saved = await _ctx.SaveChangesAsync(); 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; return saved;
} }
@@ -341,7 +341,8 @@ public class ExperionDbService : IExperionDbService
.GroupBy(x => x.RecordedAt) .GroupBy(x => x.RecordedAt)
.Select(g => new HistoryRow( .Select(g => new HistoryRow(
g.Key, 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<string, string?>)) as IReadOnlyDictionary<string, string?>))
.ToList(); .ToList();

View File

@@ -168,12 +168,14 @@ public class ExperionOpcClient : IExperionOpcClient
var readResponse = await session.ReadAsync(null, 0, TimestampsToReturn.Both, new ReadValueIdCollection { nodeToRead }, CancellationToken.None); var readResponse = await session.ReadAsync(null, 0, TimestampsToReturn.Both, new ReadValueIdCollection { nodeToRead }, CancellationToken.None);
var dv = readResponse.Results[0]; var dv = readResponse.Results[0];
var statusStr = StatusCode.IsGood(dv.StatusCode) bool isGood = StatusCode.IsGood(dv.StatusCode);
var statusStr = isGood
? "Good" ? "Good"
: $"0x{(uint)dv.StatusCode:X8}"; : $"0x{(uint)dv.StatusCode:X8}";
string? errorMsg = isGood ? null : $"OPC UA 상태 코드 오류: {statusStr}";
results.Add(new ExperionReadResult( 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) catch (Exception ex)
{ {

View File

@@ -74,7 +74,14 @@ public class ExperionRealtimeService : IExperionRealtimeService, IHostedService,
public async Task StopAsync(CancellationToken cancellationToken) 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 ────────────────────────────────────────────── // ── IExperionRealtimeService ──────────────────────────────────────────────

View File

@@ -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"}

View File

@@ -13,7 +13,7 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("ExperionCrawler")] [assembly: System.Reflection.AssemblyCompanyAttribute("ExperionCrawler")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")] [assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")] [assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+323aec34af7ca04f7d345dbb888bd9eb45bcde93")] [assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+68758f1bb83d32b4800bd97f3d70323be096b9d0")]
[assembly: System.Reflection.AssemblyProductAttribute("ExperionCrawler")] [assembly: System.Reflection.AssemblyProductAttribute("ExperionCrawler")]
[assembly: System.Reflection.AssemblyTitleAttribute("ExperionCrawler")] [assembly: System.Reflection.AssemblyTitleAttribute("ExperionCrawler")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")] [assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]

View File

@@ -1 +1 @@
bd5528d96831e1269e5461c65f271393ce4b10f88cb7442548dd757f5587c262 259756df70d95a486eed7ec635a53d85c7b061c7dfe1903c04135250ae50f242

View File

@@ -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.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/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

View File

@@ -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"}

View File

@@ -498,6 +498,91 @@ tr:last-child td { border-bottom: none; }
.pb-name-grid { grid-template-columns: repeat(2, 1fr); } .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 ─────────────────────────────────────────────── */ /* ── Utility ─────────────────────────────────────────────── */
.hidden { display: none !important; } .hidden { display: none !important; }

View File

@@ -149,7 +149,7 @@
<div class="card-cap">단일 태그 읽기</div> <div class="card-cap">단일 태그 읽기</div>
<div class="row-inp"> <div class="row-inp">
<input id="x-node" class="inp flex1" <input id="x-node" class="inp flex1"
value="ns=1;s=shinam:p-6102.hzset.fieldvalue" value="ns=1;s=sinamserver:p-6102.hzset.fieldvalue"
placeholder="ns=1;s=..."/> placeholder="ns=1;s=..."/>
<button class="btn-b" onclick="connRead()">읽기</button> <button class="btn-b" onclick="connRead()">읽기</button>
</div> </div>
@@ -194,7 +194,7 @@
<div class="card"> <div class="card">
<div class="card-cap">수집 노드 목록 <em>(한 줄에 하나씩)</em></div> <div class="card-cap">수집 노드 목록 <em>(한 줄에 하나씩)</em></div>
<textarea id="w-nodes" class="ta" rows="9" <textarea id="w-nodes" class="ta" rows="9"
placeholder="ns=1;s=...">ns=1;s=shinam:p-6102.hzset.fieldvalue</textarea> placeholder="ns=1;s=...">ns=1;s=sinamserver:p-6102.hzset.fieldvalue</textarea>
<button class="btn-a" id="crawl-btn" onclick="crawlStart()" <button class="btn-a" id="crawl-btn" onclick="crawlStart()"
style="margin-top:14px">📡 크롤링 시작</button> style="margin-top:14px">📡 크롤링 시작</button>
</div> </div>
@@ -430,7 +430,7 @@
<div class="card-cap">수동 포인트 추가</div> <div class="card-cap">수동 포인트 추가</div>
<div class="fg"> <div class="fg">
<label>Node ID 직접 입력</label> <label>Node ID 직접 입력</label>
<input id="pb-manual-nid" class="inp" placeholder="ns=2;s=Honeywell.Experion..."/> <input id="pb-manual-nid" class="inp" placeholder="ns=1;s=tagname.pv..."/>
</div> </div>
<button class="btn-b" onclick="pbAddManual()"> 추가</button> <button class="btn-b" onclick="pbAddManual()"> 추가</button>
<div id="pb-manual-log" class="logbox hidden" style="margin-top:10px"></div> <div id="pb-manual-log" class="logbox hidden" style="margin-top:10px"></div>
@@ -512,11 +512,13 @@
<div class="cols-3"> <div class="cols-3">
<div class="fg"> <div class="fg">
<label>시작 시간</label> <label>시작 시간</label>
<input id="hf-from" class="inp" type="datetime-local"/> <input type="hidden" id="hf-from"/>
<div class="dt-display inp" id="dtp-from-display" onclick="dtOpen('from')">— 선택 안 함 —</div>
</div> </div>
<div class="fg"> <div class="fg">
<label>종료 시간</label> <label>종료 시간</label>
<input id="hf-to" class="inp" type="datetime-local"/> <input type="hidden" id="hf-to"/>
<div class="dt-display inp" id="dtp-to-display" onclick="dtOpen('to')">— 선택 안 함 —</div>
</div> </div>
<div class="fg"> <div class="fg">
<label>최대 행 수</label> <label>최대 행 수</label>
@@ -536,6 +538,36 @@
</main> </main>
</div> </div>
<!-- ── 날짜/시간 선택 팝업 ──────────────────────────────────── -->
<div id="dt-overlay" class="dt-overlay hidden" onclick="dtCancel()"></div>
<div id="dt-popup" class="dt-popup hidden">
<div class="dt-cal-nav">
<button class="dt-nav-btn" onclick="dtPrevMonth()"></button>
<span id="dt-month-label" class="dt-month-label"></span>
<button class="dt-nav-btn" onclick="dtNextMonth()"></button>
</div>
<div class="dt-cal-grid" id="dt-cal-grid"></div>
<div class="dt-time-row">
<span class="dt-time-label">시간</span>
<div class="dt-time-ctrl">
<button onclick="dtAdjTime('h',-1)"></button>
<input id="dt-hour" class="dt-time-inp" type="number" min="0" max="23" value="0" oninput="dtClampTime('h',this)"/>
<button onclick="dtAdjTime('h', 1)">+</button>
</div>
<span class="dt-time-sep">:</span>
<div class="dt-time-ctrl">
<button onclick="dtAdjTime('m',-1)"></button>
<input id="dt-min" class="dt-time-inp" type="number" min="0" max="59" value="0" oninput="dtClampTime('m',this)"/>
<button onclick="dtAdjTime('m', 1)">+</button>
</div>
</div>
<div class="dt-pop-btns">
<button class="btn-b btn-sm" onclick="dtClear()">지우기</button>
<button class="btn-b btn-sm" onclick="dtCancel()">취소</button>
<button class="btn-a btn-sm" onclick="dtConfirm()">확인</button>
</div>
</div>
<script src="/js/app.js"></script> <script src="/js/app.js"></script>
</body> </body>
</html> </html>

View File

@@ -748,12 +748,134 @@ async function histQuery() {
function histReset() { function histReset() {
HIST_TAG_IDS.forEach(id => { document.getElementById(id).value = ''; }); HIST_TAG_IDS.forEach(id => { document.getElementById(id).value = ''; });
document.getElementById('hf-from').value = ''; dtClearField('from');
document.getElementById('hf-to').value = ''; dtClearField('to');
document.getElementById('hf-limit').value = '500'; document.getElementById('hf-limit').value = '500';
document.getElementById('hist-result-info').classList.add('hidden'); document.getElementById('hist-result-info').classList.add('hidden');
document.getElementById('hist-table').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 => `<div class="dt-dow">${d}</div>`).join('');
for (let i = first - 1; i >= 0; i--)
html += `<div class="dt-day other-month">${daysInPrev - i}</div>`;
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 += `<div class="${cls}" onclick="dtSelectDay(${d})">${d}</div>`;
}
const trailing = (first + daysInMon) % 7;
for (let d = 1; d <= (trailing ? 7 - trailing : 0); d++)
html += `<div class="dt-day other-month">${d}</div>`;
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(); certStatus();

36
todo.md
View File

@@ -1,33 +1,33 @@
# 1. Experion Server에서 데이터를 리얼타임으로 가져와서 저장하는 테이블 만들기 ## 1. Experion Server에서 데이터를 리얼타임으로 가져와서 저장하는 테이블 만들기
- 1. RealtimeTable은 tagname, node_id, livevalue, timestamp 컬럼으로 구성되어야 함. - [x] 1.1 RealtimeTable은 tagname, node_id, livevalue, timestamp 컬럼으로 구성되어야 함.
- 2. RealtimeTable node_map_master에서 조합 추출한다. - [x] 1.2 RealtimeTable node_map_master에서 조합 추출한다.
SELECT * SELECT *
FROM node_map_master FROM node_map_master
WHERE name IN ('pv', 'sp', 'op', 'qv', 'qv.value', 'qv.fieldvalue') WHERE name IN ('pv', 'sp', 'op', 'qv', 'qv.value', 'qv.fieldvalue')
AND data_type = 'Double'; AND data_type = 'Double';
을 데이터 베이스 레코드로 삽입 을 데이터 베이스 레코드로 삽입
- 3. tagname 컬럼은 2.항에서 추출된 레코드의 node_id 에서 오른쪽 끝에서 ':'문자를 만나기 전까지의 문자열로 채운다 (실제로 운전자가 사용하는 태그명이 된다.) - [x] 1.3 tagname 컬럼은 2.항에서 추출된 레코드의 node_id 에서 오른쪽 끝에서 ':'문자를 만나기 전까지의 문자열로 채운다 (실제로 운전자가 사용하는 태그명이 된다.)
- 4. 웹페이지 :테이블 만들기 기능은 별도의 웹페이지 '포인트빌더' 대시보드를 추가하여 구현한다. - [x] 1.4 웹페이지 :테이블 만들기 기능은 별도의 웹페이지 '포인트빌더' 대시보드를 추가하여 구현한다.
SELECT * SELECT *
FROM node_map_master FROM node_map_master
WHERE name IN ('pv', 'sp', 'op', 'qv', 'qv.value', 'qv.fieldvalue') WHERE name IN ('pv', 'sp', 'op', 'qv', 'qv.value', 'qv.fieldvalue')
AND data_type = 'Double';의 항목을 선택하는 드롭다운 메뉴 항목을 AND data_type = 'Double';의 항목을 선택하는 드롭다운 메뉴 항목을
name = 8개 name = 8개
data_type = 2개 (data_type 드롭다운 항목은 노드맵대시보드 페이지의 '데이터 타입'항목 참조) data_type = 2개 (data_type 드롭다운 항목은 노드맵대시보드 페이지의 '데이터 타입'항목 참조)
테이블 작성하기 버튼 테이블 작성하기 버튼
- 5. node_id 를 직접입력하여 수동 추가 하는 항목도 만들어줘 - [x] 1.5 node_id 를 직접입력하여 수동 추가 하는 항목도 만들어줘
- 6. 약 2000여개의 데이터 이므로 테이블 구조 설계를 잘해야 함 - [x] 1.6 약 2000여개의 데이터 이므로 테이블 구조 설계를 잘해야 함
# 2. 실시간 opcUA 서버 데이터 를 RealtimeTable 레코드의 livevalue 컬럼에 넣는 로직만들기 # 2. 실시간 opcUA 서버 데이터 를 RealtimeTable 레코드의 livevalue 컬럼에 넣는 로직만들기
- 1. opcUA 서버는 값이 변경되지 않으면 값을 주지 않는다, opcUA 통신 규약을 참조하여 실시간 데이터 업데이트 로직만들기 - [x] 2.1 opcUA 서버는 값이 변경되지 않으면 값을 주지 않는다, opcUA 통신 규약을 참조하여 실시간 데이터 업데이트 로직만들기
# 3. HistoryTable 만들기 # 3. HistoryTable 만들기
- 1. 위의 RealtimeTable의 실시간 값을 정해진 시간마다 시계열 데이터로 저장하는 HistoryTable을 만들어서 레코드 기록하는 로직만들기 - [x] 3.1 위의 RealtimeTable의 실시간 값을 정해진 시간마다 시계열 데이터로 저장하는 HistoryTable을 만들어서 레코드 기록하는 로직만들기
# 4. HistoryTable의 웹페이지 추가 # 4. HistoryTable의 웹페이지 추가
- 1. 표시 테이블 컬럼은 드롭다운 으로 선택 , 한 테이블에 8개 까지 선택가능하게 - [x] 4.1 표시 테이블 컬럼은 드롭다운 으로 선택 , 한 테이블에 8개 까지 선택가능하게
- 2. 시작 시간과 종료 시간 선택 한 범위내에서만 테이블에 표시 - [x] 4.2 시작 시간과 종료 시간 선택 한 범위내에서만 테이블에 표시