P0 셀프서비스 결정론 리포트 — 적산·물질수지 폐합·cleaning 마스크 (+ P1 온라인 스펙) #1

Open
windpacer wants to merge 43 commits from feat/p0-selfservice-report into main
3 changed files with 0 additions and 2049 deletions
Showing only changes of commit cb749568d0 - Show all commits

File diff suppressed because it is too large Load Diff

View File

@@ -1,413 +0,0 @@
# HC900 Crawler — 탭 수정 작업지시서 (교정판)
> 본 문서는 실측 검증(DB 직접 조회 + 실행 중인 앱 API Content-Type 확인)으로 교정되었다.
> 이전 판의 "문제 1(DB 스키마 불일치)"과 "문제 2(DI 캐스트 오류)"는 **오진으로 확인되어 삭제**했다.
---
## 진단 요약
빌드: ✅ 성공 / 앱: ✅ 실행 중(포트 5000) / DB 스키마: ✅ 정상
**진짜 근본 원인은 단 하나: JS가 존재하지 않는 API 경로를 호출한다.**
ASP.NET이 `MapFallbackToFile("index.html")`로 매핑되어 있어, 없는 GET 경로는
**404가 아니라 200 + `index.html`(HTML)** 을 반환한다.
프런트는 이 HTML을 `JSON.parse()` 시도 → 예외 → 사용자에게는 "DB/연결 오류"처럼 보인다.
실제로는 DB도 연결도 정상이며, **경로 오타/미구현**이 전부다.
### 검증 방법 (재현 가능)
```bash
# text/html = 엔드포인트 없음(SPA 폴백), application/json = 실제 존재
curl -s -o /dev/null -w "%{content_type}\n" http://localhost:5000/api/history/tags # application/json ✓
curl -s -o /dev/null -w "%{content_type}\n" http://localhost:5000/api/history/tagnames # text/html ✗(없음)
```
### 실측 결과 (오진 정정 포함)
| 이전 판 주장 | 실측 | 판정 |
|---|---|---|
| controller_id 컬럼 미적용 → InitializeAsync 실패 | 5개 테이블 모두 컬럼 존재, v_tag_summary 정상 | **오진(삭제)** |
| IHc900GatewayService DI 캐스트 오류 | 해당 등록 줄 이미 제거됨, 빌드 성공 | **오진(삭제)** |
| /api/realtime/points DB오류 | 200 application/json 정상 | **오진** |
| JS 경로 불일치 (hist/evt/write/fast/t2s) | text/html 또는 404 확인 | **정확(유지)** |
---
## 별도 이슈: 데이터가 비어 보이는 진짜 원인 (코드 아님)
경로를 다 고쳐도 **표에 값이 안 나올 수 있다.** 원인은 게이트웨이 Modbus 연결 실패다.
```
GET /api/gateway/health →
{ "status":"NotServing", "controllerIp":"192.168.0.230", "pollCount":0 }
realtime_table rows = 0
```
게이트웨이가 `192.168.0.230`에 연결 못 함(`NotServing`) → 수집 0건 → 모든 탭 데이터 공란.
HC900 기본 IP는 `192.168.0.240`. **Setup 탭에서 컨트롤러 IP를 실제 값으로 교정 후 재시작.**
(이건 코드 수정과 무관한 운영 설정 문제이며, 탭 로직 수정과 병행해야 데이터가 보인다.)
---
## 수정 대상 (모두 JS 경로 문제, DB 무관)
| # | 탭 | JS 호출 (없는 경로) | 실제/대체 경로 | 작업 |
|---|---|---|---|---|
| 1 | hist | `GET /api/history/tagnames` | `GET /api/history/tags` | JS 1줄 |
| 2 | hist | `GET /api/history/query` | `POST /api/history/query` | JS 메서드+body |
| 3 | hist | `/api/experion/hypertable/*` | 없음 | 엔드포인트 신설 |
| 4 | evt | `/api/event-history/*` (3개) | `POST /api/events/query` | JS 3함수 |
| 5 | write | `/api/points/*` (4개) | `POST /api/gateway/write` | JS 4함수 |
| 6 | fast | `/api/fast/*`, `/api/pointbuilder/points` | FastController 없음 | 컨트롤러 신설 |
| 7 | t2s | `POST /api/text-to-sql/execute` | `execute-mcp`만 존재 | JS 통일 또는 신설 |
> pid / llmchat / kbadmin / docs / trend / ff 탭은 **코드 수정 불필요.**
> 데이터 공란은 위 "게이트웨이 Modbus 연결" 이슈로만 설명된다.
---
## 수정 1: `hist.js` — 이력 조회 탭
### 1-1. tagnames → tags (hist.js:42)
```javascript
// 변경 전
const d = await api('GET', '/api/history/tagnames'); // text/html (없음)
// 변경 후
const d = await api('GET', '/api/history/tags');
```
### 1-2. query GET → POST (hist.js:98)
`HistoryController``[HttpPost("query")]`만 있다(GET 없음). GET 호출은 SPA 폴백으로 빠진다.
```javascript
// 변경 전 (hist.js:98) — 쿼리스트링 GET
const d = await api('GET', `/api/history/query?${params}`);
// 변경 후 — POST + body DTO
const d = await api('POST', '/api/history/query', {
tagNames: tags, // 배열
from: fromDt, // ISO 또는 null
to: toDt,
limit: parseInt(limit) || 1000
});
```
> 응답 형태(`d.rows`, `d.tagNames`)는 기존 코드와 동일하므로 파싱부는 그대로 둔다.
### 1-3. hypertable 엔드포인트 신설 (hist.js:241, 343)
JS가 `/api/experion/hypertable/status`·`/create`를 호출하나 둘 다 없다.
`Hc900Controllers.cs`에 컨트롤러 추가:
```csharp
[ApiController]
[Route("api/experion/hypertable")]
public class HypertableController : ControllerBase
{
private readonly IExperionDbService _db;
public HypertableController(IExperionDbService db) => _db = db;
[HttpGet("status")]
public async Task<IActionResult> Status()
=> Ok(await _db.GetHypertableStatsAsync()); // 기존 hypertable 조회 메서드 재사용
[HttpPost("create")]
public IActionResult Create()
=> Ok(new { success = true, message = "history_table 하이퍼테이블 설정됨" });
}
```
> `IExperionDbService`에 하이퍼테이블 상태 조회 메서드가 이미 있으면 그대로,
> 없으면 `Hc900DbContext.cs`의 `timescaledb_information.hypertables` 조회 로직(이미 존재)을
> public 메서드로 노출.
---
## 수정 2: `evt.js` — 이벤트 히스토리 탭
`/api/event-history/*` 3개 경로 모두 없음. 실제 API는 `POST /api/events/query`(정상 작동).
**백엔드 변경 없이 JS만 수정.**
```javascript
// (1) 디지털 태그 목록 — 전용 API 없음 → realtime에서 클라이언트 필터
// 주의: 응답은 PascalCase (TagName, LiveValue)
async function evtLoadDigitalTags() {
const pts = await api('GET', '/api/realtime/points');
return pts.filter(p => (p.LiveValue ?? '').startsWith('{')); // 상태 레이블 형식 {n|LABEL|}
}
// (2) 이벤트 조회 — GET /api/event-history → POST /api/events/query
async function evtQuery() {
const body = {
tagName: document.getElementById('ev-tag').value.trim() || null,
eventType: document.getElementById('ev-type').value || null,
area: document.getElementById('ev-area').value.trim() || null,
from: fromISO,
to: toISO,
limit: 500
};
const d = await api('POST', '/api/events/query', body);
// d = EventHistoryRecord[] (PascalCase: TagName, EventType, EventTime, CurrValue ...)
}
// (3) 요약 — 전용 엔드포인트 없음 → (2) 결과로 클라이언트 집계
// 예: eventType별 count, 또는 최근 N건 그룹화
```
---
## 수정 3: `write.js` — 태그 쓰기 탭
`/api/points/*` 4개 모두 없음. HC900 쓰기는 gRPC 기반 `POST /api/gateway/write` 단일 경로.
`WriteTagDto = { ControllerId, TagName, Value }`.
```javascript
// write/mode/control 전부 동일 엔드포인트로 통합
async function writeValue() {
const controllerId = document.getElementById('w-ctrl').value || 'HC1';
const tagName = document.getElementById('w-tag').value.trim();
const value = parseFloat(document.getElementById('w-val').value);
const d = await api('POST', '/api/gateway/write', { controllerId, tagName, value });
}
// MODE 변경: tagName에 .MODE 접미사, value=정수
async function writeMode() {
const controllerId = document.getElementById('w-ctrl').value || 'HC1';
const tagName = document.getElementById('m-tag').value.trim() + '.MODE';
const value = parseInt(document.getElementById('m-mode').value);
await api('POST', '/api/gateway/write', { controllerId, tagName, value });
}
// read: 전용 read API 없음 → realtime에서 조회 (PascalCase 주의!)
async function readTag() {
const tagName = document.getElementById('r-tag').value.trim().toLowerCase();
const pts = await api('GET', '/api/realtime/points');
const point = pts.find(p => (p.TagName ?? '').toLowerCase() === tagName); // ← p.TagName (대문자)
// point.LiveValue 표시
}
```
> ⚠️ 이전 판은 `p.tagName`(소문자)으로 적었으나 직렬화가 PascalCase라 **항상 undefined**가 된다.
> 반드시 `p.TagName`, `p.LiveValue`로 쓴다.
**컨트롤러 드롭다운** (`panes/write.html` + `write.js`):
```html
<div class="fg"><label>컨트롤러</label><select id="w-ctrl" class="inp"></select></div>
```
```javascript
async function writeLoadControllers() {
const d = await api('GET', '/api/gateway/status'); // { controllers: [{controllerId, controllerIp}, ...] }
const sel = document.getElementById('w-ctrl');
(d.controllers || []).forEach(c => {
const id = c.controllerId ?? c.ControllerId;
const opt = document.createElement('option');
opt.value = id; opt.textContent = `${id}${c.controllerIp ?? c.ControllerIp}`;
sel.appendChild(opt);
});
}
```
> `/api/gateway/status`의 `controllers` 필드는 `Hc900RealtimeService.ControllerConnected`
> 직렬화 결과이므로 키 케이싱을 실제 응답으로 한 번 확인하고 맞춘다.
---
## 수정 4: `fast.js` — fastRecord 탭 (컨트롤러 신규)
`IExperionDbService`에 FastSession/FastRecord 메서드 존재(`IExperionServices.cs:44-61`).
`GetRealtimeRecordsByTagNamesAsync`(L61)도 있어 실시간 값 채우기 가능. **컨트롤러만 신설.**
### 신규 파일: `src/Hc900Crawler/Controllers/FastController.cs`
```csharp
using Hc900Crawler.Core.Application.Interfaces;
using Hc900Crawler.Core.Domain.Entities;
using Microsoft.AspNetCore.Mvc;
namespace Hc900Crawler.Web.Controllers;
[ApiController]
[Route("api/fast")]
public class FastController : ControllerBase
{
private readonly IExperionDbService _db;
private static readonly Dictionary<int, CancellationTokenSource> _sessions = new();
public FastController(IExperionDbService db) => _db = db;
[HttpGet("sessions")]
public async Task<IActionResult> GetSessions() => Ok(await _db.GetFastSessionsAsync());
[HttpGet("{id}")]
public async Task<IActionResult> GetSession(int id)
{
var s = await _db.GetFastSessionAsync(id);
return s == null ? NotFound() : Ok(s);
}
[HttpPost("start")]
public async Task<IActionResult> Start([FromBody] FastSessionStartRequest req)
{
var createReq = new FastSessionCreateRequest(
req.Name, DateTime.UtcNow, "Running",
req.SamplingMs, req.DurationSec,
System.Text.Json.JsonSerializer.Serialize(req.TagNames),
0, req.RetentionDays);
var session = await _db.CreateFastSessionAsync(createReq);
var cts = new CancellationTokenSource();
lock (_sessions) { _sessions[session.Id] = cts; }
_ = Task.Run(() => RunSessionAsync(session.Id, req, cts.Token));
return Ok(new { session.Id, status = "Running" });
}
private async Task RunSessionAsync(int sessionId, FastSessionStartRequest req, CancellationToken ct)
{
var endAt = DateTime.UtcNow.AddSeconds(req.DurationSec);
int rowCount = 0;
try
{
while (!ct.IsCancellationRequested && DateTime.UtcNow < endAt)
{
var now = DateTime.UtcNow;
// 실시간 값 조회 후 기록 (빈 값 대신 실제 값)
var live = (await _db.GetRealtimeRecordsByTagNamesAsync(req.TagNames))
.ToDictionary(p => p.TagName, p => p.LiveValue);
var records = req.TagNames.Select(t => new FastRecord
{
SessionId = sessionId,
RecordedAt = now,
TagName = t,
Value = live.GetValueOrDefault(t)
}).ToList();
await _db.BatchInsertFastRecordsAsync(records);
rowCount += records.Count;
await _db.UpdateFastSessionRowCountAsync(sessionId, rowCount);
await Task.Delay(req.SamplingMs, ct);
}
await _db.UpdateFastSessionStatusAsync(sessionId, "Completed");
}
catch (OperationCanceledException)
{
await _db.UpdateFastSessionStatusAsync(sessionId, "Stopped");
}
finally { lock (_sessions) { _sessions.Remove(sessionId); } }
}
[HttpPost("{id}/stop")]
public async Task<IActionResult> Stop(int id)
{
lock (_sessions) { if (_sessions.TryGetValue(id, out var cts)) { cts.Cancel(); _sessions.Remove(id); } }
await _db.UpdateFastSessionStatusAsync(id, "Stopped");
return Ok(new { success = true });
}
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(int id)
{
lock (_sessions) { if (_sessions.TryGetValue(id, out var cts)) { cts.Cancel(); _sessions.Remove(id); } }
await _db.DeleteFastSessionAsync(id);
return Ok(new { success = true });
}
[HttpPost("{id}/pin")]
public async Task<IActionResult> Pin(int id, [FromBody] bool pinned)
{
await _db.UpdateFastSessionPinnedAsync(id, pinned);
return Ok(new { success = true });
}
[HttpGet("{id}/records")]
public async Task<IActionResult> GetRecords(int id, [FromQuery] DateTime? from = null, [FromQuery] DateTime? to = null)
=> Ok(await _db.GetFastRecordsAsync(id, from, to));
[HttpGet("{id}/csv")]
public async Task<IActionResult> ExportCsv(int id, [FromQuery] DateTime? from = null, [FromQuery] DateTime? to = null)
{
var stream = new MemoryStream();
await _db.ExportFastRecordsToCsvAsync(id, stream, from, to);
stream.Position = 0;
return File(stream, "text/csv", $"fast_{id}.csv");
}
}
```
> `FastController`는 attribute routing이라 별도 DI 등록 불필요(`AddControllers`가 자동 스캔).
> `FastSessionStartRequest`/`FastSessionCreateRequest`/`FastQueryResult` 레코드 정의를
> `IExperionServices.cs:130~` 에서 확인하고 생성자 인자 순서를 맞출 것.
### fast.js:389 — 태그 목록
```javascript
// 변경 전: /api/pointbuilder/points (없음)
// 변경 후: 기존 태그 API 재사용
const res = await fetch('/api/gateway/tags?limit=500'); // 또는 /api/hc900/tags
```
---
## 수정 5: `t2s.js` — Text-to-SQL 탭
`/api/text-to-sql/execute` 없음(`execute-mcp`만 존재). 호출부 t2s.js:184, 449, 608.
**옵션 A (빠름) — JS를 execute-mcp로 통일:**
```javascript
// t2s.js의 '/api/text-to-sql/execute' 3곳을 모두 '/api/text-to-sql/execute-mcp' 로 교체
```
**옵션 B — 컨트롤러에 execute 신설** (MCP 없이 직접 실행이 필요할 때만):
```csharp
// TextToSqlController.cs
[HttpPost("execute")]
public async Task<IActionResult> ExecuteDirect([FromBody] ExecuteSqlRequest req)
{
var v = _validator.Validate(req.Sql);
if (!v.IsValid) return BadRequest(new { error = v.ErrorMessage });
// Npgsql 직접 실행 (execute-mcp에서 MCP pivot 제외한 버전)
return Ok(result);
}
```
> MCP 경로가 이미 정상 동작 중이면 **옵션 A 권장**(코드 최소화).
---
## 체크리스트
```
[ ] 0. (운영) Setup 탭에서 컨트롤러 IP 192.168.0.230 → 실제 HC900 IP(예 0.240) 교정 후 재시작
→ gateway health가 Serving 되어야 데이터가 표시됨 (코드와 무관, 병행 필수)
── JS만 수정 (백엔드 무변경) ──
[ ] 1. hist.js:42 /api/history/tagnames → /api/history/tags
[ ] 2. hist.js:98 query GET → POST + body DTO
[ ] 3. evt.js 3함수: /api/event-history/* → POST /api/events/query (+ realtime 필터)
[ ] 4. write.js 4함수: /api/points/* → POST /api/gateway/write (필드 PascalCase 주의)
[ ] 5. write.html + write.js 컨트롤러 드롭다운 추가
[ ] 6. fast.js:389 /api/pointbuilder/points → /api/gateway/tags
[ ] 7. t2s.js /api/text-to-sql/execute → execute-mcp (옵션 A)
── 백엔드 신규 ──
[ ] 8. FastController.cs 신규 작성 (수정 4)
[ ] 9. HypertableController 신설 (수정 1-3) — hist 탭 하이퍼테이블 패널
[ ] 10. dotnet build 확인
[ ] 11. 탭별 동작 확인: write → evt → hist → fast → t2s
```
---
## 검증 부록 (재현 명령)
```bash
# DB 스키마가 이미 정상임을 재확인 (오진 검증용)
/tmp/hc900_venv/bin/python3 -c "
import psycopg2
c=psycopg2.connect(host='localhost',dbname='iiot_platform',user='postgres',password='postgres')
cur=c.cursor()
for t in ['realtime_table','history_table','event_history_table','hc900_map_master','tag_metadata']:
cur.execute(\"SELECT 1 FROM information_schema.columns WHERE table_schema='hc900' AND table_name=%s AND column_name='controller_id'\",(t,))
print(t, 'controller_id:', 'YES' if cur.fetchone() else 'NO')
"
# 엔드포인트 존재 여부 (application/json=있음, text/html=없음)
for p in /api/history/tags /api/history/tagnames /api/fast/sessions \
/api/event-history/digital-tags /api/pointbuilder/points; do
printf "%-40s %s\n" "$p" "$(curl -s -o /dev/null -w '%{content_type}' http://localhost:5000$p)"
done
# 게이트웨이 실제 연결 상태
curl -s http://localhost:5000/api/gateway/health
```

View File

@@ -1,281 +0,0 @@
# HC900 Gateway — 아키텍처 및 구현 계획
## 1. 개요
ExperionCrawler의 OPC UA → Experion HS R530 경로를
**Modbus TCP → HC900** 경로로 대체.
```
Before:
HC900 ──Modbus TCP──▶ Experion R530 ──OPC UA──▶ ExperionCrawler ──▶ PostgreSQL
After:
HC900 ──Modbus TCP──▶ C++ Gateway ──gRPC──▶ HC900Crawler ──▶ PostgreSQL
```
## 2. C++ 게이트웨이 (Modbus TCP → gRPC)
### 설계 원칙
- **단순, 고효율, 경량** (예상 RSS 5~8 MB)
- **Full poll 1초 주기** — 모든 레지스터 읽어서 캐시
- **Scattered read는 안 함** — 어차피 Modbus는 batch가 빠름
### 아키텍처
```
┌─────────────────────────────────────────────┐
│ C++ Gateway │
│ ┌──────────┐ ┌──────────┐ ┌────────────┐ │
│ │ Poller │─▶│ Cache │◀─│ gRPC Server│ │
│ │ (1s 주기) │ │ (47KB) │ │ │ │
│ │ 32+3+13 │ │ │ │ ReadTags │ │
│ │ batch │ │ │ │ WriteTag │ │
│ │ reads │ │ │ │ ListTags │ │
│ └────┬──────┘ └──────────┘ └─────┬──────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────────────────────────────┐ │
│ │ industrial-comm (libcomm_core) │ │
│ │ ModbusTCP → HC900 @ 192.168.0.240 │ │
│ └─────────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
```
### 폴링 전략
- `read_holding_registers(addr, count=120)` batch
- Loop 1~24: 32 reads (120 regs each)
- Loop 25~32: 32 reads (120 regs each)
- Variable area: 3 reads (0x18C0~, 336 regs)
- Signal area: 13 reads (0x2000~, 1,508 regs)
- **Total: 48 batch reads → ~117 ms full poll**
- 1초 간격 poll, 캐시 갱신
### gRPC Service (`proto/modbus_gateway.proto`)
```protobuf
service ModbusGateway {
rpc ReadTags(ReadTagsRequest) returns (ReadTagsResponse); // 캐시 응답
rpc WriteTag(WriteTagRequest) returns (WriteTagResponse); // Modbus write
rpc StreamTags(StreamTagsRequest) returns (stream TagValue); // 실시간 구독
rpc ListTags(ListTagsRequest) returns (ListTagsResponse); // 메타정보
rpc HealthCheck(HealthCheckRequest) returns (HealthCheckResponse);
}
```
- `ReadTags`: 캐시에서 즉시 응답 (sub-millisecond)
- `WriteTag`: Modbus FC16 직접 write 후 캐시 갱신
- `StreamTags`: 1초 poll 주기에 맞춰 push
### 레지스터 맵 로딩
- 시작 시 `register-map.json` 파일 읽음 (174 KB)
- 또는 `hc900_map_master` DB 테이블에서 읽음
## 3. hc900_map_master (DB 기반 태그 카탈로그)
### 개념
- `node_map_master`와 동일한 패턴
- HC Designer CSV 3종을 DB에 로드
- 태그 prefix 기반 분류 (기존 PidPrefixRule 재활용)
### DDL
```sql
CREATE TABLE hc900_map_master (
id SERIAL PRIMARY KEY,
tag_name TEXT NOT NULL, -- 레지스터 맵 태그명 (e.g. 'FICQ3101.PV', 'FIQ6101')
base_tag TEXT NOT NULL, -- 베이스 태그 (e.g. 'FICQ3101', 'FIQ6101')
attribute TEXT, -- 속성 (e.g. 'PV', 'SP', 'QV') — NULL이면 베이스 태그 자체
addr INTEGER NOT NULL, -- Modbus 주소
count INTEGER NOT NULL, -- 레지스터 수 (1 or 2)
type TEXT NOT NULL, -- 'float32' or 'uint16'
access TEXT NOT NULL, -- 'R' or 'RW'
description TEXT,
eu TEXT,
category TEXT, -- PidPrefixRule 기반 분류 ('instrument', 'power_equipment', ...)
tag_class TEXT, -- 'field' or 'system'
tag_dcs BOOLEAN, -- DCS function block 여부
source TEXT NOT NULL, -- 'loop', 'signal', 'variable'
group_name TEXT, -- PointBuilder 그룹 ('Controller1', 'Custom', ...)
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_hc900_map_base ON hc900_map_master(base_tag);
CREATE INDEX idx_hc900_map_addr ON hc900_map_master(addr);
CREATE INDEX idx_hc900_map_group ON hc900_map_master(group_name);
```
### 데이터 출처
| CSV 파일 | source | 건수 | 범위 |
|---|---|---|---|
| SummaryFunctionBlockReport.csv | `loop` | 32 loops × 25 params = 800 | 0x0040~0x7FFF |
| SignalTags.csv | `signal` | 530 | 0x2000~0x25E4 |
| Variables.csv | `variable` | 155 | 0x18C0~0x1A10 |
### Prefix Rules (PidPrefixRule 활용)
| Prefix | Category | 예시 |
|---|---|---|
| FIC, FICA, FICQ | instrument, tag_dcs=true | FICQ3101 (PID Loop) |
| FIT, FIQ, FT | instrument, tag_dcs=false | FIT6101, FIQ6101 (Signal Tag) |
| TI, TIC, TICA | instrument, tag_dcs=true | TICA6111A, TICA3202A |
| LI, LIC, LICA | instrument | LICA6213, LICA5113 |
| PI, PIC, PICA | instrument | PICA6111 |
| XV | instrument | XV3208B_REM |
| VP, P- | power_equipment | VP8117_HS, P-3101B_RUN |
### PointBuilder-like Selection
웹 UI에서 `hc900_map_master`를 대상으로:
| Group | 필터 조건 | 용도 |
|---|---|---|
| Controller1 | prefix=FIC/TIC/PIC/LIC, attribute=PV/SP/OP | 주요 PID 값 |
| Custom | user-defined | QV, TRIP, ESD, HS 등 |
DB 조회 예시:
```sql
-- FICQ-6101 계열 모든 태그
SELECT * FROM hc900_map_master
WHERE base_tag IN (
SELECT base_tag FROM hc900_map_master
WHERE base_tag ILIKE 'ficq6101'
);
```
## 4. OPC UA 이름 매핑 (핵심 난제)
### 문제
- DB (Experion OPC UA): `ficq-6101.pv`, `ficq-6101.qv` (lowercase, dash, dot attribute)
- HC Designer CSV: `FICQ3101`, `FIQ6101` (uppercase, no dash, no prefix-dot suffix)
- R530이 중간에서 이름 변환 + 서브 속성 구성하며, **이 매핑 정보는 R530 설정에만 존재**
```
OPC UA (DB) HC Designer (CSV) Modbus
──────────────────── ────────────────── ───────
ficq-6101.pv FICA6101 (Loop #11) 0x0A40 ← prefix 다름 (FICQ vs FICA)
ficq-6101.qv.value FIQ6101 (Signal Tag) 0x2006 ← 사용자 정의 규칙 (FIQ=QV)
ficq-6101.sp FICA6101 (Loop #11) 0x0A44
ficq-3101.pv FICQ3101 (Loop #1) 0x0040
ficq-3101.qv.value FIQ3101 (Signal Tag) 0x2164
```
prefix 예: `FICQ`/`FICA`/`FIT`/`FIQ` 모두 숫자부 `6101` 공유 → **prefix는 접근 방식(loop, PV, QV)을 나타내고, 숫자부가 실제 연결고리**
### 매핑 전략: 숫자부 조인 + prefix 검증
`realtime_table`의 OPC UA 이름과 `hc900_map_master`의 HC Designer 이름을 **숫자부(6101) 기준으로 매칭**하고, prefix rules로 검증:
```sql
-- Phase A: 후보 추출 (숫자부 기반 매칭)
WITH rt_base AS (
SELECT DISTINCT
split_part(tagname, '.', 1) AS opcua_tag,
tagname,
substring(tagname FROM '(\d+)') AS tag_number
FROM realtime_table
),
hc_base AS (
SELECT
tag_name,
base_tag,
addr,
substring(tag_name FROM '(\d+)') AS tag_number
FROM hc900_map_master
)
SELECT
r.opcua_tag,
r.tagname AS opcua_name,
h.tag_name AS hc_name,
h.addr
FROM rt_base r
JOIN hc_base h ON r.tag_number = h.tag_number
AND r.opcua_tag ~* h.base_tag -- base_tag regex 매칭
ORDER BY r.opcua_tag, h.addr;
```
### 결과물: opcua_aliases
```sql
ALTER TABLE hc900_map_master ADD COLUMN opcua_aliases TEXT[];
-- 예시 (자동 생성 후 사용자 검증 필요):
-- FIQ6101 → {"ficq-6101.qv", "ficq-6101.qv.value"}
-- FICQ3101.PV → {"ficq-3101.pv"}
-- FICA6101-WSP → {"ficq-6101.wsp"}
```
### 사용자 검증 플로우
```
CSV → hc900_map_master realtime_table (OPC UA)
│ │
└──── SQL 숫자부 조인 ───┘
alias 후보 목록
사용자 검증 (Y/N)
opcua_aliases 확정
C# 앱이 gRPC 호출 시
aliases로 주소 조회
```
C# 앱에서 gRPC 요청 시:
```csharp
// C# 앱은 DB 이름(ficq-6101.pv)으로 요청
// 게이트웨이는 aliases 매칭으로 레지스터 주소 조회
var resp = client.ReadTags(new[] { "ficq-6101.pv", "ficq-6101.qv" });
```
## 5. 구현 단계
### Phase 1: C++ 게이트웨이 (완성)
- [ ] `industrial-comm`에 gRPC 서버 추가
- [ ] `register-map.json` 로더
- [ ] Poller (full poll 1초)
- [ ] gRPC 서비스 구현 (ReadTags, WriteTag, ListTags, HealthCheck)
- [ ] CMakeLists.txt에 gRPC/Protobuf 종속성 추가
- [ ] systemd unit file
### Phase 2: DB 및 백엔드
- [ ] `hc900_map_master` 테이블 생성 SQL
- [ ] `build_hc900_map.py` CSV → DB 로더 (Python)
- [ ] `scripts/build_register_map.py` → DB 직접 적재
- [ ] Prefix Rules 추가 (FIQ, FIT, FICA 등)
- [ ] PointBuilder-like 웹 UI (기존 ExperionCrawler UI 확장)
### Phase 3: C# HC900Crawler
- [ ] ExperionCrawler 포크 → gRPC Client 추가
- [ ] 기존 OPC UA 계층 대체 (IExperionOpcClient → IModbusGatewayClient)
- [ ] Hc900RealtimeService (캐시 폴링 기반)
- [ ] Write 서비스
- [ ] 기존 DB 스키마 재활용 (realtime_table, history_table, etc.)
### Phase 4: OPC UA 이름 매핑
- [ ] node_map_master 조회로 실제 OPC UA 이름-레지스터 관계 분석
- [ ] opcua_aliases DB 구축
- [ ] 매핑 검증 툴
## 6. 검증된 사실
| 항목 | 상태 |
|---|---|
| HC900 Modbus TCP 연결 | ✅ C70, port 502, IP=192.168.0.240 |
| Float 포맷 | ✅ FP_B (IEEE 754 big-endian) |
| Loop 주소 체계 | ✅ Loop #N = 0x40+(N-1)*0x100 (1~24), 0x7840+(N-25)*0x100 (25~32) |
| batch read 성능 | ✅ 120 regs = 2.4ms, full poll = 117ms |
| C70 최대 연결 | ✅ 10개 |
| R530 사용 연결 | ✅ 1개 (Modbus TCP 채널) |
| 우리 사용 가능 | ✅ 9개, 실제론 1~2개면 충분 |
| Signal Tag FIQ6101 = QV | ✅ `ficq-6101.qv.value` = `FIQ6101` @ 0x2006 |
| 사용자 정의 네이밍 | ✅ FIT=PV, FIQ=QV 등 prefix 기반 규칙 존재 |
## 7. 오픈 이슈
- [ ] **OPC UA ↔ Modbus 주소 매칭 (최대 난제)** — 숫자부 조인으로 후보 추출 후 사용자 검증 필요
- [ ] HC900 Custom Map/User Defined 영역 태그 확인 (현재는 Fixed Map만 커버)
- [ ] Write 시 Modbus FC16 테스트 필요
- [ ] 캐시 무효화 전략 (C#에서 Write 후 gateway cache 갱신)
- [ ] StreamTags 구현 방식 (1초 poll마다 변경분 push vs 전부 push)