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>
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.cs의timescaledb_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/status의controllers필드는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