// Real-time HMI Websocket Client & REST API Manager let socket = null; let currentSelectedEquipment = null; let currentSelectedPrefix = "line1"; // 기본 프리픽스 설정 let currentActiveGroup = "A"; // 현재 선택된 제어 그룹 ('A' 또는 'B') let lastReceivedStatusData = null; // 가장 최근 수신한 실시간 상태 정보 캐시 const pendingControlCommands = new Map(); let equipmentControlsLocked = true; const BACKEND_URL = `${window.location.protocol}//${window.location.host}`; const WS_PROTOCOL = window.location.protocol === "https:" ? "wss:" : "ws:"; const WS_URL = `${WS_PROTOCOL}//${window.location.host}/ws/equipment`; document.addEventListener("DOMContentLoaded", () => { loadEquipmentCards(); connectWebsocket(); initHangerPopoverOutsideClick(); }); // Websocket 연결 및 자동 재접속 로직 function connectWebsocket() { const wsStatusBadge = document.getElementById("hmi-websocket-status"); socket = new WebSocket(WS_URL); socket.onopen = () => { if (wsStatusBadge) { wsStatusBadge.textContent = "실시간 연결"; wsStatusBadge.className = "badge badge-online"; } }; socket.onmessage = (event) => { try { const msg = JSON.parse(event.data); if (msg.type === "equipment_update" && currentSelectedEquipment) { // 선택한 설비의 업데이트 정보인 경우 HMI 갱신 수행 if (String(msg.equipment_id) === String(currentSelectedEquipment.id)) { lastReceivedStatusData = msg.data; updateHMILiveDisplay(msg.data); } } else if (msg.type === "command_ack") { handleCommandAck(msg); } } catch (err) { console.error("Websocket parsing error:", err); } }; socket.onclose = () => { if (wsStatusBadge) { wsStatusBadge.textContent = "연결 안됨 (재접속중...)"; wsStatusBadge.className = "badge badge-offline"; } setTimeout(connectWebsocket, 3000); // 3초 후 자동 재연결 시도 }; socket.onerror = (err) => { console.error("Websocket error:", err); }; } function handleCommandAck(message) { if (!message.command_id || !["PLC_APPLIED", "FAILED", "TIMEOUT", "BUSY"].includes(message.status)) return; const label = pendingControlCommands.get(message.command_id) || "PLC 제어 명령"; pendingControlCommands.delete(message.command_id); if (message.status === "PLC_APPLIED") { // alert(`${label}이(가) PLC에 정상 적용되었습니다.`); } else if (message.status === "TIMEOUT") { // alert(`${label} 적용 확인 시간이 초과되었습니다.`); } else if (message.status === "BUSY") { // alert(`${label} 요청 실패: 게이트웨이가 이전 제어 명령을 처리 중입니다 (바쁨).`); } else { // alert(`${label} 적용에 실패했습니다.`); } } function setEquipmentControlsLocked(locked) { equipmentControlsLocked = locked; const overlay = document.getElementById("initialization-overlay"); if (!overlay) return; overlay.classList.toggle("active", locked); overlay.setAttribute("aria-hidden", String(!locked)); } let cachedEquipments = []; // 설비 카드 목록 로드 async function loadEquipmentCards() { const container = document.getElementById("equipment-cards-container"); try { const response = await fetch(`${BACKEND_URL}/api/equipment`); if (response.ok) { const list = await response.json(); cachedEquipments = list; container.innerHTML = ""; // 초기화 if (currentSelectedEquipment) { // 상세페이지 뷰 모드 container.style.display = "none"; document.getElementById("hmi-detail-panel").style.display = "block"; // 페이지 헤더 타이틀 업데이트 document.getElementById("main-page-title").innerText = "실시간 HMI 제어"; document.getElementById("main-page-desc").style.display = "none"; return; } // 목록 뷰 모드 container.style.display = "grid"; document.getElementById("hmi-detail-panel").style.display = "none"; // 페이지 헤더 타이틀 업데이트 document.getElementById("main-page-title").innerText = "설비 리스트"; document.getElementById("main-page-desc").innerText = "등록된 설비 목록 및 상세 사양 정보를 확인할 수 있습니다."; document.getElementById("main-page-desc").style.display = "block"; if (list.length === 0) { container.innerHTML = `
등록된 설비가 없습니다.
`; return; } list.forEach(eq => { const isOnline = eq.status === "running" || eq.is_online; const card = document.createElement("div"); card.className = `card equip-card`; card.onclick = () => selectEquipment(eq); card.innerHTML = `
${eq.name || eq.equipment_id}
타입: ${eq.type || "N/A"}
${isOnline ? 'ONLINE' : 'OFFLINE'}
IP Address: ${eq.ip_address || "0.0.0.0"}
보빈 수: ${eq.yarn_bobbin_count || 0}개
건조기 타입: ${eq.dryer_type || "N/A"}
소비 전력: ${eq.power_consumption || 0} kW
PLC 연결상태: ${eq.device_state || 'OFFLINE'}
최대 속도: Cut: ${eq.max_cutting_speed || 0} / Feed: ${eq.max_feed_speed || 0}
`; container.appendChild(card); }); } } catch (e) { console.error("Failed to load equipment list:", e); } } // 전체 목록으로 돌아가기 function goBackToEquipmentList() { currentSelectedEquipment = null; document.getElementById("hmi-detail-panel").style.display = "none"; document.getElementById("equipment-cards-container").style.display = "grid"; loadEquipmentCards(); } // 설비 선택 및 HMI 렌더링 async function selectEquipment(eq) { currentSelectedEquipment = eq; currentSelectedPrefix = eq.line_prefix || "line1"; // 목록 화면 숨김 & HMI 디테일 표출 document.getElementById("equipment-cards-container").style.display = "none"; document.getElementById("hmi-detail-panel").style.display = "block"; // 페이지 헤더 타이틀 업데이트 document.getElementById("main-page-title").innerText = "실시간 HMI 제어"; document.getElementById("main-page-desc").style.display = "none"; // 초기 그룹은 'A'로 설정 및 탭 활성화 setHmiActiveGroup('A'); // DB에서 실시간 상세 상태 데이터 가져와 UI 동기화 try { const response = await fetch(`${BACKEND_URL}/api/equipment/${eq.id}/status`); if (response.ok) { const statusData = await response.json(); lastReceivedStatusData = statusData; updateHMILiveDisplay(statusData); } } catch (e) { console.error("실시간 설비 상태 로드 실패:", e); } } // A그룹 / B그룹 제어 전환 설정 function setHmiActiveGroup(groupChar) { currentActiveGroup = groupChar; // 각 그룹 카드의 제목 span 태그를 찾아 뒤에 A 또는 B 추가 document.querySelectorAll('.hmi-group-title > span:first-child').forEach(span => { if (!span.dataset.baseTitle) { span.dataset.baseTitle = span.textContent.trim(); } span.textContent = `${span.dataset.baseTitle} ${groupChar}`; }); // 건조부 히터 번호 변경 (A그룹: H1~H5, B그룹: H6~H10) for (let h = 1; h <= 5; h++) { const btn = document.getElementById(`btn-dryer-h${h}`); if (btn) { const heaterNum = groupChar === 'A' ? h : (h + 5); const lampSpan = btn.querySelector('.hmi-lamp'); btn.innerHTML = `H${heaterNum} `; if (lampSpan) { btn.appendChild(lampSpan); } } } // 탭 UI 교체 const tabA = document.getElementById("btn-tab-a"); const tabB = document.getElementById("btn-tab-b"); if (groupChar === 'A') { tabA.classList.add("active"); tabB.classList.remove("active"); } else { tabB.classList.add("active"); tabA.classList.remove("active"); } // 행거 포트 그리드 재렌더링 renderHangerPorts(); // 팝오버 닫기 hideHangerPopover(); // 기존 캐싱 데이터가 존재한다면 즉시 새로운 오프셋 기준으로 리렌더링 if (lastReceivedStatusData) { updateHMILiveDisplay(lastReceivedStatusData); } } // 6그룹: 행거부 20개 포트 콤팩트 그리드 렌더링 function renderHangerPorts() { const container = document.getElementById("hanger-ports-container"); container.innerHTML = ""; const startPortNum = currentActiveGroup === 'A' ? 1 : 21; const endPortNum = currentActiveGroup === 'A' ? 20 : 40; for (let p = startPortNum; p <= endPortNum; p++) { const cell = document.createElement("div"); const paddedNum = String(p).padStart(2, '0'); cell.className = "port-cell port-idle"; cell.id = `port-cell-${p}`; cell.innerText = `P${paddedNum}`; cell.onclick = (e) => { e.stopPropagation(); showHangerPopover(p, cell); }; container.appendChild(cell); } } // 행거 포트 클릭시 공용 제어 팝오버 표시 function showHangerPopover(portNum, cellElement) { const popover = document.getElementById("hanger-popover"); const title = document.getElementById("popover-title"); const paddedNum = String(portNum).padStart(2, '0'); title.innerText = `P${paddedNum} 제어`; // 팝오버 위치를 클릭된 셀 뒤로 가져다 붙임 popover.style.display = "flex"; // 1행인지 2행 이하인지 구분하여 위/아래 배치 결정 (그리드가 5열이므로 index 5(6번째 포트)부터 2행) const startPortNum = currentActiveGroup === 'A' ? 1 : 21; const relativeIndex = portNum - startPortNum; if (relativeIndex >= 5) { // 2행 이하: 셀 위에 배치 (위로 열림) popover.style.top = `${cellElement.offsetTop - popover.offsetHeight - 6}px`; } else { // 1행: 셀 아래에 배치 (아래로 열림) popover.style.top = `${cellElement.offsetTop + cellElement.offsetHeight + 6}px`; } popover.style.left = `${cellElement.offsetLeft + (cellElement.offsetWidth / 2) - 65}px`; // 제어 버튼 바인딩 (PLC 쓰기 비트 인덱스 계산) const btnAutoDown = document.getElementById("btn-popover-auto-down"); const btnManualDown = document.getElementById("btn-popover-manual-down"); const btnManualUp = document.getElementById("btn-popover-manual-up"); let autoDownBitId, manualDownBitId, manualUpBitId; if (currentActiveGroup === 'A') { autoDownBitId = 68 + (portNum - 1); // 68 ~ 87 manualDownBitId = 108 + (portNum - 1); // 108 ~ 127 manualUpBitId = 148 + (portNum - 1); // 148 ~ 167 } else { autoDownBitId = 88 + (portNum - 21); // 88 ~ 107 manualDownBitId = 128 + (portNum - 21); // 128 ~ 147 manualUpBitId = 168 + (portNum - 21); // 168 ~ 187 } btnAutoDown.onclick = () => { sendHmiBitDirect(autoDownBitId); hideHangerPopover(); }; btnManualDown.onclick = () => { sendHmiBitDirect(manualDownBitId); hideHangerPopover(); }; btnManualUp.onclick = () => { sendHmiBitDirect(manualUpBitId); hideHangerPopover(); }; } function hideHangerPopover() { const popover = document.getElementById("hanger-popover"); if (popover) popover.style.display = "none"; } function initHangerPopoverOutsideClick() { document.addEventListener("click", () => { hideHangerPopover(); }); const popover = document.getElementById("hanger-popover"); if (popover) { popover.addEventListener("click", (e) => { e.stopPropagation(); // 팝오버 내부 클릭은 닫히지 않음 }); } } // 실시간 수신 데이터의 A/B 오프셋을 판단하여 UI 상태 갱신 function updateHMILiveDisplay(data) { if (!data) return; setEquipmentControlsLocked(data.sync_state === "SYNCING" || data.device_state !== "ONLINE"); const mRead = typeof data.m_read_raw === 'string' ? JSON.parse(data.m_read_raw || '{}') : (data.m_read_raw || {}); // A/B 그룹에 따른 읽기 비트 오프셋 설정 const isGroupB = (currentActiveGroup === 'B'); const mOffset = isGroupB ? 19 : 0; const dOffset = isGroupB ? 5 : 0; // ---------------------------------------------------- // 1그룹: 활성화 및 리셋 비트 상태 동기화 (m_read_raw) // ---------------------------------------------------- // 안전 인터락 상태 (A-1) - 정상=녹색, 이상=적색 setInterlockLamp("lamp-estop", mRead[String(1 + mOffset)], 1); setInterlockLamp("lamp-door-left", mRead[String(2 + mOffset)], 1); setInterlockLamp("lamp-door-right", mRead[String(3 + mOffset)], 1); setInterlockLamp("lamp-water-level", mRead[String(4 + mOffset)], 0); setInterlockLamp("lamp-heater-interlock", mRead[String(5 + mOffset)], 1); setLampStatus("lamp-group-active-on", mRead[String(6 + mOffset)]); // 활성화 ON setLampStatus("lamp-group-active-off", mRead[String(7 + mOffset)], true); // 활성화 OFF (반전/빨간색) // 함침&행거 ON/OFF (2그룹 & 6그룹 동시 업데이트) setLampStatus("lamp-impreg-hanger-on-g2", mRead[String(11 + mOffset)]); setLampStatus("lamp-impreg-hanger-off-g2", mRead[String(12 + mOffset)], true); setLampStatus("lamp-impreg-hanger-on-g6", mRead[String(11 + mOffset)]); setLampStatus("lamp-impreg-hanger-off-g6", mRead[String(12 + mOffset)], true); setLampStatus("lamp-dryer-on", mRead[String(13 + mOffset)]); // 건조 ON setLampStatus("lamp-dryer-off", mRead[String(14 + mOffset)], true); setLampStatus("lamp-cutter-on", mRead[String(15 + mOffset)]); // 커팅 ON setLampStatus("lamp-cutter-off", mRead[String(16 + mOffset)], true); setLampStatus("lamp-transfer-on", mRead[String(17 + mOffset)]); // 이송 ON setLampStatus("lamp-transfer-off", mRead[String(18 + mOffset)], true); setLampStatus("lamp-group-stop", mRead[String(8 + mOffset)], true); // 전체정지 setLampStatus("lamp-group-pause", mRead[String(9 + mOffset)], true); // 일시정지 setLampStatus("lamp-group-restart", mRead[String(10 + mOffset)]); // 재시작 setLampStatus("lamp-interlock-reset", mRead[String(0 + mOffset)]); // 인터락 리셋 // ---------------------------------------------------- // 2그룹: 함침부 작동 상태 및 수치 동기화 // ---------------------------------------------------- const impregAutoOffset = isGroupB ? 42 : 38; const impregManualOffset = isGroupB ? 43 : 39; const pumpCircOffset = isGroupB ? 44 : 40; const pumpDrainOffset = isGroupB ? 45 : 41; setLampStatus("lamp-impreg-auto", mRead[String(impregAutoOffset)]); setLampStatus("lamp-impreg-manual", mRead[String(impregManualOffset)]); setLampStatus("lamp-pump-circ", mRead[String(pumpCircOffset)]); setLampStatus("lamp-pump-drain", mRead[String(pumpDrainOffset)]); // 순환펌프 재작동 시간 (D0 / D5) const circTimeVal = data[`d_read_g${0 + dOffset}`] !== undefined ? data[`d_read_g${0 + dOffset}`] : 0; document.getElementById("val-pump-circ-time").innerHTML = `${circTimeVal} `; // ---------------------------------------------------- // 3그룹: 건조기 작동 상태 동기화 // ---------------------------------------------------- const dryerAutoOffset = isGroupB ? 56 : 46; const dryerManualOffset = isGroupB ? 57 : 47; const fanTripOffset = isGroupB ? 58 : 48; const heaterReqOffset = isGroupB ? 59 : 49; const fanActiveOffset = isGroupB ? 60 : 50; const heaterStartOffset = isGroupB ? 61 : 51; setLampStatus("lamp-dryer-auto", mRead[String(dryerAutoOffset)]); setLampStatus("lamp-dryer-manual", mRead[String(dryerManualOffset)]); setLampStatus("lamp-dryer-fan", mRead[String(fanActiveOffset)]); // 히터 1~5 (A그룹: 51~55, B그룹: 61~65) for (let h = 1; h <= 5; h++) { setLampStatus(`lamp-dryer-h${h}`, mRead[String(heaterStartOffset + (h - 1))]); } setLampStatus("lamp-fan-trip", mRead[String(fanTripOffset)], true); // 송풍기 트립 (알람이므로 빨간색) setLampStatus("lamp-heater-req", mRead[String(heaterReqOffset)]); // ---------------------------------------------------- // 4그룹: 커팅부 작동 상태 및 수치 동기화 // ---------------------------------------------------- const cutterActiveOffset = isGroupB ? 68 : 66; const cutterStartOffset = isGroupB ? 69 : 67; setLampStatus("lamp-cutter-motor-active", mRead[String(cutterActiveOffset)]); setLampStatus("lamp-cutter-motor-start", mRead[String(cutterStartOffset)]); // 커팅속도 (D1 / D6) - 소수점 1째자리 포맷팅 및 Hz 단위 적용 const rawCutterSpeed = data[`d_read_g${1 + dOffset}`] !== undefined ? data[`d_read_g${1 + dOffset}`] : 0; const cutterSpeedVal = Number(rawCutterSpeed).toFixed(1); document.getElementById("val-cutter-speed").innerHTML = `${cutterSpeedVal} Hz`; // ---------------------------------------------------- // 5그룹: 이송 작동 상태 및 수치 동기화 // ---------------------------------------------------- const transAutoOffset = isGroupB ? 79 : 70; const transManualOffset = isGroupB ? 80 : 71; const transAutoStartOffset = isGroupB ? 81 : 72; const transAutoStopOffset = isGroupB ? 82 : 73; const transFwdOffset = isGroupB ? 83 : 74; const transRevOffset = isGroupB ? 84 : 75; const transStopOffset = isGroupB ? 85 : 76; const transZeroOffset = isGroupB ? 86 : 77; const transErrOffset = isGroupB ? 87 : 78; setLampStatus("lamp-transfer-auto", mRead[String(transAutoOffset)]); setLampStatus("lamp-transfer-manual", mRead[String(transManualOffset)]); setLampStatus("lamp-transfer-auto-start", mRead[String(transAutoStartOffset)]); setLampStatus("lamp-transfer-auto-stop", mRead[String(transAutoStopOffset)], true); setLampStatus("lamp-transfer-fwd", mRead[String(transFwdOffset)]); setLampStatus("lamp-transfer-rev", mRead[String(transRevOffset)]); setLampStatus("lamp-transfer-stop", mRead[String(transStopOffset)], true); setLampStatus("lamp-transfer-zero", mRead[String(transZeroOffset)]); setLampStatus("lamp-transfer-err-reset", mRead[String(transErrOffset)]); // 이송 데이터 (D2, D3, D4 / D7, D8, D9) const transTargetPos = data[`d_read_g${2 + dOffset}`] !== undefined ? data[`d_read_g${2 + dOffset}`] : 0; const transCurPos = data[`d_read_g${3 + dOffset}`] !== undefined ? data[`d_read_g${3 + dOffset}`] : 0; const transTargetSpeed = data[`d_read_g${4 + dOffset}`] !== undefined ? data[`d_read_g${4 + dOffset}`] : 0; // 자동 목표 이동량(그룹 2/7)은 REAL 소수 1자리, 수동 목표 속도(그룹 4/9)는 REAL 정수 표기 document.getElementById("val-transfer-target-pos").innerText = Number(transTargetPos).toFixed(1); document.getElementById("val-transfer-cur-pos").innerHTML = `${Number(transCurPos).toFixed(1)} mm/s`; document.getElementById("val-transfer-target-speed").innerText = Number(transTargetSpeed).toFixed(0); // ---------------------------------------------------- // 6그룹: 행거부 작동 (20개 포트 상태 셀 리프레시) // ---------------------------------------------------- const startPortNum = isGroupB ? 21 : 1; const endPortNum = isGroupB ? 40 : 20; for (let p = startPortNum; p <= endPortNum; p++) { const cell = document.getElementById(`port-cell-${p}`); if (!cell) continue; let autoDownBitId, manualDownBitId, manualUpBitId; if (!isGroupB) { // A그룹 포트 읽기 비트 매핑 autoDownBitId = 88 + (p - 1); // 88 ~ 107 manualDownBitId = 128 + (p - 1); // 128 ~ 147 manualUpBitId = 168 + (p - 1); // 168 ~ 187 (168:자동상승, 169~187:수동상승) } else { // B그룹 포트 읽기 비트 매핑 autoDownBitId = 108 + (p - 21); // 108 ~ 127 manualDownBitId = 148 + (p - 21); // 148 ~ 167 manualUpBitId = 188 + (p - 21); // 188 ~ 207 } // CSS 스타일 초기화 후 상태에 따라 네온 효과 부여 cell.className = "port-cell"; if (mRead[String(autoDownBitId)] === 1) { cell.classList.add("port-active-auto-down"); } else if (mRead[String(manualDownBitId)] === 1) { cell.classList.add("port-active-manual-down"); } else if (mRead[String(manualUpBitId)] === 1) { cell.classList.add("port-active-manual-up"); } else { cell.classList.add("port-idle"); } } } // 램프 인디케이터 상태 변경 유틸리티 함수 function setLampStatus(elementId, value, isRed = false) { const el = document.getElementById(elementId); if (!el) return; const activeClass = isRed ? "active-red" : "active"; // 버튼 바인딩용 클래스 처리 (상위 부모가 btn-hmi인 경우 버튼 테두리 색상 처리 포함) const btnParent = el.closest(".btn-hmi"); if (value === 1 || value === true || String(value).toLowerCase() === "true" || String(value) === "1") { el.classList.add("active"); if (btnParent) btnParent.classList.add(activeClass); } else { el.classList.remove("active"); if (btnParent) { btnParent.classList.remove("active"); btnParent.classList.remove("active-red"); } } } // 안전 인터락 전용 상태 갱신 함수 (정상 = 녹색(active), 이상 = 적색(active-red)) function setInterlockLamp(elementId, value, normalValue) { const el = document.getElementById(elementId); if (!el) return; // value와 normalValue 값을 비교 (타입 무관하게 스트링 및 boolean 유연하게 비교) const isNormal = (String(value) === String(normalValue) || (value === true && normalValue === 1) || (value === false && normalValue === 0)); if (isNormal) { el.classList.add("active"); el.classList.remove("active-red"); } else { el.classList.add("active-red"); el.classList.remove("active"); } } // HMI 비트 제어 패킷 전송 (A/B 오프셋 적용하여 자동 제어) async function sendHmiBit(baseBitId, actionName) { if (!currentSelectedEquipment) return; // A/B 그룹별 PLC 쓰기 오프셋 계산 const isGroupB = (currentActiveGroup === 'B'); let actualBitId = baseBitId; // 그룹별 매핑 규정 오프셋 적용 if (isGroupB) { if (baseBitId >= 0 && baseBitId <= 11) { actualBitId = baseBitId + 12; // 1그룹: Offset 12 (12~23) } else if (baseBitId >= 24 && baseBitId <= 27) { actualBitId = baseBitId + 4; // 2그룹: Offset 4 (28~31) } else if (baseBitId >= 32 && baseBitId <= 39) { actualBitId = baseBitId + 8; // 3그룹: Offset 8 (40~47) } else if (baseBitId === 48) { actualBitId = baseBitId + 1; // 4그룹: Offset 1 (49) } else if (baseBitId >= 50 && baseBitId <= 58) { actualBitId = baseBitId + 9; // 5그룹: Offset 9 (59~67) } } await sendHmiBitDirect(actualBitId); } // 실제 HTTP 통신으로 비트 명령 전송 async function sendHmiBitDirect(bitId) { if (!currentSelectedEquipment || equipmentControlsLocked) return; try { const payload = [[bitId, 1]]; // 제어 패킷 [[ID, 1]] (모멘터리 비트) const response = await fetch(`${BACKEND_URL}/api/equipment/${currentSelectedEquipment.id}/control/m`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (!response.ok) { console.error(`Control failed for bit ${bitId}`); } else { const result = await response.json(); pendingControlCommands.set(result.command_id, `M${bitId} 비트 제어`); } } catch (e) { console.error("비트 전송 실패:", e); } } // HMI 설정 레지스터 전송 (D영역 설정) async function sendHmiRegister(baseRegId, inputElementId) { if (!currentSelectedEquipment || equipmentControlsLocked) return; const inputElement = document.getElementById(inputElementId); if (!inputElement) return; const rawVal = parseFloat(inputElement.value); if (!Number.isFinite(rawVal)) return; // A/B 그룹에 따른 쓰기 레지스터 ID 오프셋 계산 (A: D0, D1 / B: D2, D3) const isGroupB = (currentActiveGroup === 'B'); let actualRegId = baseRegId; if (isGroupB) { actualRegId = baseRegId + 2; // Offset = 2 } try { const payload = [[actualRegId, rawVal]]; const response = await fetch(`${BACKEND_URL}/api/equipment/${currentSelectedEquipment.id}/control/d`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (response.ok) { const result = await response.json(); pendingControlCommands.set(result.command_id, `D${actualRegId} 설정`); inputElement.value = ""; // 입력창 초기화 (접수 팝업은 조작 편의를 위해 표시하지 않음) } else { alert("전송 실패"); } } catch (e) { console.error("수치 전송 실패:", e); } } // 모달 다이얼로그 제어 function openAddModal() { document.getElementById("add-equipment-modal").style.display = "flex"; } function closeAddModal() { document.getElementById("add-equipment-modal").style.display = "none"; document.getElementById("add-equipment-form").reset(); } // 새 설비 저장 async function saveEquipment(event) { event.preventDefault(); const eqId = document.getElementById("eq-id").value; const name = document.getElementById("eq-name").value; const type = document.getElementById("eq-type").value; const ip = document.getElementById("eq-ip").value; const payload = { equipment_id: eqId, name: name, type: type, ip_address: ip, line_prefix: "line1" // 기본 기본라인 할당 }; try { const response = await fetch(`${BACKEND_URL}/api/equipment`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (response.ok) { closeAddModal(); loadEquipmentCards(); } else { alert("중복된 ID이거나 등록 정보가 올바르지 않습니다."); } } catch (e) { console.error("Save equipment error:", e); } } // 수동 Full-Sync 초기화 요청 전송 async function triggerManualSync() { if (!currentSelectedEquipment) return; if (!confirm("게이트웨이의 내부 캐시를 초기화하고 PLC로부터 전체 데이터를 강제 수신(Full-Sync)하시겠습니까?")) return; try { const response = await fetch(`${BACKEND_URL}/api/equipment/${currentSelectedEquipment.id}/sync`, { method: 'POST' }); if (response.ok) { setEquipmentControlsLocked(true); alert("Full-Sync 초기화 명령이 전송되었습니다. 게이트웨이가 즉시 전체 상태를 갱신합니다."); } else { alert("초기화 명령 전송 실패"); } } catch (e) { console.error("Manual sync request failed:", e); alert("서버 통신 오류가 발생했습니다."); } }