# ExperionCrawler — 작업 이력 ## 작업 규칙 - 복잡한 작업은 항상 todo 목록 먼저 생성 - 각 단계 시작 전 todo 목록 확인 - 단계 완료 후 즉시 completed 표시 ## 완료된 작업 ### 기능 추가 — OPC UA 서버 기능 (2026-04-15) #### 배경 ExperionCrawler가 OPC UA 클라이언트 역할만 했으나, 외부 OPC UA 클라이언트(SCADA, MES 등)가 ExperionCrawler에 접속해 실시간 값을 읽을 수 있도록 OPC UA 서버 기능 추가. #### 아키텍처 ``` [Experion HS R530] ──(OPC UA Client)──► ExperionCrawler ◄──(OPC UA Client)── [외부 시스템] │ (OPC UA Server) │ [PostgreSQL DB] ``` #### 주소 공간 구조 ``` Root/Objects/ExperionCrawler ├── ServerInfo/Status, PointCount, LastUpdateTime └── Realtime/, , … (ns=2;s=tag_{tagname}) ``` #### 수정/추가 파일 | 파일 | 수정 내용 | |------|----------| | `src/Web/ExperionCrawler.csproj` | `OPCFoundation.NetStandard.Opc.Ua.Server v1.5.378.134` 패키지 추가 | | `src/Web/appsettings.json` | `OpcUaServer` 섹션 추가 (Port:4841, EnableSecurity:false, AllowAnonymous:true) | | `src/Core/Application/Interfaces/IExperionServices.cs` | `IExperionOpcServerService` 인터페이스, `OpcServerStatus` record, `GetRealtimeNodeDataTypesAsync()` 추가 | | `src/Infrastructure/OpcUa/ExperionOpcServerNodeManager.cs` | 신규 — `CustomNodeManager2` 상속, 주소 공간 관리 (`CreateAddressSpace`, `RebuildAddressSpace`, `UpdateNodeValue`) | | `src/Infrastructure/OpcUa/ExperionOpcServerService.cs` | 신규 — `ExperionStandardServer` + `ExperionOpcServerService` (`IHostedService` + `IExperionOpcServerService`) | | `src/Infrastructure/OpcUa/ExperionRealtimeService.cs` | `_pointCache` (nodeId→RealtimePoint) 추가; `FlushPendingAsync`에서 OPC 서버 노드 값 lazy 갱신 | | `src/Infrastructure/Database/ExperionDbContext.cs` | `GetRealtimeNodeDataTypesAsync()` — realtime_table × node_map_master 조인 | | `src/Web/Controllers/ExperionControllers.cs` | `ExperionOpcServerController` 추가 (start/stop/status/rebuild) | | `src/Web/Program.cs` | `ExperionOpcServerService` Singleton+HostedService 등록 | | `src/Web/wwwroot/index.html` | 08 OPC UA 서버 탭 + pane-opcsvr 섹션 추가 | | `src/Web/wwwroot/js/app.js` | `srvLoad/Start/Stop/Rebuild/_srvRender/_srvStartPoll/_srvStopPoll` 구현 | | `src/Web/wwwroot/css/style.css` | `.srv-status-card`, `.srv-meta`, `.dot.grn` 스타일 추가 | #### 주요 설계 결정 | 항목 | 결정 | |------|------| | 인증서 | 기존 `pki/own/certs/{hostname}.pfx` 재사용 (`ApplicationType.ClientAndServer`) | | 포트 | 기본 4841 (4840은 Experion HS R530이 사용 가능) | | 보안 | 기본 None (appsettings.json에서 변경 가능) | | 자동 재시작 | `opcserver_autostart.json` 플래그 파일 패턴 (RealtimeService와 동일) | | 순환 참조 | `IServiceProvider` lazy resolve — `_opcServer ??= _sp.GetService()` | | FlushLoop 연동 | 500ms 배치 DB 업데이트 후 → OPC 서버 노드 값도 동시 갱신 (DB 폴링 없음) | #### API 엔드포인트 - `GET /api/opcserver/status` — 상태 조회 (running, clientCount, nodeCount, endpointUrl, startedAt) - `POST /api/opcserver/start` — 서버 시작 - `POST /api/opcserver/stop` — 서버 중지 - `POST /api/opcserver/rebuild` — 주소 공간 재구성 #### 빌드 결과 - 경고 11건 (기존 8건 + OPC SDK Server Start/Stop deprecated 3건), **에러 0건** — 빌드 성공 #### OPC UA 서버가 노출하는 데이터 **데이터 출처**: `realtime_table`에 등록된 포인트 전체 (포인트빌더에서 빌드/수동 추가한 포인트) **주소 공간 구조** ``` Root/Objects/ExperionCrawler ├── ServerInfo/ │ ├── Status (String) — "Running" / "Stopped" │ ├── PointCount (Int32) — 구독 중인 포인트 수 │ └── LastUpdateTime (DateTime) — 마지막 값 갱신 시각 └── Realtime/ ├── ns=2;s=tag_FIC101_PV ├── └── … ``` **NodeId 명명 규칙**: `ns=2;s=tag_{tagname}` **DataType 결정**: `realtime_table` × `node_map_master` 조인 - Double/Float/Int32/Int64/Boolean/DateTime → 해당 OPC UA 타입 - 기타/NULL → String (fallback) **접근 제한**: 읽기 전용 (`AccessLevel = CurrentRead`), `Historizing = false` **갱신 주기**: Experion HS R530 → FlushLoop 500ms 배치 → DB + OPC 서버 노드 동시 갱신 --- ### 로그 정리 — 스냅샷 로그 2줄 → 1줄 (2026-04-15) #### 증상 히스토리 스냅샷 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 테이블을 조회·탐색할 수 있는 웹 대시보드를 풀스택으로 구현했다. #### 수정된 파일 | 파일 | 내용 | |------|------| | `src/Core/Application/Interfaces/IExperionServices.cs` | `IExperionDbService`에 `GetMasterStatsAsync()` / `QueryMasterAsync()` 추가, `NodeMapStats` / `NodeMapQueryResult` record 추가 | | `src/Infrastructure/Database/ExperionDbContext.cs` | `ExperionDbService`에 두 메서드 구현 (통계·필터 조회, 페이지네이션) | | `src/Web/Controllers/ExperionControllers.cs` | `ExperionNodeMapController` 추가 (`GET /api/nodemap/stats`, `GET /api/nodemap/query`) | | `src/Web/wwwroot/index.html` | 사이드바 05번 탭 추가, `#pane-nm-dash` 섹션 추가 (통계 카드·필터폼·페이지네이션·테이블) | | `src/Web/wwwroot/js/app.js` | `nmLoad()` / `nmQuery()` / `nmPrev()` / `nmNext()` / `nmReset()` 구현, 탭 클릭 핸들러에 `nmLoad()` 호출 추가 | | `src/Web/wwwroot/css/style.css` | `.nm-stat-row`, `.nm-cls`, `.nm-dtype`, `.pg`, `.btn-sm` 등 대시보드 전용 스타일 추가 | #### 빌드 결과 - 경고 3건 (기존 경고 동일), **에러 0건** — 빌드 성공 #### 주의 사항 - 인증서 관련 코드(`ExperionCertificateService.cs`, 인증서 컨트롤러)는 일절 수정하지 않음 --- ### 이름 필터 드롭다운 OR 조건 검색 (2026-04-14) 노드맵 대시보드의 이름 검색을 텍스트 입력에서 `name` 컬럼 고유값 풀다운 메뉴 4개로 교체, OR 조건 최대 4개 동시 선택 가능하도록 확장했다. #### 수정된 파일 | 파일 | 내용 | |------|------| | `src/Core/Application/Interfaces/IExperionServices.cs` | `GetNameListAsync()` 추가; `QueryMasterAsync` 파라미터 `string? name` → `IEnumerable? names` | | `src/Infrastructure/Database/ExperionDbContext.cs` | `GetNameListAsync()` 구현 (distinct + 오름차순 정렬); `QueryMasterAsync`에서 `nameList.Contains(x.Name)` → EF가 `WHERE name IN (...)` SQL 생성 | | `src/Web/Controllers/ExperionControllers.cs` | `GET /api/nodemap/names` 엔드포인트 추가; `Query` 액션 파라미터 `string? name` → `List? names` (ASP.NET Core가 `?names=A&names=B` 자동 바인딩) | | `src/Web/wwwroot/index.html` | "이름 검색" 텍스트 입력 제거 → `nf-name-1` ~ `nf-name-4` 4개 `