Files
HC900-Crawler/TAB_FIX_WORK_ORDER.md
windpacer 16fc7a2598 Initial commit: HC900 Crawler
Honeywell HC900을 Modbus TCP로 직접 폴링 → gRPC → C# 크롤러 → PostgreSQL.
기존 Experion OPC UA 데이터 경로를 HC900 직접 통신으로 대체.

- industrial-comm/cpp: C++ Modbus 게이트웨이 (gRPC 서버)
- src: C# .NET 8 ASP.NET Core 크롤러 + 웹 UI (3-Layer)
- mcp-server: Python FastMCP (RAG/NL2SQL/P&ID)
- 다중 컨트롤러(N-Controller) 지원

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 20:28:14 +09:00

17 KiB

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도 연결도 정상이며, 경로 오타/미구현이 전부다.

검증 방법 (재현 가능)

# 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)

// 변경 전
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 폴백으로 빠진다.

// 변경 전 (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에 컨트롤러 추가:

[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.cstimescaledb_information.hypertables 조회 로직(이미 존재)을 public 메서드로 노출.


수정 2: evt.js — 이벤트 히스토리 탭

/api/event-history/* 3개 경로 모두 없음. 실제 API는 POST /api/events/query(정상 작동). 백엔드 변경 없이 JS만 수정.

// (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 }.

// 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):

<div class="fg"><label>컨트롤러</label><select id="w-ctrl" class="inp"></select></div>
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/statuscontrollers 필드는 Hc900RealtimeService.ControllerConnected 직렬화 결과이므로 키 케이싱을 실제 응답으로 한 번 확인하고 맞춘다.


수정 4: fast.js — fastRecord 탭 (컨트롤러 신규)

IExperionDbService에 FastSession/FastRecord 메서드 존재(IExperionServices.cs:44-61). GetRealtimeRecordsByTagNamesAsync(L61)도 있어 실시간 값 채우기 가능. 컨트롤러만 신설.

신규 파일: src/Hc900Crawler/Controllers/FastController.cs

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 — 태그 목록

// 변경 전: /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로 통일:

// t2s.js의 '/api/text-to-sql/execute' 3곳을 모두 '/api/text-to-sql/execute-mcp' 로 교체

옵션 B — 컨트롤러에 execute 신설 (MCP 없이 직접 실행이 필요할 때만):

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

검증 부록 (재현 명령)

# 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