운전시 버그 수정
This commit is contained in:
148
CLAUDE.md
148
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.<<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 + 포인트빌더 대시보드
|
||||
|
||||
413
opcServer.md
Normal file
413
opcServer.md
Normal 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` — 노드 매니저 테스트 코드
|
||||
@@ -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<string, string?>))
|
||||
.ToList();
|
||||
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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"}
|
||||
@@ -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")]
|
||||
|
||||
@@ -1 +1 @@
|
||||
bd5528d96831e1269e5461c65f271393ce4b10f88cb7442548dd757f5587c262
|
||||
259756df70d95a486eed7ec635a53d85c7b061c7dfe1903c04135250ae50f242
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
1
src/Web/realtime_autostart.json
Normal file
1
src/Web/realtime_autostart.json
Normal 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"}
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -149,7 +149,7 @@
|
||||
<div class="card-cap">단일 태그 읽기</div>
|
||||
<div class="row-inp">
|
||||
<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=..."/>
|
||||
<button class="btn-b" onclick="connRead()">읽기</button>
|
||||
</div>
|
||||
@@ -194,7 +194,7 @@
|
||||
<div class="card">
|
||||
<div class="card-cap">수집 노드 목록 <em>(한 줄에 하나씩)</em></div>
|
||||
<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()"
|
||||
style="margin-top:14px">📡 크롤링 시작</button>
|
||||
</div>
|
||||
@@ -430,7 +430,7 @@
|
||||
<div class="card-cap">수동 포인트 추가</div>
|
||||
<div class="fg">
|
||||
<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>
|
||||
<button class="btn-b" onclick="pbAddManual()">+ 추가</button>
|
||||
<div id="pb-manual-log" class="logbox hidden" style="margin-top:10px"></div>
|
||||
@@ -512,11 +512,13 @@
|
||||
<div class="cols-3">
|
||||
<div class="fg">
|
||||
<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 class="fg">
|
||||
<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 class="fg">
|
||||
<label>최대 행 수</label>
|
||||
@@ -536,6 +538,36 @@
|
||||
</main>
|
||||
</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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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 => `<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();
|
||||
|
||||
30
todo.md
30
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')
|
||||
- [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 시작 시간과 종료 시간 선택 한 범위내에서만 테이블에 표시
|
||||
|
||||
Reference in New Issue
Block a user